Skip to content

Commit 855488f

Browse files
feat(cli): C6 — up / down commands (spec 07/09) (#10)
Wire the orchestrate saga into the cobra tree, replacing the up/down stubs. - `up [project...]` — reconcile, then BuildUp + Saga.Run: preflight → network → generate → shared(health-gated) → compose-up → hooks. Plain mode streams one line per phase as it completes (orchestrate.FormatPlain via the Emit hook); --json emits the documented array ([{phase,status,durationMs,error,detail}]); the exit code is non-zero iff a phase failed. Flags: --build, --no-hooks, --no-preflight, --profile. - `down [project...]` — run preDown hooks (warn-by-default), `compose down` each project stack (never -v: volumes/DBs survive), drop its ref rows. The shared network and shared containers are left alone (autostop is X1 config + spec 03). - buildUpDeps assembles deps over the real Engine SDK client (up/down need a daemon; the preflight phase reports an unreachable one clearly). docker.Compose.base() now omits -f when File is empty so down/stop can run label-driven by project name even if the generated compose file is absent. orchestrate gains a NoPreflight option (--no-preflight). Verified end-to-end against a real Engine 29.5.3 in an isolated XDG sandbox (then fully torn down): `up` ensured the network, brought up shared-postgres HEALTHY (the cross-project health gate), built+started the project stack, ran postUp; a re-run skipped every satisfied phase; --json matched the contract; `down` removed the project + dropped refs to 0 while leaving postgres running. Unit tests cover registration + workspace-discovery errors; the daemon e2e in CI lands with G1's isolation harness. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 301061a commit 855488f

6 files changed

Lines changed: 273 additions & 5 deletions

File tree

internal/cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ func NewRootCmd(opts Options) *cobra.Command {
6666
cobra.OnInitialize()
6767

6868
root.AddCommand(
69+
newUpCmd(g),
70+
newDownCmd(g),
6971
newDoctorCmd(g),
7072
newConfigCmd(g),
7173
newGenerateCmd(g),

internal/cli/stubs.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ func rootName(c *cobra.Command) string { return c.Root().Name() }
3030
// alias and version are real; everything else is a milestone-tagged placeholder.
3131
func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3232
root.AddCommand(
33-
stub("up", "Bring the workspace up (clone, shared infra, provision, generate, compose up)", "M2/M6"),
34-
stub("down", "Stop this workspace's project stacks", "M2"),
3533
stub("status", "Multi-repo git + service health table", "M3"),
3634
stub("shell", "Open a shell in a service container", "M2"),
3735
stub("logs", "Stream service logs", "M2"),

internal/cli/up.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/open-source-cloud/devstack/internal/config"
12+
"github.com/open-source-cloud/devstack/internal/docker"
13+
"github.com/open-source-cloud/devstack/internal/generate"
14+
"github.com/open-source-cloud/devstack/internal/hooks"
15+
"github.com/open-source-cloud/devstack/internal/lock"
16+
"github.com/open-source-cloud/devstack/internal/orchestrate"
17+
"github.com/open-source-cloud/devstack/internal/state"
18+
"github.com/open-source-cloud/devstack/internal/workspace"
19+
"github.com/open-source-cloud/devstack/internal/xdg"
20+
)
21+
22+
// newUpCmd wires `devstack up [project...]` — the onboarding saga (spec 09): it
23+
// reconciles the ledger, then drives preflight → network → generate →
24+
// shared(health-gated) → compose-up → hooks, resumable and compensating.
25+
func newUpCmd(g *GlobalOpts) *cobra.Command {
26+
var (
27+
build bool
28+
noHooks bool
29+
noPreflight bool
30+
profile string
31+
)
32+
cmd := &cobra.Command{
33+
Use: "up [project...]",
34+
Short: "Bring the workspace up (network, shared infra, generate, compose up, hooks)",
35+
Long: "up takes a workspace from config to a running, health-gated stack in one\n" +
36+
"idempotent command. Phases record their state so a re-run skips satisfied\n" +
37+
"work and a crash mid-run resumes; a failure compensates the mutating phases\n" +
38+
"(refs/containers) but never destroys data (volumes/DBs survive).",
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
d, closeFn, err := buildUpDeps(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
defer closeFn()
45+
d.Projects = args
46+
d.Build = build
47+
d.NoHooks = noHooks
48+
d.NoPreflight = noPreflight
49+
d.Profile = profile
50+
51+
// Self-healing reconcile before the saga (spec 09): prune ref rows for
52+
// projects no longer live. Best-effort — never blocks `up`.
53+
_, _ = d.Manager.Reconcile(cmd.Context())
54+
55+
phases, err := orchestrate.BuildUp(d)
56+
if err != nil {
57+
return err
58+
}
59+
saga := &orchestrate.Saga{Workspace: d.Model.Workspace.Name, DB: d.DB, LockPath: d.LockPath}
60+
61+
// Plain/quiet stream each phase as it completes; --json collects them.
62+
if !g.JSON && !g.Quiet {
63+
w := cmd.OutOrStdout()
64+
saga.Emit = func(r orchestrate.Record) { fmt.Fprintln(w, orchestrate.FormatPlain(r)) }
65+
}
66+
records, runErr := saga.Run(cmd.Context(), phases)
67+
68+
if g.JSON {
69+
if err := writeJSON(cmd, records); err != nil {
70+
return err
71+
}
72+
}
73+
if runErr != nil {
74+
return runErr
75+
}
76+
return nil
77+
},
78+
}
79+
cmd.Flags().BoolVar(&build, "build", false, "build images before starting (compose build)")
80+
cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks")
81+
cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks")
82+
cmd.Flags().StringVar(&profile, "profile", "", "env-overlay profile for ${profile}")
83+
return cmd
84+
}
85+
86+
// newDownCmd wires `devstack down [project...]` — stop project stacks, run
87+
// preDown hooks first, drop their ref rows. The external network and volumes are
88+
// never touched; shared services are left running (autostop is X1 config + spec 03).
89+
func newDownCmd(g *GlobalOpts) *cobra.Command {
90+
cmd := &cobra.Command{
91+
Use: "down [project...]",
92+
Short: "Stop this workspace's project stacks and release their refs",
93+
RunE: func(cmd *cobra.Command, args []string) error {
94+
d, closeFn, err := buildUpDeps(cmd)
95+
if err != nil {
96+
return err
97+
}
98+
defer closeFn()
99+
100+
projects := args
101+
if len(projects) == 0 {
102+
for name := range d.Model.Projects {
103+
projects = append(projects, name)
104+
}
105+
}
106+
ctx := cmd.Context()
107+
w := cmd.OutOrStdout()
108+
type result struct {
109+
Project string `json:"project"`
110+
Status string `json:"status"`
111+
Error string `json:"error,omitempty"`
112+
}
113+
var results []result
114+
var firstErr error
115+
for _, p := range projects {
116+
if _, ok := d.Model.Projects[p]; !ok {
117+
return fmt.Errorf("project %q is not in this workspace", p)
118+
}
119+
status := "stopped"
120+
if err := downProject(ctx, d, p); err != nil {
121+
status = "failed"
122+
results = append(results, result{Project: p, Status: status, Error: err.Error()})
123+
if firstErr == nil {
124+
firstErr = err
125+
}
126+
continue
127+
}
128+
results = append(results, result{Project: p, Status: status})
129+
if !g.JSON && !g.Quiet {
130+
fmt.Fprintf(w, "[ok] down %s\n", p)
131+
}
132+
}
133+
if g.JSON {
134+
if err := writeJSON(cmd, map[string]any{"down": results}); err != nil {
135+
return err
136+
}
137+
}
138+
return firstErr
139+
},
140+
}
141+
return cmd
142+
}
143+
144+
// downProject runs a project's preDown hooks (warn-by-default), composes the
145+
// stack down (never -v: volumes survive), and drops its ref rows.
146+
func downProject(ctx context.Context, d orchestrate.UpDeps, project string) error {
147+
outDir := filepath.Join(d.Model.ProjectDir(project), generate.GenDir)
148+
composeFile := filepath.Join(outDir, generate.ComposeFile)
149+
if _, err := os.Stat(composeFile); err != nil {
150+
composeFile = "" // fall back to label-driven `compose -p <proj> down`
151+
}
152+
153+
p := d.Model.Projects[project]
154+
if len(p.Hooks.PreDown) > 0 {
155+
runner := &hooks.Runner{
156+
Execer: hooks.OSExecer{BaseDir: d.Model.ProjectDir(project), Project: "devstack-" + project, File: composeFile},
157+
Ledger: d.DB,
158+
Lock: func(ctx context.Context, fn func() error) error { return lock.WithLock(ctx, d.LockPath, fn) },
159+
}
160+
// preDown defaults to warn so a broken teardown hook can't trap a workspace.
161+
if _, err := runner.RunPhase(ctx, p.Hooks.PreDown, hooks.PhaseOpts{
162+
Project: project, Phase: "preDown", DefaultOnFailure: hooks.OnWarn,
163+
}); err != nil {
164+
return err
165+
}
166+
}
167+
168+
cp := docker.Compose{Project: "devstack-" + project, File: composeFile, Dir: outDir, Runner: docker.ExecRunner{}}
169+
if err := cp.Down(ctx, false); err != nil {
170+
return err
171+
}
172+
if _, err := d.Manager.RegisterDown(ctx, project); err != nil {
173+
return err
174+
}
175+
return nil
176+
}
177+
178+
// buildUpDeps assembles the up/down dependencies from the current directory. It
179+
// uses the real Engine SDK client (up/down require a daemon — the preflight
180+
// phase reports an unreachable daemon clearly).
181+
func buildUpDeps(cmd *cobra.Command) (orchestrate.UpDeps, func(), error) {
182+
var zero orchestrate.UpDeps
183+
cwd, err := os.Getwd()
184+
if err != nil {
185+
return zero, nil, err
186+
}
187+
model, err := config.Load(cwd)
188+
if err != nil {
189+
return zero, nil, err
190+
}
191+
ctx := cmd.Context()
192+
dc, err := docker.NewClient(ctx)
193+
if err != nil {
194+
return zero, nil, fmt.Errorf("docker client: %w", err)
195+
}
196+
db, err := state.Open(ctx, xdg.DataHome(), dc.ContextName())
197+
if err != nil {
198+
_ = dc.Close()
199+
return zero, nil, err
200+
}
201+
lockPath := filepath.Join(xdg.RuntimeDir(), "devstack.lock")
202+
mgr := &workspace.Manager{Model: model, DB: db, Docker: dc, Source: builtinSource(), LockPath: lockPath}
203+
d := orchestrate.UpDeps{
204+
Model: model, DB: db, Docker: dc, Manager: mgr,
205+
Source: mgr.Source, LockPath: lockPath,
206+
}
207+
closeFn := func() {
208+
db.Close()
209+
_ = dc.Close()
210+
}
211+
return d, closeFn, nil
212+
}

internal/cli/up_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func findCmd(t *testing.T, name string) bool {
9+
t.Helper()
10+
root := NewRootCmd(Options{})
11+
for _, c := range root.Commands() {
12+
if c.Name() == name {
13+
return c.RunE != nil // a real command, not a stub group
14+
}
15+
}
16+
return false
17+
}
18+
19+
func TestUpDownRegistered(t *testing.T) {
20+
for _, name := range []string{"up", "down"} {
21+
if !findCmd(t, name) {
22+
t.Errorf("command %q is not registered as a real RunE command", name)
23+
}
24+
}
25+
}
26+
27+
func TestUpOutsideWorkspaceErrors(t *testing.T) {
28+
t.Chdir(t.TempDir())
29+
root := NewRootCmd(Options{})
30+
root.SetArgs([]string{"up"})
31+
root.SetOut(&strings.Builder{})
32+
root.SetErr(&strings.Builder{})
33+
if err := root.Execute(); err == nil {
34+
t.Fatal("up outside a workspace should error (no workspace.yaml)")
35+
}
36+
}
37+
38+
func TestDownOutsideWorkspaceErrors(t *testing.T) {
39+
t.Chdir(t.TempDir())
40+
root := NewRootCmd(Options{})
41+
root.SetArgs([]string{"down"})
42+
root.SetOut(&strings.Builder{})
43+
root.SetErr(&strings.Builder{})
44+
if err := root.Execute(); err == nil {
45+
t.Fatal("down outside a workspace should error (no workspace.yaml)")
46+
}
47+
}

internal/docker/compose.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ func NewCompose(project, file, dir string) *Compose {
8484
}
8585

8686
func (c *Compose) base() []string {
87+
// File is optional for label-driven verbs (down/stop discover the stack from
88+
// the project name's container labels); up/build require it.
89+
if c.File == "" {
90+
return []string{"compose", "-p", c.Project}
91+
}
8792
return []string{"compose", "-p", c.Project, "-f", c.File}
8893
}
8994

internal/orchestrate/up.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type UpDeps struct {
4949

5050
Build bool // compose up --build
5151
NoHooks bool // skip the hooks phase
52+
NoPreflight bool // skip the preflight phase (fast inner loops)
5253
HealthTimeout time.Duration // per-shared-service gate cap (0 → health.Compile default)
5354
}
5455

@@ -72,12 +73,15 @@ func BuildUp(d UpDeps) ([]Phase, error) {
7273
return nil, err
7374
}
7475

75-
phases := []Phase{
76-
preflightPhase(d),
76+
var phases []Phase
77+
if !d.NoPreflight {
78+
phases = append(phases, preflightPhase(d))
79+
}
80+
phases = append(phases,
7781
networkPhase(d),
7882
generatePhase(d, gen),
7983
sharedPhase(d, projects),
80-
}
84+
)
8185
for _, p := range projects {
8286
phases = append(phases, composeUpPhase(d, p))
8387
}

0 commit comments

Comments
 (0)