db83db383c
Critical: watchdog mutex panic safety, SetGeoAppOverride nil guard, SSD-only app DB restore fallback. High: double deploy race (atomic Deploying flag), delete/remove during deploy guard, ScanStacks overwrite protection, FileBrowser mount mutex, PushEvent history, PushOnce error handling, DB dump sync+close before rename, restic retry fresh context, encrypt failure logging, cross-backup path traversal validation, deepCopyStack completeness. Security: constant-time API key comparison, login rate limiting (5/min), git credential masking in logs, storage path prefix traversal fix. Concurrency: MigrateEncryption lock ordering, SubdomainInUse I/O outside lock, scheduler late-registered jobs, SQLite WAL verification, metrics shutdown context, telemetry scan error logging, asset sync lock scope. Optimization: streaming file copy for DB dumps, restic stats dedup, atomic infra config copy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
782 lines
24 KiB
Go
782 lines
24 KiB
Go
package stacks
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"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 {
|
|
// Collect stack dirs and metadata under lock, then do I/O outside the lock.
|
|
type candidate struct {
|
|
dir string
|
|
metaSubdomain string
|
|
}
|
|
var candidates []candidate
|
|
|
|
m.mu.RLock()
|
|
for name, stack := range m.stacks {
|
|
if name == excludeStack || !stack.Deployed {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate{
|
|
dir: filepath.Dir(stack.ComposePath),
|
|
metaSubdomain: stack.Meta.Subdomain,
|
|
})
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
for _, c := range candidates {
|
|
appCfg := LoadAppConfig(c.dir)
|
|
if appCfg == nil {
|
|
continue
|
|
}
|
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
|
|
return true
|
|
}
|
|
if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub {
|
|
if c.metaSubdomain == 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) {
|
|
// Atomically check and set the Deploying flag to prevent concurrent deploys (H1 fix).
|
|
m.mu.Lock()
|
|
sPtr, sOk := m.stacks[req.StackName]
|
|
if !sOk {
|
|
m.mu.Unlock()
|
|
return "", fmt.Errorf("stack %q not found", req.StackName)
|
|
}
|
|
if sPtr.Deploying {
|
|
m.mu.Unlock()
|
|
return "", fmt.Errorf("stack %q is already being deployed — please wait", req.StackName)
|
|
}
|
|
if sPtr.Deployed {
|
|
m.mu.Unlock()
|
|
return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
|
|
}
|
|
sPtr.Deploying = true
|
|
sPtr.DeployError = ""
|
|
m.mu.Unlock()
|
|
|
|
// If any validation below fails, clear the Deploying flag.
|
|
clearDeploying := func() {
|
|
m.mu.Lock()
|
|
if s, ok := m.stacks[req.StackName]; ok {
|
|
s.Deploying = false
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
stack, ok := m.GetStack(req.StackName)
|
|
if !ok {
|
|
clearDeploying()
|
|
return "", fmt.Errorf("stack %q not found", req.StackName)
|
|
}
|
|
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
meta := LoadMetadata(stackDir)
|
|
|
|
// --- 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 {
|
|
clearDeploying()
|
|
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 {
|
|
clearDeploying()
|
|
return "", err
|
|
}
|
|
if reservedSubdomains[value] {
|
|
clearDeploying()
|
|
return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
|
|
}
|
|
if m.SubdomainInUse(value, req.StackName) {
|
|
clearDeploying()
|
|
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 {
|
|
clearDeploying()
|
|
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 {
|
|
clearDeploying()
|
|
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 == "" {
|
|
clearDeploying()
|
|
return "", fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
|
|
}
|
|
|
|
// Validate path fields exist on the host filesystem
|
|
if field.Type == "path" && value != "" {
|
|
if _, err := os.Stat(value); os.IsNotExist(err) {
|
|
clearDeploying()
|
|
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 {
|
|
clearDeploying()
|
|
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. Deploying was already set at the top (H1 fix).
|
|
// 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.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 and disk state
|
|
m.mu.Lock()
|
|
if s, ok := m.stacks[name]; ok {
|
|
s.Deployed = false
|
|
s.Deploying = false
|
|
s.DeployError = composeErr.Error()
|
|
s.AppConfig = nil
|
|
}
|
|
// Also revert the shared appCfg under lock (C03 fix)
|
|
appCfg.Deployed = false
|
|
m.mu.Unlock()
|
|
// Save reverted state to disk with encryption (H05 fix)
|
|
meta := LoadMetadata(stackDir)
|
|
_ = SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta))
|
|
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)
|
|
}
|
|
|
|
// Use stackEnv which loads decrypted values for docker compose (C01 fix).
|
|
// appCfg.Env may contain encrypted values from LoadAppConfig.
|
|
env := m.stackEnv(stackDir)
|
|
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); 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
|
|
} else {
|
|
// H10 fix: log encryption failure — value will be saved in plaintext.
|
|
log.Printf("[WARN] Failed to encrypt env var %q: %v — saving as plaintext", k, err)
|
|
}
|
|
}
|
|
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)
|
|
|
|
// Atomic write: write to .tmp then rename (H04 fix)
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
|
|
return fmt.Errorf("writing %s: %w", tmpPath, err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("renaming %s to %s: %w", tmpPath, 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
|
|
} |