package stacks import ( "crypto/rand" "encoding/hex" "fmt" "math/big" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // 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: // 1. Load metadata (.felhom.yml) to know what fields exist // 2. Auto-generate secrets for secret/password fields without user values // 3. Auto-fill domain from controller config // 4. Merge with user-provided values // 5. Save app.yaml // 6. Run docker compose up -d with env vars func (m *Manager) DeployStack(req DeployRequest) 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) } // 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 "secret": // Always auto-generate, user never sees these generated, err := generateValue(field.Generate) if err != nil { return fmt.Errorf("generating %s: %w", field.EnvVar, err) } value = generated case "password": // Use user value if provided, otherwise generate if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" { value = userVal } else if field.Generate != "" { generated, err := generateValue(field.Generate) if err != nil { return fmt.Errorf("generating %s: %w", field.EnvVar, err) } value = generated } 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("required field %q (%s) is empty", field.Label, field.EnvVar) } // Validate path fields exist 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); err != nil { return fmt.Errorf("saving app config: %w", err) } m.logger.Printf("[INFO] Deploying stack %s with %d env vars", req.StackName, len(env)) // Run docker compose up -d _, err := m.composeExecWithEnv(stackDir, env, "up", "-d") if err != nil { // Deployment failed — keep app.yaml for debugging but mark as not deployed appCfg.Deployed = false _ = SaveAppConfig(stackDir, appCfg) return fmt.Errorf("docker compose up failed: %w", err) } m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName) return 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) } // Apply changes, respecting locked fields lockedSet := make(map[string]bool) for _, f := range appCfg.LockedFields { lockedSet[f] = true } 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); err != nil { return fmt.Errorf("saving updated config: %w", err) } // Restart with new env _, 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) { // Build env slice: start with os env, then add our vars cmdEnv := os.Environ() for k, v := range env { cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v)) } // Always inject DOMAIN from controller config 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 } // --- App config persistence --- // LoadAppConfig reads app.yaml from a stack directory. // Returns nil if the file doesn't exist. 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 } // SaveAppConfig writes app.yaml to a stack directory. func SaveAppConfig(stackDir string, cfg *AppConfig) error { data, err := yaml.Marshal(cfg) 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 } // --- Secret generation --- const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // generateValue creates a random value based on the generator spec. // Formats: "password:N", "hex:N", "static:VALUE" 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) } genType := parts[0] param := parts[1] switch genType { case "password": length := 0 if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 { return "", fmt.Errorf("invalid password length: %q", param) } return randomAlphanumeric(length) case "hex": byteLen := 0 if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 { return "", fmt.Errorf("invalid hex length: %q", param) } 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 "static": return param, nil default: return "", fmt.Errorf("unknown generator type: %q", genType) } } 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 }