75424a5f25
Build / build (push) Successful in 10m42s
Adds a new Stacks feature: upload/edit docker-compose YAML, deploy as atomic units, browse revisions, roll back, and stream logs. Backend in internal/stack + internal/api/stacks.go, persistent storage in internal/store/stacks.go. Stacks pages (list, new, detail) use a modern Forge aesthetic — Instrument Serif display type, JetBrains Mono for meta/code, indigo ember accents, dot-grid hero, registration marks on hover, terminal panel for logs. Palette is sourced from the app's existing design tokens so the feature remains consistent with the rest of Tinyforge. Fonts self-hosted via @fontsource/instrument-serif and @fontsource/jetbrains-mono to satisfy the strict CSP.
335 lines
10 KiB
Go
335 lines
10 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", "")
|
|
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", "")
|
|
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", "")
|
|
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 ---
|
|
|
|
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) }
|