| 1 | package steps |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "io" |
| 6 | "strings" |
| 7 | |
| 8 | "go.bigb.es/auxilia/culpa" |
| 9 | "go.bigb.es/auxilia/legatus" |
| 10 | ) |
| 11 | |
| 12 | // ComposeAction is a docker compose subcommand. Read-only actions (Ps, Logs) |
| 13 | // report Outcome.Changed:false; state-changing ones (Up, Down, Pull, Restart) |
| 14 | // report Changed:true. |
| 15 | type ComposeAction string |
| 16 | |
| 17 | const ( |
| 18 | ComposeUp ComposeAction = "up" |
| 19 | ComposeDown ComposeAction = "down" |
| 20 | ComposePull ComposeAction = "pull" |
| 21 | ComposePs ComposeAction = "ps" |
| 22 | ComposeLogs ComposeAction = "logs" |
| 23 | ComposeRestart ComposeAction = "restart" |
| 24 | ) |
| 25 | |
| 26 | // changesState reports whether the action mutates the stack (so Apply can set |
| 27 | // Outcome.Changed). Read-only inspection actions return false. |
| 28 | func (a ComposeAction) changesState() bool { |
| 29 | switch a { |
| 30 | case ComposePs, ComposeLogs: |
| 31 | return false |
| 32 | default: |
| 33 | return true |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Compose runs `docker compose <action>` in a stack's directory. It is a thin, |
| 38 | // opinion-free typed wrapper over a structural legatus.Cmd with Cmd.Dir set |
| 39 | // (PC2): it adds no stack/service policy of its own, only the `up -d` flag when |
| 40 | // Detach is set. It always applies (no idempotency guard); read-only actions |
| 41 | // report Changed:false so the engine's tally stays honest. |
| 42 | // |
| 43 | // When Stream is set, stdout/stderr also stream live to FlowCtx.Out() as the |
| 44 | // command runs (for `up`/`logs` output), while stdout is still captured for |
| 45 | // Outcome.Data via a tee — mirroring Command's streaming contract. |
| 46 | type Compose struct { |
| 47 | legatus.AlwaysApply |
| 48 | Dir string |
| 49 | Action ComposeAction |
| 50 | Services []string |
| 51 | Detach bool |
| 52 | Stream bool |
| 53 | } |
| 54 | |
| 55 | // Name returns the step name. |
| 56 | func (c Compose) Name() string { return "compose:" + string(c.Action) } |
| 57 | |
| 58 | // build renders the structural docker compose Cmd: `docker compose <action> |
| 59 | // [-d] [services...]` with Dir set (G8). The argv is structural — no sh -c |
| 60 | // concatenation — so service names can never re-split (IV1). |
| 61 | func (c Compose) build() legatus.Cmd { |
| 62 | args := []string{"compose", string(c.Action)} |
| 63 | if c.Action == ComposeUp && c.Detach { |
| 64 | args = append(args, "-d") |
| 65 | } |
| 66 | args = append(args, c.Services...) |
| 67 | return legatus.Cmd{Path: "docker", Args: args, Dir: c.Dir} |
| 68 | } |
| 69 | |
| 70 | // Apply runs the compose command in Dir and reports whether it changed state. |
| 71 | // An empty Action fails loud rather than running `docker compose` with no |
| 72 | // subcommand. |
| 73 | func (c Compose) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 74 | if c.Action == "" { |
| 75 | return legatus.Outcome{}, culpa.WithCode(culpa.New("compose: empty action"), "COMPOSE_RUN") |
| 76 | } |
| 77 | cmd := c.build() |
| 78 | var ( |
| 79 | res legatus.ExecResult |
| 80 | err error |
| 81 | out string // captured stdout (the Data payload) |
| 82 | errOut string // captured stderr (for the failure message only) |
| 83 | ) |
| 84 | if c.Stream { |
| 85 | // Tee both streams: live to fc.Out(), captured for Data (stdout) and the |
| 86 | // non-zero-exit error message (stderr). ExecStream leaves res.Stdout/ |
| 87 | // res.Stderr nil because both writers are non-nil. |
| 88 | var outBuf, errBuf bytes.Buffer |
| 89 | res, err = fc.Conn().ExecStream(fc.Ctx(), cmd, |
| 90 | io.MultiWriter(fc.Out(), &outBuf), io.MultiWriter(fc.Out(), &errBuf)) |
| 91 | out, errOut = outBuf.String(), errBuf.String() |
| 92 | } else { |
| 93 | res, err = fc.Conn().Exec(fc.Ctx(), cmd) |
| 94 | out, errOut = string(res.Stdout), string(res.Stderr) |
| 95 | } |
| 96 | if err != nil { |
| 97 | return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "compose %s", c.Action), "COMPOSE_RUN") |
| 98 | } |
| 99 | if res.ExitCode != 0 { |
| 100 | return legatus.Outcome{}, culpa.WithCode( |
| 101 | culpa.Errorf("compose %s: exit %d: %s", c.Action, res.ExitCode, strings.TrimSpace(out+errOut)), |
| 102 | "COMPOSE_RUN") |
| 103 | } |
| 104 | return legatus.Outcome{Changed: c.Action.changesState(), Message: "compose " + string(c.Action), Data: out}, nil |
| 105 | } |
| 106 | |