Files
deploy-felhom-compose/controller/internal/stacks/deploy.go
T
admin 8130c344cc feat: deployed app removal + missing field injection (v0.19.0)
Add "Eltávolítás" to remove deployed (non-orphaned) stacks — reverts
them to "Nincs telepítve" while preserving templates for redeploy.
Modal offers HDD data and backup data cleanup choices.

Auto-inject missing deploy fields (secrets, domains) into existing
app.yaml when templates are updated via sync or on controller startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:01:21 +01:00

526 lines
16 KiB
Go

package stacks
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"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.
// 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, memErr := system.GetTotalMemoryMB()
if memErr != nil {
m.logger.Printf("[WARN] Cannot read system memory: %v — skipping memory check", memErr)
} else {
usableMB := totalMB - reservedMB
currentReqMB, currentLimitMB := m.CommittedMemory()
newReqMB := ParseMemoryMB(meta.Resources.MemRequest)
newLimitMB := ParseMemoryMB(meta.Resources.MemLimit)
m.logger.Printf("[INFO] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, committed_req=%dMB, new_req=%dMB, remaining=%dMB",
totalMB, reservedMB, usableMB, currentReqMB, newReqMB, usableMB-currentReqMB-newReqMB)
// Hard block: requests exceed usable memory
if newReqMB > 0 && currentReqMB+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 már foglalt, %d MB rendszer számára fenntartva)",
newReqMB,
usableMB-currentReqMB,
totalMB,
currentReqMB,
reservedMB,
)
}
// Soft warning: limits exceed total (overcommit)
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 "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":
// 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); 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 BEFORE compose up so the UI reflects
// "deployed" immediately (compose up can take 30-60s for image pulls).
// If compose up fails, we revert both disk and in-memory state below.
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
start := time.Now()
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
if composeErr != nil {
m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", req.StackName, time.Since(start).Seconds(), composeErr)
// Revert in-memory state
m.mu.Lock()
if s, ok := m.stacks[req.StackName]; ok {
s.Deployed = false
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)
return "", fmt.Errorf("docker compose up failed: %w", composeErr)
}
m.logger.Printf("[INFO] Stack %s deployed successfully (took %.1fs)", req.StackName, time.Since(start).Seconds())
// Post-deploy container state check (async, non-blocking)
deployEnv := m.stackEnv(stackDir)
m.logPostStartStatus(req.StackName, stackDir, deployEnv)
return deployWarning, 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
}
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)
}
_, 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
if err := SaveAppConfig(stackDir, appCfg); 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)
}
// --- 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) 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"
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)
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); 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
}