package stacks import ( "bytes" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // ContainerState represents the current state of a container. type ContainerState string const ( StateRunning ContainerState = "running" StateStopped ContainerState = "stopped" StateRestarting ContainerState = "restarting" StateExited ContainerState = "exited" StatePaused ContainerState = "paused" StateUnknown ContainerState = "unknown" StateNotDeployed ContainerState = "not_deployed" ) // ContainerInfo holds status info about a single container within a stack. type ContainerInfo struct { Name string `json:"name"` Image string `json:"image"` State ContainerState `json:"state"` Status string `json:"status"` // e.g. "Up 3 hours" } // Stack represents a docker compose stack on disk. type Stack struct { Name string `json:"name"` Meta Metadata `json:"meta"` ComposePath string `json:"compose_path"` State ContainerState `json:"state"` Deployed bool `json:"deployed"` // Has app.yaml with deployed=true Protected bool `json:"protected"` Containers []ContainerInfo `json:"containers"` AppConfig *AppConfig `json:"app_config,omitempty"` LastUpdated time.Time `json:"last_updated"` } // Manager handles all docker compose stack operations. type Manager struct { cfg *config.Config logger *log.Logger composeCmd string stacks map[string]*Stack mu sync.RWMutex } // NewManager creates a new stack manager. func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) { composeCmd := cfg.Stacks.ComposeCommand if composeCmd == "" { composeCmd = detectComposeCommand() } if composeCmd == "" { return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')") } logger.Printf("[INFO] Using compose command: %s", composeCmd) if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil { return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err) } return &Manager{ cfg: cfg, logger: logger, composeCmd: composeCmd, stacks: make(map[string]*Stack), }, nil } // toTitleCase capitalizes the first letter of each word. func toTitleCase(s string) string { words := strings.Fields(s) for i, w := range words { if len(w) > 0 { words[i] = strings.ToUpper(w[:1]) + w[1:] } } return strings.Join(words, " ") } func detectComposeCommand() string { if err := exec.Command("docker", "compose", "version").Run(); err == nil { return "docker compose" } if _, err := exec.LookPath("docker-compose"); err == nil { return "docker-compose" } return "" } // ScanStacks discovers all compose stacks in the stacks directory. func (m *Manager) ScanStacks() error { m.mu.Lock() defer m.mu.Unlock() entries, err := os.ReadDir(m.cfg.Paths.StacksDir) if err != nil { return fmt.Errorf("reading stacks directory: %w", err) } found := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() stackDir := filepath.Join(m.cfg.Paths.StacksDir, name) composePath := filepath.Join(stackDir, "docker-compose.yml") if _, err := os.Stat(composePath); os.IsNotExist(err) { composePath = filepath.Join(stackDir, "docker-compose.yaml") if _, err := os.Stat(composePath); os.IsNotExist(err) { continue } } found[name] = true meta := LoadMetadata(stackDir) appCfg := LoadAppConfig(stackDir) deployed := appCfg != nil && appCfg.Deployed if existing, ok := m.stacks[name]; ok { existing.ComposePath = composePath existing.Meta = meta existing.Protected = m.cfg.IsProtectedStack(name) existing.Deployed = deployed existing.AppConfig = appCfg } else { m.stacks[name] = &Stack{ Name: name, Meta: meta, ComposePath: composePath, State: StateNotDeployed, Deployed: deployed, Protected: m.cfg.IsProtectedStack(name), AppConfig: appCfg, } } } // Remove stacks no longer on disk for name := range m.stacks { if !found[name] { delete(m.stacks, name) } } m.logger.Printf("[INFO] Scanned stacks: %d found", len(m.stacks)) return m.refreshStatusLocked() } // RefreshStatus updates container status for all known stacks. func (m *Manager) RefreshStatus() error { m.mu.Lock() defer m.mu.Unlock() return m.refreshStatusLocked() } func (m *Manager) refreshStatusLocked() error { output, err := m.execCommand("docker", "ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}", "--no-trunc") if err != nil { return fmt.Errorf("docker ps: %w", err) } projectContainers := make(map[string][]ContainerInfo) for _, line := range strings.Split(strings.TrimSpace(output), "\n") { if line == "" { continue } parts := strings.SplitN(line, "\t", 5) if len(parts) < 5 || parts[4] == "" { continue } ci := ContainerInfo{ Name: parts[0], Image: parts[1], State: parseContainerState(parts[2]), Status: parts[3], } projectContainers[parts[4]] = append(projectContainers[parts[4]], ci) } for name, stack := range m.stacks { containers, exists := projectContainers[name] if !exists { stack.Containers = nil if stack.Deployed { stack.State = StateStopped } else { stack.State = StateNotDeployed } } else { stack.Containers = containers stack.State = aggregateState(containers) } stack.LastUpdated = time.Now() } return nil } func parseContainerState(s string) ContainerState { switch strings.ToLower(strings.TrimSpace(s)) { case "running": return StateRunning case "exited": return StateExited case "restarting": return StateRestarting case "paused": return StatePaused case "created", "dead", "removing": return StateStopped default: return StateUnknown } } func aggregateState(containers []ContainerInfo) ContainerState { if len(containers) == 0 { return StateNotDeployed } for _, c := range containers { if c.State == StateRunning { return StateRunning } } for _, c := range containers { if c.State == StateRestarting { return StateRestarting } } return StateStopped } // --- Stack accessors --- func (m *Manager) GetStacks() []Stack { m.mu.RLock() defer m.mu.RUnlock() result := make([]Stack, 0, len(m.stacks)) for _, s := range m.stacks { result = append(result, *s) } return result } func (m *Manager) GetStack(name string) (*Stack, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.stacks[name] if !ok { return nil, false } copy := *s return ©, true } // --- Stack operations --- // StartStack, StopStack, etc. now load app.yaml env for deployed stacks. func (m *Manager) StartStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] Starting stack: %s", name) dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil { return fmt.Errorf("starting stack %s: %w", name, err) } m.logger.Printf("[INFO] Stack %s started", name) return m.RefreshStatus() } func (m *Manager) StopStack(name string) error { if m.cfg.IsProtectedStack(name) { return fmt.Errorf("stack %q is protected and cannot be stopped", name) } stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] Stopping stack: %s", name) dir := filepath.Dir(stack.ComposePath) if _, err := m.composeExec(dir, "down"); err != nil { return fmt.Errorf("stopping stack %s: %w", name, err) } m.logger.Printf("[INFO] Stack %s stopped", name) return m.RefreshStatus() } func (m *Manager) RestartStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] Restarting stack: %s", name) dir := filepath.Dir(stack.ComposePath) if _, err := m.composeExec(dir, "restart"); err != nil { return fmt.Errorf("restarting stack %s: %w", name, err) } m.logger.Printf("[INFO] Stack %s restarted", name) return m.RefreshStatus() } func (m *Manager) UpdateStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] Updating stack: %s", name) dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil { return fmt.Errorf("pulling images for %s: %w", name, err) } if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil { return fmt.Errorf("recreating %s: %w", name, err) } m.logger.Printf("[INFO] Stack %s updated", name) return m.RefreshStatus() } func (m *Manager) GetLogs(name string, lines int) (string, error) { stack, ok := m.GetStack(name) if !ok { return "", fmt.Errorf("stack %q not found", name) } if lines <= 0 { lines = 100 } if lines > 1000 { lines = 1000 } dir := filepath.Dir(stack.ComposePath) output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color") if err != nil { return "", fmt.Errorf("getting logs for %s: %w", name, err) } return output, nil } // --- Env and compose helpers --- // stackEnv builds the full OS env slice for a stack, merging app.yaml values. func (m *Manager) stackEnv(stackDir string) []string { env := os.Environ() // Always inject DOMAIN env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain)) // Load app.yaml if it exists — merge its env vars appCfg := LoadAppConfig(stackDir) if appCfg != nil { for k, v := range appCfg.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } } return env } func (m *Manager) composeExec(dir string, args ...string) (string, error) { return m.composeExecCustomEnv(dir, nil, args...) } func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) { var cmd *exec.Cmd if m.composeCmd == "docker compose" { fullArgs := append([]string{"compose"}, args...) cmd = exec.Command("docker", fullArgs...) } else { cmd = exec.Command("docker-compose", args...) } cmd.Dir = dir if env != nil { cmd.Env = env } else { cmd.Env = m.stackEnv(dir) } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir) if err := cmd.Run(); err != nil { return stdout.String(), fmt.Errorf("%w\nstderr: %s", err, stderr.String()) } return stdout.String(), nil } func (m *Manager) execCommand(name string, args ...string) (string, error) { cmd := exec.Command(name, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String()) } return stdout.String(), nil }