package stacks import ( "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "math/big" "os" "path/filepath" "regexp" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "gopkg.in/yaml.v3" ) // reservedSubdomains lists subdomains reserved for system use. var reservedSubdomains = map[string]bool{ "felhom": true, // controller dashboard "files": true, // filebrowser "traefik": true, // reverse proxy "api": true, "www": true, "mail": true, "smtp": true, "ftp": true, "admin": true, "portal": true, "ssh": true, "ns1": true, "ns2": true, "mx": true, "pop": true, "imap": true, } var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // validateSubdomain checks that a subdomain is DNS-safe. func validateSubdomain(s string) error { if s == "" { return fmt.Errorf("az aldomain nem lehet üres") } if len(s) > 63 { return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet") } if !subdomainRe.MatchString(s) { return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel") } return nil } // SubdomainInUse checks if a subdomain is already used by any deployed stack // other than excludeStack. func (m *Manager) SubdomainInUse(subdomain, excludeStack string) bool { m.mu.RLock() defer m.mu.RUnlock() for name, stack := range m.stacks { if name == excludeStack || !stack.Deployed { continue } stackDir := filepath.Dir(stack.ComposePath) appCfg := LoadAppConfig(stackDir) if appCfg == nil { continue } // Check stored SUBDOMAIN first if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain { return true } // Backward compat: check metadata subdomain for apps without SUBDOMAIN in env if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub { if stack.Meta.Subdomain == subdomain { return true } } } return false } // AppConfig holds the per-app deployment configuration. // Saved as app.yaml in each stack directory after first deployment. type AppConfig struct { Deployed bool `yaml:"deployed" json:"deployed"` DeployedAt string `yaml:"deployed_at" json:"deployed_at"` Env map[string]string `yaml:"env" json:"env"` LockedFields []string `yaml:"locked_fields" json:"locked_fields"` } // DeployRequest contains the user-provided values from the deploy form. type DeployRequest struct { StackName string `json:"stack_name"` Values map[string]string `json:"values"` // env_var -> user-provided value } // DeployStack handles first-time deployment of an app. // Returns a warning message (empty if none) and an error if deployment is blocked. // 1. Check available memory against app requirements // 2. Load metadata (.felhom.yml) to know what fields exist // 3. Auto-generate secrets for secret fields (hidden from user) // 4. Auto-fill domain from controller config // 5. Validate all user-provided values (password, path, required fields) // 6. Save app.yaml // 7. Run docker compose up -d with env vars // 8. Update in-memory stack state func (m *Manager) DeployStack(req DeployRequest) (string, error) { stack, ok := m.GetStack(req.StackName) if !ok { return "", fmt.Errorf("stack %q not found", req.StackName) } stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) // Check if already deployed existing := LoadAppConfig(stackDir) if existing != nil && existing.Deployed { return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName) } // --- Memory validation --- var deployWarning string reservedMB := m.cfg.System.ReservedMemoryMB totalMB, usedMB, memErr := system.GetMemoryMB() if memErr != nil { m.logger.Printf("[WARN] Cannot read system memory: %v — skipping memory check", memErr) } else { usableMB := totalMB - reservedMB newReqMB := ParseMemoryMB(meta.Resources.MemRequest) m.logger.Printf("[INFO] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, real_used=%dMB, new_req=%dMB, remaining=%dMB", totalMB, reservedMB, usableMB, usedMB, newReqMB, usableMB-usedMB-newReqMB) // Hard block: real used + new request exceeds usable memory if newReqMB > 0 && usedMB+newReqMB > usableMB { return "", fmt.Errorf( "Nincs elég memória az alkalmazás telepítéséhez. "+ "Szükséges: %d MB, Elérhető: %d MB "+ "(összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)", newReqMB, usableMB-usedMB, totalMB, usedMB, reservedMB, ) } // Soft warning: limits exceed total (overcommit) _, currentLimitMB := m.CommittedMemory() newLimitMB := ParseMemoryMB(meta.Resources.MemLimit) if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB { deployWarning = "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. " + "Normál használat mellett ez nem okoz problémát." } } // Debug: log received values (redact passwords/secrets) m.logger.Printf("[DEBUG] Deploy %s: received %d user values", req.StackName, len(req.Values)) for k, v := range req.Values { if strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") { m.logger.Printf("[DEBUG] %s = [REDACTED, len=%d]", k, len(v)) } else { m.logger.Printf("[DEBUG] %s = %q", k, v) } } // Build the full env map env := make(map[string]string) var lockedFields []string for _, field := range meta.DeployFields { var value string switch field.Type { case "domain": // Auto-fill from controller config value = m.cfg.Customer.Domain case "subdomain": // User-editable with default from metadata if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" { value = strings.ToLower(strings.TrimSpace(userVal)) } else if field.Default != "" { value = field.Default } if err := validateSubdomain(value); err != nil { return "", err } if reservedSubdomains[value] { return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value) } if m.SubdomainInUse(value, req.StackName) { return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value) } case "secret": // Use pre-generated value if provided by the deploy page (same value the user saw), // otherwise fall back to generating a fresh one. if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" { value = userVal } else { generated, err := generateValue(field.Generate) if err != nil { return "", fmt.Errorf("generating %s: %w", field.EnvVar, err) } value = generated } case "password": // Password fields MUST be filled by the user (via typing or Generálás button). // We never silently auto-generate — the user needs to know their password. if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" { value = userVal } else { return "", fmt.Errorf("a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label) } default: // text, path, select, boolean — use user value or default if userVal, ok := req.Values[field.EnvVar]; ok { value = userVal } else if field.Default != "" { value = field.Default } } // Validate required fields if field.Required && value == "" { return "", fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar) } // Validate path fields exist on disk (inside the container's filesystem) if field.Type == "path" && value != "" { if _, err := os.Stat(value); os.IsNotExist(err) { return "", fmt.Errorf("path %q does not exist for field %q", value, field.Label) } } if value != "" { env[field.EnvVar] = value } if field.LockedAfterDeploy { lockedFields = append(lockedFields, field.EnvVar) } } // Save app.yaml appCfg := &AppConfig{ Deployed: true, DeployedAt: time.Now().UTC().Format(time.RFC3339), Env: env, LockedFields: lockedFields, } if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return "", fmt.Errorf("saving app config: %w", err) } // Debug: log final env var keys (not values) envKeys := make([]string, 0, len(env)) for k := range env { envKeys = append(envKeys, k) } m.logger.Printf("[INFO] Deploying stack %s with %d env vars: [%s]", req.StackName, len(env), strings.Join(envKeys, ", ")) // Check which images are available locally before pulling if m.isDebug() { m.checkLocalImages(req.StackName, stackDir) } // Update in-memory stack state and mark as deploying. The compose-up // runs in a goroutine so the API can return immediately and the UI // shows progress via polling (image pull can take 30-60s). m.mu.Lock() if s, ok := m.stacks[req.StackName]; ok { s.Deployed = true s.Deploying = true s.DeployError = "" s.AppConfig = appCfg } m.mu.Unlock() // Run docker compose up -d asynchronously go m.runComposeDeploy(req.StackName, stackDir, env, appCfg) return deployWarning, nil } // runComposeDeploy executes docker compose up -d in background. // On success it refreshes status; on failure it reverts the deploy state. func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, appCfg *AppConfig) { start := time.Now() _, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d") if composeErr != nil { m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr) // Revert in-memory state m.mu.Lock() if s, ok := m.stacks[name]; ok { s.Deployed = false s.Deploying = false s.DeployError = composeErr.Error() s.AppConfig = nil } m.mu.Unlock() // Revert disk state — keep app.yaml for debugging but mark as not deployed appCfg.Deployed = false _ = SaveAppConfig(stackDir, appCfg, nil, nil) return } m.logger.Printf("[INFO] Stack %s deployed successfully (took %.1fs)", name, time.Since(start).Seconds()) // Clear deploying flag m.mu.Lock() if s, ok := m.stacks[name]; ok { s.Deploying = false } m.mu.Unlock() // Post-deploy container state check (async, non-blocking) deployEnv := m.stackEnv(stackDir) m.logPostStartStatus(name, stackDir, deployEnv) _ = m.RefreshStatus() } // UpdateStackConfig updates non-locked fields for a deployed stack. func (m *Manager) UpdateStackConfig(name string, values map[string]string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } stackDir := filepath.Dir(stack.ComposePath) appCfg := LoadAppConfig(stackDir) if appCfg == nil || !appCfg.Deployed { return fmt.Errorf("stack %q is not deployed yet", name) } if appCfg.Env == nil { appCfg.Env = make(map[string]string) } lockedSet := make(map[string]bool) for _, f := range appCfg.LockedFields { lockedSet[f] = true } meta := LoadMetadata(stackDir) for key, val := range values { if lockedSet[key] { return fmt.Errorf("field %q is locked and cannot be changed after deployment", key) } appCfg.Env[key] = val } if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving updated config: %w", err) } _, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d") if err != nil { return fmt.Errorf("restarting with new config: %w", err) } m.logger.Printf("[INFO] Stack %s config updated and restarted", name) return m.RefreshStatus() } // composeExecWithEnv runs a compose command with custom env vars injected. func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) { cmdEnv := os.Environ() for k, v := range env { cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v)) } cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain)) return m.composeExecCustomEnv(dir, cmdEnv, args...) } // GetDeployFields returns the deployment fields for a stack (for the deploy form). func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) { stack, ok := m.GetStack(name) if !ok { return nil, nil, fmt.Errorf("stack %q not found", name) } stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) appCfg := LoadAppConfig(stackDir) return &meta, appCfg, nil } // UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed. // Only updates env vars that are listed in the metadata's optional_config sections. func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error { stack, ok := m.GetStack(stackName) if !ok { return fmt.Errorf("stack %q not found", stackName) } // Build a set of allowed env vars from optional_config allowed := make(map[string]bool) for _, group := range stack.Meta.OptionalConfig { for _, field := range group.Fields { allowed[field.EnvVar] = true } } if len(allowed) == 0 { return fmt.Errorf("no optional config fields defined for %s", stackName) } // Load existing app.yaml (or create empty one) stackDir := filepath.Dir(stack.ComposePath) appCfg := LoadAppConfig(stackDir) if appCfg == nil { appCfg = &AppConfig{ Env: make(map[string]string), } } if appCfg.Env == nil { appCfg.Env = make(map[string]string) } // Update only allowed env vars changed := false for key, val := range values { if !allowed[key] { m.logger.Printf("[WARN] Ignoring non-optional env var: %s", key) continue } if appCfg.Env[key] != val { appCfg.Env[key] = val changed = true m.logger.Printf("[INFO] Updated optional config %s for %s", key, stackName) } } if !changed { return nil } // Save app.yaml meta := LoadMetadata(stackDir) if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving app config: %w", err) } m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName) // If deployed, recreate containers to pick up new env vars // (docker compose restart does NOT pick up new env vars — must use up -d) if stack.Deployed { m.logger.Printf("[INFO] Restarting %s to apply new optional config", stackName) env := m.stackEnv(stackDir) if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil { return fmt.Errorf("restart after config update: %w", err) } m.logPostStartStatus(stackName, stackDir, env) } return m.RefreshStatus() } // LoadAppConfigByName reads app.yaml for a named stack. Returns nil if not found. func (m *Manager) LoadAppConfigByName(stackName string) *AppConfig { stack, ok := m.GetStack(stackName) if !ok { return nil } stackDir := filepath.Dir(stack.ComposePath) return LoadAppConfig(stackDir) } // PreviewDeployValues generates the auto-field values that will be used at deploy time: // domain from controller config and freshly-generated secrets. These values are shown // on the deploy page so the user can see (and note down) their passwords before deploying. // Pass them back in DeployRequest.Values so the same values are saved to app.yaml. func (m *Manager) PreviewDeployValues(name string) (map[string]string, error) { stack, ok := m.GetStack(name) if !ok { return nil, fmt.Errorf("stack %q not found", name) } stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) result := make(map[string]string) for _, field := range meta.DeployFields { switch field.Type { case "domain": // Show the base domain. The subdomain is now a separate user-editable field. result[field.EnvVar] = m.cfg.Customer.Domain case "secret": if field.Generate == "" { continue } val, err := generateValue(field.Generate) if err != nil { return nil, fmt.Errorf("generating preview for %s: %w", field.EnvVar, err) } result[field.EnvVar] = val } } return result, nil } // --- App config persistence --- func LoadAppConfig(stackDir string) *AppConfig { path := filepath.Join(stackDir, "app.yaml") data, err := os.ReadFile(path) if err != nil { return nil } cfg := &AppConfig{} if err := yaml.Unmarshal(data, cfg); err != nil { return nil } return cfg } func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error { // Clone env and encrypt sensitive values saveCfg := &AppConfig{ Deployed: cfg.Deployed, DeployedAt: cfg.DeployedAt, Env: make(map[string]string, len(cfg.Env)), LockedFields: cfg.LockedFields, } sensitiveSet := make(map[string]bool, len(sensitiveVars)) for _, v := range sensitiveVars { sensitiveSet[v] = true } for k, v := range cfg.Env { if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" { if enc, err := crypto.Encrypt(encKey, v); err == nil { saveCfg.Env[k] = enc continue } } saveCfg.Env[k] = v } data, err := yaml.Marshal(saveCfg) if err != nil { return fmt.Errorf("marshaling app config: %w", err) } path := filepath.Join(stackDir, "app.yaml") header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n" content := header + string(data) if err := os.WriteFile(path, []byte(content), 0600); err != nil { return fmt.Errorf("writing %s: %w", path, err) } return nil } // LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values. func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig { cfg := LoadAppConfig(stackDir) if cfg == nil || encKey == nil { return cfg } cfg.Env = crypto.DecryptMap(encKey, cfg.Env) return cfg } // SensitiveEnvVars returns the env var names for secret/password fields from metadata. func SensitiveEnvVars(meta *Metadata) []string { var vars []string for _, f := range meta.DeployFields { if f.Type == "secret" || f.Type == "password" { vars = append(vars, f.EnvVar) } } return vars } // --- Secret generation --- const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func generateValue(spec string) (string, error) { if spec == "" { return "", fmt.Errorf("empty generator spec") } parts := strings.SplitN(spec, ":", 2) if len(parts) != 2 { return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec) } switch parts[0] { case "password": length := 0 if _, err := fmt.Sscanf(parts[1], "%d", &length); err != nil || length <= 0 { return "", fmt.Errorf("invalid password length: %q", parts[1]) } return randomAlphanumeric(length) case "hex": byteLen := 0 if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 { return "", fmt.Errorf("invalid hex length: %q", parts[1]) } b := make([]byte, byteLen) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("reading random bytes: %w", err) } return hex.EncodeToString(b), nil case "base64key": byteLen := 0 if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 { return "", fmt.Errorf("invalid base64key length: %q", parts[1]) } b := make([]byte, byteLen) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("reading random bytes: %w", err) } return "base64:" + base64.StdEncoding.EncodeToString(b), nil case "static": return parts[1], nil default: return "", fmt.Errorf("unknown generator type: %q", parts[0]) } } // InjectMissingFields checks deployed stacks for new deploy_fields that are not // yet in app.yaml and auto-generates values for secret/domain fields. // Called after sync (for updated stacks) and on startup (for all deployed stacks). func (m *Manager) InjectMissingFields(stackNames []string) { for _, name := range stackNames { stack, ok := m.GetStack(name) if !ok { continue } stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) appCfg := LoadAppConfig(stackDir) if appCfg == nil || !appCfg.Deployed { continue } var injected []string for _, field := range meta.DeployFields { if _, exists := appCfg.Env[field.EnvVar]; exists { continue // already present } switch field.Type { case "secret": if field.Generate == "" { m.logger.Printf("[WARN] Stack %s: new secret field %s has no generator — skipping", name, field.EnvVar) continue } value, err := generateValue(field.Generate) if err != nil { m.logger.Printf("[ERROR] Stack %s: failed to generate %s: %v", name, field.EnvVar, err) continue } appCfg.Env[field.EnvVar] = value if field.LockedAfterDeploy { appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar) } injected = append(injected, field.EnvVar) case "domain": appCfg.Env[field.EnvVar] = m.cfg.Customer.Domain if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) { appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar) } injected = append(injected, field.EnvVar) case "subdomain": // Auto-fill from field default or metadata subdomain val := field.Default if val == "" { val = meta.Subdomain } if val == "" { m.logger.Printf("[WARN] Stack %s: new subdomain field %s has no default — skipping", name, field.EnvVar) continue } appCfg.Env[field.EnvVar] = val if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) { appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar) } injected = append(injected, field.EnvVar) default: m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type) } } if len(injected) > 0 { if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { m.logger.Printf("[ERROR] Stack %s: failed to save app.yaml after injection: %v", name, err) continue } m.logger.Printf("[SYNC] Stack %s: injected missing fields: %s", name, strings.Join(injected, ", ")) } } } func containsStr(slice []string, s string) bool { for _, v := range slice { if v == s { return true } } return false } func randomAlphanumeric(length int) (string, error) { result := make([]byte, length) for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars)))) if err != nil { return "", err } result[i] = alphanumChars[n.Int64()] } return string(result), nil }