This commit is contained in:
2026-02-13 21:15:00 +01:00
parent bcc7877c41
commit fd29e602e8
4 changed files with 134 additions and 124 deletions
+19 -30
View File
@@ -35,6 +35,7 @@ type DeployRequest struct {
// 4. Validate all user-provided values (password, path, required fields)
// 5. Save app.yaml
// 6. Run docker compose up -d with env vars
// 7. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) error {
stack, ok := m.GetStack(req.StackName)
if !ok {
@@ -50,7 +51,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
// Debug: log received values (redact passwords)
// 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") {
@@ -103,7 +104,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
}
// Validate path fields exist
// 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)
@@ -147,6 +148,15 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("docker compose up failed: %w", err)
}
// Update in-memory stack state immediately so the UI reflects the deployment
// without waiting for the next ScanStacks() cycle.
m.mu.Lock()
if s, ok := m.stacks[req.StackName]; ok {
s.Deployed = true
s.AppConfig = appCfg
}
m.mu.Unlock()
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
return m.RefreshStatus()
}
@@ -164,7 +174,6 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
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
@@ -181,7 +190,6 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
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)
@@ -193,14 +201,11 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
// 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...)
}
@@ -220,15 +225,12 @@ func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
// --- 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
@@ -236,18 +238,14 @@ func LoadAppConfig(stackDir string) *AppConfig {
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)
}
@@ -258,45 +256,36 @@ func SaveAppConfig(stackDir string, cfg *AppConfig) error {
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 {
switch parts[0] {
case "password":
length := 0
if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
return "", fmt.Errorf("invalid password length: %q", param)
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(param, "%d", &byteLen); err != nil || byteLen <= 0 {
return "", fmt.Errorf("invalid hex length: %q", param)
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 "static":
return param, nil
return parts[1], nil
default:
return "", fmt.Errorf("unknown generator type: %q", genType)
return "", fmt.Errorf("unknown generator type: %q", parts[0])
}
}
+79 -19
View File
@@ -18,12 +18,14 @@ import (
type ContainerState string
const (
StateRunning ContainerState = "running"
StateStopped ContainerState = "stopped"
StateRestarting ContainerState = "restarting"
StateExited ContainerState = "exited"
StatePaused ContainerState = "paused"
StateUnknown ContainerState = "unknown"
StateRunning ContainerState = "running"
StateStarting ContainerState = "starting" // running but health: starting
StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
StateStopped ContainerState = "stopped"
StateRestarting ContainerState = "restarting"
StateExited ContainerState = "exited"
StatePaused ContainerState = "paused"
StateUnknown ContainerState = "unknown"
StateNotDeployed ContainerState = "not_deployed"
)
@@ -32,7 +34,7 @@ type ContainerInfo struct {
Name string `json:"name"`
Image string `json:"image"`
State ContainerState `json:"state"`
Status string `json:"status"` // e.g. "Up 3 hours"
Status string `json:"status"` // e.g. "Up 3 hours (healthy)"
}
// Stack represents a docker compose stack on disk.
@@ -193,9 +195,9 @@ func (m *Manager) refreshStatusLocked() error {
}
ci := ContainerInfo{
Name: parts[0],
Image: parts[1],
State: parseContainerState(parts[2]),
Name: parts[0],
Image: parts[1],
State: resolveContainerState(parts[2], parts[3]),
Status: parts[3],
}
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
@@ -220,10 +222,27 @@ func (m *Manager) refreshStatusLocked() error {
return nil
}
func parseContainerState(s string) ContainerState {
switch strings.ToLower(strings.TrimSpace(s)) {
// resolveContainerState determines the effective state by combining Docker's
// State field (running/exited/etc.) with the Status field that contains health info.
//
// Docker State: "running", "exited", "restarting", "paused", "created", "dead", "removing"
// Docker Status: "Up 3 hours (healthy)", "Up 9 seconds (health: starting)", "Up 2 min (unhealthy)"
func resolveContainerState(dockerState, dockerStatus string) ContainerState {
state := strings.ToLower(strings.TrimSpace(dockerState))
status := strings.ToLower(dockerStatus)
switch state {
case "running":
// Check health sub-status for containers with healthchecks
if strings.Contains(status, "(health: starting)") {
return StateStarting
}
if strings.Contains(status, "(unhealthy)") {
return StateUnhealthy
}
// "(healthy)" or no healthcheck = running
return StateRunning
case "exited":
return StateExited
case "restarting":
@@ -237,20 +256,61 @@ func parseContainerState(s string) ContainerState {
}
}
// aggregateState determines the overall stack state from its containers.
// Priority: unhealthy/starting > restarting > all-running > stopped
func aggregateState(containers []ContainerInfo) ContainerState {
if len(containers) == 0 {
return StateNotDeployed
}
running := 0
starting := 0
unhealthy := 0
restarting := 0
stopped := 0
for _, c := range containers {
if c.State == StateRunning {
return StateRunning
switch c.State {
case StateRunning:
running++
case StateStarting:
starting++
case StateUnhealthy:
unhealthy++
case StateRestarting:
restarting++
case StateStopped, StateExited:
stopped++
}
}
for _, c := range containers {
if c.State == StateRestarting {
return StateRestarting
}
total := len(containers)
// Any unhealthy → whole stack is unhealthy
if unhealthy > 0 {
return StateUnhealthy
}
// Any still starting → stack is starting
if starting > 0 {
return StateStarting
}
// Any restarting → stack is restarting
if restarting > 0 {
return StateRestarting
}
// All running (and healthy) → stack is running
if running == total {
return StateRunning
}
// All stopped → stack is stopped
if stopped == total {
return StateStopped
}
// Mix (some running, some stopped) — report as running (partial)
if running > 0 {
return StateRunning
}
return StateStopped
}
@@ -449,4 +509,4 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
}
return stdout.String(), nil
}
}