updated
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user