410a131cec
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.
129 lines
4.6 KiB
Go
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
|
|
}
|