// 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 -f 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 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 stop`. func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) { return c.run(ctx, projectName, "stop") } // Start runs `docker compose -p 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 -f 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 logs --no-color --tail= `. // 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 ` 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 }