b6f20599d7
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.
406 lines
13 KiB
Go
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) }
|