feat: docker-compose stacks with Forge-themed UI
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.
This commit is contained in:
2026-04-16 03:48:37 +03:00
parent b622384774
commit 75424a5f25
23 changed files with 3603 additions and 18 deletions
+118
View File
@@ -0,0 +1,118 @@
// Package stack provides docker-compose ("stack") management for Tinyforge.
//
// Stacks are a first-class concept distinct from single-container Projects:
// users upload a docker-compose YAML, which is stored as an append-only
// revision and deployed via the `docker compose` CLI. Rollback is a new
// revision whose YAML is copied from an older one, redeployed.
package stack
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// Compose is a thin wrapper around the `docker compose` CLI.
// It intentionally shells out rather than using the Docker SDK: compose has
// no first-class Go SDK, and the CLI is the canonical interface.
type Compose struct {
binary string // path to `docker` binary; defaults to "docker"
}
// NewCompose returns a Compose wrapper. If binary is empty, "docker" is used.
func NewCompose(binary string) *Compose {
if binary == "" {
binary = "docker"
}
return &Compose{binary: binary}
}
// Available returns nil if `docker compose version` succeeds.
func (c *Compose) Available(ctx context.Context) error {
cmd := exec.CommandContext(ctx, c.binary, "compose", "version")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker compose not available: %w (output: %s)", err, string(out))
}
return nil
}
// Up runs `docker compose -p <projectName> -f <yamlPath> up -d`.
// Returns combined stdout+stderr for log persistence.
func (c *Compose) Up(ctx context.Context, projectName, yamlPath string) (string, error) {
return c.run(ctx, projectName, "-f", yamlPath, "up", "-d", "--remove-orphans")
}
// Down runs `docker compose -p <projectName> down`.
// removeVolumes controls whether named volumes are also removed (`-v`).
func (c *Compose) Down(ctx context.Context, projectName string, removeVolumes bool) (string, error) {
args := []string{"down", "--remove-orphans"}
if removeVolumes {
args = append(args, "-v")
}
return c.run(ctx, projectName, args...)
}
// Stop runs `docker compose -p <projectName> stop`.
func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) {
return c.run(ctx, projectName, "stop")
}
// Start runs `docker compose -p <projectName> start`.
func (c *Compose) Start(ctx context.Context, projectName string) (string, error) {
return c.run(ctx, projectName, "start")
}
// Service is a single row of `docker compose ps --format json`.
type Service struct {
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
Status string `json:"Status"`
Health string `json:"Health"`
ExitCode int `json:"ExitCode"`
}
// Ps runs `docker compose -p <projectName> -f <yamlPath> ps --format json`
// and returns one Service per running+stopped service. yamlPath may be empty
// (compose uses stored state when known).
func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Service, error) {
args := []string{}
if yamlPath != "" {
args = append(args, "-f", yamlPath)
}
args = append(args, "ps", "--format", "json", "--all")
out, err := c.run(ctx, projectName, args...)
if err != nil {
return nil, err
}
return parsePsOutput(out), nil
}
// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
// If service is empty, logs for all services are returned.
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
if service != "" {
args = append(args, service)
}
return c.run(ctx, projectName, args...)
}
// run executes `docker compose -p <projectName> <args...>` and returns combined output.
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
full := append([]string{"compose", "-p", projectName}, args...)
cmd := exec.CommandContext(ctx, c.binary, full...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
out := buf.String()
if err != nil {
return out, fmt.Errorf("docker compose %s: %w (output: %s)",
strings.Join(args, " "), err, strings.TrimSpace(out))
}
return out, nil
}
+334
View File
@@ -0,0 +1,334 @@
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) }
+38
View File
@@ -0,0 +1,38 @@
package stack
import (
"encoding/json"
"strings"
)
// parsePsOutput handles both formats emitted by `docker compose ps --format json`:
// newer versions emit NDJSON (one object per line); older versions emit a single JSON array.
func parsePsOutput(out string) []Service {
out = strings.TrimSpace(out)
if out == "" {
return nil
}
// Array form.
if strings.HasPrefix(out, "[") {
var arr []Service
if err := json.Unmarshal([]byte(out), &arr); err == nil {
return arr
}
}
// NDJSON form: one object per line.
var services []Service
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
var svc Service
if err := json.Unmarshal([]byte(line), &svc); err != nil {
continue
}
services = append(services, svc)
}
return services
}
+52
View File
@@ -0,0 +1,52 @@
package stack
import (
"fmt"
"gopkg.in/yaml.v3"
)
// ComposeSpec is a minimal, lenient representation of a compose file.
// We only decode fields we need for validation + label-based proxy routing;
// everything else is preserved as-is and passed to `docker compose`.
type ComposeSpec struct {
Version string `yaml:"version,omitempty"`
Services map[string]ServiceSpec `yaml:"services"`
}
// ServiceSpec captures the subset of compose service fields we inspect.
type ServiceSpec struct {
Image string `yaml:"image,omitempty"`
Ports []any `yaml:"ports,omitempty"`
Labels map[string]string `yaml:"labels,omitempty"`
Privileged bool `yaml:"privileged,omitempty"`
}
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
func Parse(yamlText string) (ComposeSpec, error) {
var spec ComposeSpec
if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil {
return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err)
}
if len(spec.Services) == 0 {
return ComposeSpec{}, fmt.Errorf("compose file has no services")
}
return spec, nil
}
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
// Current rules:
// - No service may set `privileged: true`.
// - Every service must declare an image (compose supports build: too, but
// Tinyforge v1 disallows building from context to avoid arbitrary-code exec).
func Validate(spec ComposeSpec) error {
for name, svc := range spec.Services {
if svc.Privileged {
return fmt.Errorf("service %q: privileged mode is not allowed", name)
}
if svc.Image == "" {
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
}
}
return nil
}