Files
tiny-forge/internal/stack/compose.go
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

129 lines
4.6 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. The service arg
// is preceded by `--` so a service name that begins with `-` cannot be
// re-parsed as a flag by the docker CLI (flag-injection guard).
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. projectName is verified not to begin with `-` because
// `docker compose -p '--foo'` would otherwise be re-parsed as a flag —
// the callers already sanitize project names through projectNameSanitizer,
// but a belt-and-braces refusal here means any future caller cannot
// accidentally bypass the sanitizer.
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
if projectName == "" || strings.HasPrefix(projectName, "-") {
return "", fmt.Errorf("docker compose: refusing project name %q", projectName)
}
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
}