package stack import ( "context" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "time" "github.com/alexei/tinyforge/internal/events" "github.com/alexei/tinyforge/internal/store" ) // Manager orchestrates the stack deployment pipeline: validate YAML, persist // a revision, write YAML to disk, run `docker compose up`, update status. type Manager struct { store *store.Store compose *Compose eventBus *events.Bus workDir string // where per-stack YAML files are written } // NewManager constructs a stack Manager. workDir is the directory where // per-stack YAML files are written; it is created if missing. func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) { if workDir == "" { workDir = filepath.Join(os.TempDir(), "tinyforge-stacks") } if err := os.MkdirAll(workDir, 0o755); err != nil { return nil, fmt.Errorf("create stack workdir: %w", err) } return &Manager{ store: st, compose: compose, eventBus: eventBus, workDir: workDir, }, nil } // Available reports whether the underlying `docker compose` CLI is usable. func (m *Manager) Available(ctx context.Context) error { return m.compose.Available(ctx) } // Create inserts a new stack + its initial revision. Does NOT deploy. func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) { if strings.TrimSpace(name) == "" { return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required") } spec, err := Parse(yamlText) if err != nil { return store.Stack{}, store.StackRevision{}, err } if err := Validate(spec); err != nil { return store.Stack{}, store.StackRevision{}, err } st := store.Stack{ Name: name, Description: description, ComposeProjectName: composeProjectName(name), Status: "stopped", } st, err = m.store.CreateStack(st) if err != nil { return store.Stack{}, store.StackRevision{}, err } rev, err := m.store.CreateStackRevision(store.StackRevision{ StackID: st.ID, YAML: yamlText, Author: author, }) if err != nil { // Best-effort cleanup of the stack row. _ = m.store.DeleteStack(st.ID) return store.Stack{}, store.StackRevision{}, err } return st, rev, nil } // Deploy brings up the stack for the given revision. Updates stack + revision // status transitions: deploying → running | failed. Blocking. func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error { st, err := m.store.GetStackByID(stackID) if err != nil { return err } rev, err := m.store.GetStackRevisionByID(revisionID) if err != nil { return err } if rev.StackID != stackID { return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID) } deploy, err := m.store.CreateStackDeploy(store.StackDeploy{ StackID: stackID, RevisionID: revisionID, Status: "deploying", }) if err != nil { return err } _ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID) m.setStatus(st, "deploying", "") yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML) if err != nil { m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err)) return err } out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath) if upErr != nil { m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out)) return upErr } // Success. deploy.Status = "success" deploy.Log = out deploy.FinishedAt = store.Now() _ = m.store.UpdateStackDeploy(deploy) _ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID) _ = m.store.SetStackCurrentRevision(st.ID, rev.ID) m.setStatus(st, "running", "") m.syncContainerRows(ctx, st, yamlPath) return nil } // NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it. func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) { spec, err := Parse(yamlText) if err != nil { return store.StackRevision{}, err } if err := Validate(spec); err != nil { return store.StackRevision{}, err } rev, err := m.store.CreateStackRevision(store.StackRevision{ StackID: stackID, YAML: yamlText, Author: author, }) if err != nil { return store.StackRevision{}, err } if err := m.Deploy(ctx, stackID, rev.ID); err != nil { return rev, err } return rev, nil } // NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine. // Returns the created revision immediately. func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) { spec, err := Parse(yamlText) if err != nil { return store.StackRevision{}, err } if err := Validate(spec); err != nil { return store.StackRevision{}, err } rev, err := m.store.CreateStackRevision(store.StackRevision{ StackID: stackID, YAML: yamlText, Author: author, }) if err != nil { return store.StackRevision{}, err } go func(stackID, revID string) { bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() if err := m.Deploy(bgCtx, stackID, revID); err != nil { slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err) } }(stackID, rev.ID) return rev, nil } // RollbackAsync creates a copy-revision from a target and deploys asynchronously. func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) { target, err := m.store.GetStackRevisionByID(targetRevisionID) if err != nil { return store.StackRevision{}, err } if target.StackID != stackID { return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID) } return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")") } // Rollback creates a new revision whose YAML is copied from the given prior // revision, then deploys it. Keeps history append-only. func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) { target, err := m.store.GetStackRevisionByID(targetRevisionID) if err != nil { return store.StackRevision{}, err } if target.StackID != stackID { return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID) } return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")") } // Stop runs `docker compose stop` without removing containers. func (m *Manager) Stop(ctx context.Context, stackID string) error { st, err := m.store.GetStackByID(stackID) if err != nil { return err } if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil { return err } m.setStatus(st, "stopped", "") m.markStackContainersState(stackID, "stopped") return nil } // Start runs `docker compose start` on existing containers. func (m *Manager) Start(ctx context.Context, stackID string) error { st, err := m.store.GetStackByID(stackID) if err != nil { return err } if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil { return err } m.setStatus(st, "running", "") m.markStackContainersState(stackID, "running") return nil } // Delete tears down the stack and removes the DB row. If removeVolumes is // true, named volumes are also deleted (`compose down -v`). Destructive. func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error { st, err := m.store.GetStackByID(stackID) if err != nil { return err } if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil { // Log but continue — DB row must not be orphaned. slog.Warn("stack: compose down failed", "stack", st.Name, "error", err) } // Best-effort YAML cleanup. _ = os.RemoveAll(filepath.Join(m.workDir, st.ID)) return m.store.DeleteStack(stackID) } // Services returns current service state for a stack. func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) { st, err := m.store.GetStackByID(stackID) if err != nil { return nil, err } yamlPath := "" if st.CurrentRevisionID != "" { if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil { yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML) } } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath) } // Logs returns the last `tail` log lines for a service (or all services if empty). func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) { st, err := m.store.GetStackByID(stackID) if err != nil { return "", err } if tail <= 0 { tail = 200 } return m.compose.Logs(ctx, st.ComposeProjectName, service, tail) } // --- internals --- // syncContainerRows upserts one Container row per compose service for this // stack so the global container index stays in sync after every deploy. The // Docker container ID is left empty here — the reconciler resolves it from // `docker ps` via the `com.docker.compose.project` label. Best-effort: a // failure here is logged but does not affect deploy outcome. func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) { w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID) if err != nil { slog.Warn("stack: resolve workload", "stack", st.ID, "error", err) return } psCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath) if err != nil { slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err) return } for _, svc := range services { state := svc.State if state == "" { state = svc.Status } m.upsertStackContainer(w.ID, svc, state) } } // upsertStackContainer writes a Container row for one compose service. The // row ID is deterministic — `:` — so re-deploys update // the same row instead of accumulating rows. func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) { role := svc.Service if role == "" { role = svc.Name } if err := m.store.UpsertContainer(store.Container{ ID: workloadID + ":" + role, WorkloadID: workloadID, WorkloadKind: string(store.WorkloadKindStack), Role: role, ContainerID: "", // reconciler fills in from docker ps Host: "local", State: state, LastSeenAt: store.Now(), }); err != nil { slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err) } } // markStackContainersState bulk-updates the state of every container row for // this stack (used by Stop/Start which don't go through compose ps). func (m *Manager) markStackContainersState(stackID, state string) { w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID) if err != nil { return } rows, err := m.store.ListContainersByWorkload(w.ID) if err != nil { slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err) return } for _, r := range rows { if err := m.store.UpdateContainerState(r.ID, state); err != nil { slog.Warn("stack: update container state", "container_row", r.ID, "error", err) } } } func (m *Manager) setStatus(st store.Stack, status, errMsg string) { _ = m.store.UpdateStackStatus(st.ID, status, errMsg) if m.eventBus != nil { m.eventBus.Publish(events.Event{ Type: events.EventStackStatus, Payload: events.StackStatusPayload{ StackID: st.ID, Name: st.Name, Status: status, Error: errMsg, }, }) } } func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) { d.Status = "failed" d.Error = errMsg d.FinishedAt = store.Now() _ = m.store.UpdateStackDeploy(d) _ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID) m.setStatus(st, "failed", errMsg) } // writeYAML writes yaml to //rev-.yml and returns the path. func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) { dir := filepath.Join(m.workDir, stackID) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision)) if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil { return "", err } return path, nil } // composeProjectName sanitises a user-provided stack name into something // `docker compose -p` will accept: lowercase, digits, dashes only. func composeProjectName(name string) string { name = strings.ToLower(name) name = nonProjectChars.ReplaceAllString(name, "-") name = strings.Trim(name, "-") if name == "" { name = "stack" } return "tinyforge-" + name } var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`) func itoa(n int) string { return fmt.Sprintf("%d", n) }