5fdd2039fd
The previous commit landed only the new test/badge files: a 'git stash' used to compare REUSE.md ref-check output silently dropped the staged index, so every modification to an existing file was left behind and that commit does not build. This adds the metadata field, the predicates, the fail-closed deploy gate, the catalog filter, the funcmap entries, the template edits and the docs that those tests exercise.
1010 lines
34 KiB
Go
1010 lines
34 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/appbackup"
|
|
"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"`
|
|
// EmailEnabled is the per-app app-email toggle (default off). When on AND the global toggle is
|
|
// on AND the app has an smtp_mapping, the controller injects the relay SMTP env at compose time.
|
|
EmailEnabled bool `yaml:"email_enabled,omitempty" json:"email_enabled,omitempty"`
|
|
}
|
|
|
|
// 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)
|
|
|
|
// --- Lifecycle gate (defence in depth) ---
|
|
// The API handler refuses this first, with the customer-facing Hungarian message. This second
|
|
// check exists because DeployStack is the manager-level choke point EVERY caller goes through,
|
|
// and metadata is already loaded here — so a future caller that does not route through the API
|
|
// cannot bypass the rule by simply not knowing about it. Deliberately before the first mutation.
|
|
if !meta.CanInstall() {
|
|
clearDeploying()
|
|
return "", fmt.Errorf("stack %q is not installable (lifecycle: %s)", req.StackName, meta.EffectiveLifecycle())
|
|
}
|
|
|
|
// --- Memory validation ---
|
|
var deployWarning string
|
|
reservedMB := m.cfg.System.ReservedMemoryMB
|
|
totalMB, usedMB, memErr := system.GetMemoryMB()
|
|
// F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own
|
|
// cgroup (the cap is on the LXC ancestor). Prefer the guest cap from the Docker daemon (runs in the
|
|
// LXC). And use the controller's OWN committed-memory accounting for "used" — accurate and cheap —
|
|
// rather than host /proc RSS, which is unobservable-per-guest and would otherwise make this guard
|
|
// either never fire (host total) or always fire (host used > guest cap).
|
|
if gt, ok := system.GuestMemTotalMB(); ok && gt > 0 {
|
|
totalMB = gt
|
|
memErr = nil
|
|
}
|
|
if committedReqMB, _ := m.CommittedMemory(); committedReqMB > 0 || memErr == nil {
|
|
usedMB = committedReqMB
|
|
}
|
|
if memErr != nil {
|
|
m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr)
|
|
} else {
|
|
usableMB := totalMB - reservedMB
|
|
newReqMB := ParseMemoryMB(meta.Resources.MemRequest)
|
|
|
|
m.logger.Printf("[INFO] [stacks] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, committed_used=%dMB, new_req=%dMB, remaining=%dMB",
|
|
totalMB, reservedMB, usableMB, usedMB, newReqMB, usableMB-usedMB-newReqMB)
|
|
|
|
// Hard block: committed + 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.
|
|
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
|
|
// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
|
|
// A crash/power-loss during the image-pull window must NOT leave a
|
|
// ghost-deployed stack on disk (Deployed:true with no containers), which
|
|
// DeployStack would then refuse to redeploy. The IN-MEMORY Deployed flag is
|
|
// still set true below to preserve the "no stale Telepítés button during
|
|
// pull" UX; only the durable record waits for success.
|
|
appCfg := &AppConfig{
|
|
Deployed: true, // in-memory truth (see below); the disk write overrides to false
|
|
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Env: env,
|
|
LockedFields: lockedFields,
|
|
}
|
|
|
|
diskCfg := *appCfg
|
|
diskCfg.Deployed = false // transitional: env saved, not yet marked deployed
|
|
if err := SaveAppConfig(stackDir, &diskCfg, 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] [stacks] 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] [stacks] 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] [stacks] Stack %s deployed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
|
|
|
// CTRL-T2-1: compose up -d succeeded — only NOW mark deployed on disk.
|
|
// (DeployStack wrote the env with Deployed:false; flip it true here so the
|
|
// durable record matches reality and survives a restart.)
|
|
meta := LoadMetadata(stackDir)
|
|
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
|
// Running but not durably recorded as deployed. Revert so the customer
|
|
// can cleanly redeploy rather than be stuck with a half-recorded stack.
|
|
m.logger.Printf("[ERROR] [stacks] Stack %s: compose succeeded but persisting deployed state failed: %v — reverting", name, err)
|
|
m.mu.Lock()
|
|
if s, ok := m.stacks[name]; ok {
|
|
s.Deployed = false
|
|
s.Deploying = false
|
|
s.DeployError = "deploy succeeded but state could not be saved: " + err.Error()
|
|
s.AppConfig = nil
|
|
}
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// 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 {
|
|
m.logger.Printf("[INFO] [stacks] Updating config for stack %s", name)
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig called: name=%q, %d values to update", name, len(values))
|
|
}
|
|
|
|
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)
|
|
var changedKeys []string
|
|
for key, val := range values {
|
|
if lockedSet[key] {
|
|
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
|
|
}
|
|
if appCfg.Env[key] != val {
|
|
changedKeys = append(changedKeys, key)
|
|
}
|
|
appCfg.Env[key] = val
|
|
}
|
|
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
|
|
}
|
|
|
|
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] [stacks] Stack %s config updated and restarted", name)
|
|
return m.RefreshStatus()
|
|
}
|
|
|
|
// RedeployFromEnv writes app.yaml from the given FULL env (encrypting secret fields) and (re-)deploys
|
|
// the stack with `docker compose up -d`, which re-pulls the pinned image. Used by the restore-from-unit
|
|
// flow (Phase 2b): unlike UpdateStackConfig it sets the full env INCLUDING locked secrets — which were
|
|
// recovered from the guest's own app.yaml, never regenerated. Caller is responsible for the gate.
|
|
func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
|
|
if err := m.PersistUnitRedeployConfig(name, env); err != nil {
|
|
return err
|
|
}
|
|
stack, ok := m.GetStack(name)
|
|
if !ok {
|
|
return fmt.Errorf("stack %q not found", name)
|
|
}
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
deployEnv := m.stackEnv(stackDir) // decrypts secrets back for compose
|
|
if _, err := m.composeExecCustomEnv(stackDir, deployEnv, "up", "-d"); err != nil {
|
|
return fmt.Errorf("compose up: %w", err)
|
|
}
|
|
m.logPostStartStatus(name, stackDir, deployEnv)
|
|
return m.RefreshStatus()
|
|
}
|
|
|
|
// PersistUnitRedeployConfig is the PERSIST half of RedeployFromEnv: it writes app.yaml from the full
|
|
// env (encrypting secret fields, recording locked fields) and marks the stack deployed in memory —
|
|
// and starts NOTHING.
|
|
//
|
|
// Split out for R-47. The restore paths must place the app's definition and then bring up only the
|
|
// database service for the dump replay; calling RedeployFromEnv there would end in a full
|
|
// `compose up -d` BEFORE the replay, which is exactly the race (H4) this work removes.
|
|
// RedeployFromEnv itself is this function plus the unchanged up-and-report tail, so its public
|
|
// behaviour is identical to before the split.
|
|
func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string) error {
|
|
stack, ok := m.GetStack(name)
|
|
if !ok {
|
|
return fmt.Errorf("stack %q not found", name)
|
|
}
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
meta := LoadMetadata(stackDir)
|
|
|
|
cfg := &AppConfig{
|
|
Deployed: true,
|
|
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Env: env,
|
|
}
|
|
for _, f := range meta.DeployFields {
|
|
if f.LockedAfterDeploy {
|
|
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
|
|
}
|
|
}
|
|
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
|
return fmt.Errorf("saving app config: %w", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if s, ok := m.stacks[name]; ok {
|
|
s.Deployed = true
|
|
s.AppConfig = cfg
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.logger.Printf("[INFO] [stacks] Redeploying %s from recovery unit with %d env vars", name, len(env))
|
|
return nil
|
|
}
|
|
|
|
// composeExecWithEnv runs a compose command with custom env vars injected. Used by the initial deploy
|
|
// path (DeployStack), which builds env from the deploy values rather than from app.yaml via stackEnv —
|
|
// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves
|
|
// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root.
|
|
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))
|
|
cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
|
|
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
|
}
|
|
|
|
// withUserdataPath appends USERDATA_PATH=<hdd>/userdata to a "K=V" env slice when hdd is non-empty.
|
|
// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial
|
|
// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus
|
|
// root-owned dir at the container root.
|
|
func withUserdataPath(cmdEnv []string, hdd string) []string {
|
|
if hdd != "" {
|
|
cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd))
|
|
}
|
|
return cmdEnv
|
|
}
|
|
|
|
// 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 {
|
|
m.logger.Printf("[INFO] [stacks] Updating optional config for stack %s", stackName)
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig called: stack=%q, %d values provided", stackName, len(values))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if m.isDebug() {
|
|
allowedKeys := make([]string, 0, len(allowed))
|
|
for k := range allowed {
|
|
allowedKeys = append(allowedKeys, k)
|
|
}
|
|
m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig %s: allowed fields: [%s]", stackName, strings.Join(allowedKeys, ", "))
|
|
}
|
|
|
|
// 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] [stacks] Ignoring non-optional env var: %s", key)
|
|
continue
|
|
}
|
|
if appCfg.Env[key] != val {
|
|
appCfg.Env[key] = val
|
|
changed = true
|
|
m.logger.Printf("[INFO] [stacks] 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] [stacks] 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] [stacks] 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 {
|
|
log.Printf("[WARN] [stacks] LoadAppConfig: %v", err)
|
|
return nil
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
|
|
encryptedCount := 0
|
|
// 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,
|
|
EmailEnabled: cfg.EmailEnabled,
|
|
}
|
|
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 != "" {
|
|
enc, err := crypto.Encrypt(encKey, v)
|
|
if err != nil {
|
|
// H10 (fail-closed): NEVER persist a sensitive value in plaintext.
|
|
// Earlier code logged a WARN and fell through to a plaintext write;
|
|
// that leaked the secret to disk. Abort the save instead — callers
|
|
// already propagate this error and the deploy fails cleanly.
|
|
return fmt.Errorf("encrypting sensitive env var %q (refusing to persist plaintext): %w", k, err)
|
|
}
|
|
saveCfg.Env[k] = enc
|
|
encryptedCount++
|
|
continue
|
|
}
|
|
saveCfg.Env[k] = v
|
|
}
|
|
|
|
log.Printf("[DEBUG] [stacks] SaveAppConfig: saving %s — %d env vars, %d encrypted, %d sensitive fields",
|
|
stackDir, len(saveCfg.Env), encryptedCount, len(sensitiveVars))
|
|
|
|
data, err := yaml.Marshal(saveCfg)
|
|
if err != nil {
|
|
log.Printf("[ERROR] [stacks] SaveAppConfig: failed to marshal config for %s: %v", stackDir, err)
|
|
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 {
|
|
log.Printf("[ERROR] [stacks] SaveAppConfig: failed to save %s: %v", path, err)
|
|
return fmt.Errorf("writing %s: %w", tmpPath, err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
log.Printf("[ERROR] [stacks] SaveAppConfig: failed to save %s: %v", path, err)
|
|
return fmt.Errorf("renaming %s to %s: %w", tmpPath, path, err)
|
|
}
|
|
log.Printf("[INFO] [stacks] SaveAppConfig: saved config for %s", filepath.Base(stackDir))
|
|
return nil
|
|
}
|
|
|
|
// LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values.
|
|
func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig {
|
|
cfg := LoadAppConfig(stackDir)
|
|
if cfg == nil {
|
|
return cfg
|
|
}
|
|
if encKey == nil {
|
|
log.Printf("[DEBUG] [stacks] LoadAppConfigDecrypted: no encryption key, returning raw config for %s", stackDir)
|
|
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])
|
|
}
|
|
}
|
|
|
|
// GenerateSecretForField generates a replacement value for a stack's RESETTABLE secret deploy-field
|
|
// (O4: the restore-from-unit path uses this — via backup.SetSecretGenerator — when a resettable
|
|
// secret cannot be recovered from the guest's app.yaml, so the app redeploys with a fresh credential
|
|
// instead of a blank one that fails compose-up).
|
|
//
|
|
// Returns ok=false when the field is unknown, has no generator spec, or — deliberately — is a
|
|
// DATA-ENCRYPTING key: data-keys are NEVER generated (regenerating one would render stored data
|
|
// unreadable; the restore's fail-closed gate refuses before this point, this is defense-in-depth).
|
|
// The generated VALUE is never logged — names only.
|
|
func (m *Manager) GenerateSecretForField(stackName, envVar string) (string, bool) {
|
|
s, ok := m.GetStack(stackName)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
meta := LoadMetadata(filepath.Dir(s.ComposePath))
|
|
for _, f := range meta.DeployFields {
|
|
if f.EnvVar != envVar {
|
|
continue
|
|
}
|
|
if f.DataKey {
|
|
m.logger.Printf("[WARN] [stacks] GenerateSecretForField(%s/%s): refusing — field is a data-encrypting key", stackName, envVar)
|
|
return "", false
|
|
}
|
|
if (f.Type != "secret" && f.Type != "password") || f.Generate == "" {
|
|
return "", false
|
|
}
|
|
value, err := generateValue(f.Generate)
|
|
if err != nil || value == "" {
|
|
m.logger.Printf("[ERROR] [stacks] GenerateSecretForField(%s/%s): generator %q failed: %v", stackName, envVar, f.Generate, err)
|
|
return "", false
|
|
}
|
|
return value, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// 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) {
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking %d stacks", len(stackNames))
|
|
}
|
|
|
|
count := 0
|
|
for _, name := range stackNames {
|
|
stack, ok := m.GetStack(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
count++
|
|
|
|
stackDir := filepath.Dir(stack.ComposePath)
|
|
meta := LoadMetadata(stackDir)
|
|
appCfg := LoadAppConfig(stackDir)
|
|
if appCfg == nil || !appCfg.Deployed {
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: skipping %s (not deployed or no app config)", name)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if m.isDebug() {
|
|
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking stack %s — %d deploy fields, %d existing env vars",
|
|
name, len(meta.DeployFields), len(appCfg.Env))
|
|
}
|
|
|
|
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] [stacks] 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] [stacks] 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] [stacks] 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] [stacks] 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] [stacks] Stack %s: failed to save app.yaml after injection: %v", name, err)
|
|
continue
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] Stack %s: injected missing fields: %s", name, strings.Join(injected, ", "))
|
|
}
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] InjectMissingFields: processed %d stacks", count)
|
|
}
|
|
|
|
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
|
|
}
|