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.
119 lines
4.0 KiB
Go
119 lines
4.0 KiB
Go
// 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
|
|
}
|