Files
tiny-forge/internal/stack/manager.go
T
alexei.dolgolyov b6f20599d7 feat(workload): wire stack + static-site into containers index
Stack manager now upserts a Container row per compose service
after every deploy (deterministic ID = workloadID + service so
re-deploys update in place). Stop/Start bulk-flip the state
field. Compose containers don't yet carry the new tinyforge.*
labels — the reconciler will join via com.docker.compose.project
when it lands.

Static site manager passes WorkloadID/Kind to ContainerConfig
so the new labels are stamped, and upserts a single Container
row per site (deterministic ID = workloadID + ":site"). Stop/
Start flip state. Delete cascades through the store layer.

Now every Tinyforge-managed container — project, stack service,
or static site — has a row in the containers index, ready for
the reconciler + global view in the next batches.
2026-05-09 13:41:03 +03:00

406 lines
13 KiB
Go

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 — `<workloadID>:<service>` — 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 <workDir>/<stackID>/rev-<n>.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) }