From ee8af6b28bcdd20a5236d765bd2dc4d048ecbbb1 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 10:30:45 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20C6=20=E2=80=94=20`up`=20/=20`down`?= =?UTF-8?q?=20commands=20(spec=2007/09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/cli/root.go | 2 + internal/cli/stubs.go | 2 - internal/cli/up.go | 212 +++++++++++++++++++++++++++++++++++++ internal/cli/up_test.go | 47 ++++++++ internal/docker/compose.go | 5 + internal/orchestrate/up.go | 10 +- 6 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 internal/cli/up.go create mode 100644 internal/cli/up_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index ebffde6..3bfc5f1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -66,6 +66,8 @@ func NewRootCmd(opts Options) *cobra.Command { cobra.OnInitialize() root.AddCommand( + newUpCmd(g), + newDownCmd(g), newDoctorCmd(g), newConfigCmd(g), newGenerateCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 1c96647..0288d2b 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -30,8 +30,6 @@ func rootName(c *cobra.Command) string { return c.Root().Name() } // alias and version are real; everything else is a milestone-tagged placeholder. func addStubCommands(root *cobra.Command, _ *GlobalOpts) { root.AddCommand( - stub("up", "Bring the workspace up (clone, shared infra, provision, generate, compose up)", "M2/M6"), - stub("down", "Stop this workspace's project stacks", "M2"), stub("status", "Multi-repo git + service health table", "M3"), stub("shell", "Open a shell in a service container", "M2"), stub("logs", "Stream service logs", "M2"), diff --git a/internal/cli/up.go b/internal/cli/up.go new file mode 100644 index 0000000..f59cc38 --- /dev/null +++ b/internal/cli/up.go @@ -0,0 +1,212 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/hooks" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/workspace" + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// newUpCmd wires `devstack up [project...]` — the onboarding saga (spec 09): it +// reconciles the ledger, then drives preflight → network → generate → +// shared(health-gated) → compose-up → hooks, resumable and compensating. +func newUpCmd(g *GlobalOpts) *cobra.Command { + var ( + build bool + noHooks bool + noPreflight bool + profile string + ) + cmd := &cobra.Command{ + Use: "up [project...]", + Short: "Bring the workspace up (network, shared infra, generate, compose up, hooks)", + Long: "up takes a workspace from config to a running, health-gated stack in one\n" + + "idempotent command. Phases record their state so a re-run skips satisfied\n" + + "work and a crash mid-run resumes; a failure compensates the mutating phases\n" + + "(refs/containers) but never destroys data (volumes/DBs survive).", + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + d.Projects = args + d.Build = build + d.NoHooks = noHooks + d.NoPreflight = noPreflight + d.Profile = profile + + // Self-healing reconcile before the saga (spec 09): prune ref rows for + // projects no longer live. Best-effort — never blocks `up`. + _, _ = d.Manager.Reconcile(cmd.Context()) + + phases, err := orchestrate.BuildUp(d) + if err != nil { + return err + } + saga := &orchestrate.Saga{Workspace: d.Model.Workspace.Name, DB: d.DB, LockPath: d.LockPath} + + // Plain/quiet stream each phase as it completes; --json collects them. + if !g.JSON && !g.Quiet { + w := cmd.OutOrStdout() + saga.Emit = func(r orchestrate.Record) { fmt.Fprintln(w, orchestrate.FormatPlain(r)) } + } + records, runErr := saga.Run(cmd.Context(), phases) + + if g.JSON { + if err := writeJSON(cmd, records); err != nil { + return err + } + } + if runErr != nil { + return runErr + } + return nil + }, + } + cmd.Flags().BoolVar(&build, "build", false, "build images before starting (compose build)") + cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks") + cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks") + cmd.Flags().StringVar(&profile, "profile", "", "env-overlay profile for ${profile}") + return cmd +} + +// newDownCmd wires `devstack down [project...]` — stop project stacks, run +// preDown hooks first, drop their ref rows. The external network and volumes are +// never touched; shared services are left running (autostop is X1 config + spec 03). +func newDownCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "down [project...]", + Short: "Stop this workspace's project stacks and release their refs", + RunE: func(cmd *cobra.Command, args []string) error { + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + + projects := args + if len(projects) == 0 { + for name := range d.Model.Projects { + projects = append(projects, name) + } + } + ctx := cmd.Context() + w := cmd.OutOrStdout() + type result struct { + Project string `json:"project"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + } + var results []result + var firstErr error + for _, p := range projects { + if _, ok := d.Model.Projects[p]; !ok { + return fmt.Errorf("project %q is not in this workspace", p) + } + status := "stopped" + if err := downProject(ctx, d, p); err != nil { + status = "failed" + results = append(results, result{Project: p, Status: status, Error: err.Error()}) + if firstErr == nil { + firstErr = err + } + continue + } + results = append(results, result{Project: p, Status: status}) + if !g.JSON && !g.Quiet { + fmt.Fprintf(w, "[ok] down %s\n", p) + } + } + if g.JSON { + if err := writeJSON(cmd, map[string]any{"down": results}); err != nil { + return err + } + } + return firstErr + }, + } + return cmd +} + +// downProject runs a project's preDown hooks (warn-by-default), composes the +// stack down (never -v: volumes survive), and drops its ref rows. +func downProject(ctx context.Context, d orchestrate.UpDeps, project string) error { + outDir := filepath.Join(d.Model.ProjectDir(project), generate.GenDir) + composeFile := filepath.Join(outDir, generate.ComposeFile) + if _, err := os.Stat(composeFile); err != nil { + composeFile = "" // fall back to label-driven `compose -p down` + } + + p := d.Model.Projects[project] + if len(p.Hooks.PreDown) > 0 { + runner := &hooks.Runner{ + Execer: hooks.OSExecer{BaseDir: d.Model.ProjectDir(project), Project: "devstack-" + project, File: composeFile}, + Ledger: d.DB, + Lock: func(ctx context.Context, fn func() error) error { return lock.WithLock(ctx, d.LockPath, fn) }, + } + // preDown defaults to warn so a broken teardown hook can't trap a workspace. + if _, err := runner.RunPhase(ctx, p.Hooks.PreDown, hooks.PhaseOpts{ + Project: project, Phase: "preDown", DefaultOnFailure: hooks.OnWarn, + }); err != nil { + return err + } + } + + cp := docker.Compose{Project: "devstack-" + project, File: composeFile, Dir: outDir, Runner: docker.ExecRunner{}} + if err := cp.Down(ctx, false); err != nil { + return err + } + if _, err := d.Manager.RegisterDown(ctx, project); err != nil { + return err + } + return nil +} + +// buildUpDeps assembles the up/down dependencies from the current directory. It +// uses the real Engine SDK client (up/down require a daemon — the preflight +// phase reports an unreachable daemon clearly). +func buildUpDeps(cmd *cobra.Command) (orchestrate.UpDeps, func(), error) { + var zero orchestrate.UpDeps + cwd, err := os.Getwd() + if err != nil { + return zero, nil, err + } + model, err := config.Load(cwd) + if err != nil { + return zero, nil, err + } + ctx := cmd.Context() + dc, err := docker.NewClient(ctx) + if err != nil { + return zero, nil, fmt.Errorf("docker client: %w", err) + } + db, err := state.Open(ctx, xdg.DataHome(), dc.ContextName()) + if err != nil { + _ = dc.Close() + return zero, nil, err + } + lockPath := filepath.Join(xdg.RuntimeDir(), "devstack.lock") + mgr := &workspace.Manager{Model: model, DB: db, Docker: dc, Source: builtinSource(), LockPath: lockPath} + d := orchestrate.UpDeps{ + Model: model, DB: db, Docker: dc, Manager: mgr, + Source: mgr.Source, LockPath: lockPath, + } + closeFn := func() { + db.Close() + _ = dc.Close() + } + return d, closeFn, nil +} diff --git a/internal/cli/up_test.go b/internal/cli/up_test.go new file mode 100644 index 0000000..e4b8fc0 --- /dev/null +++ b/internal/cli/up_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "strings" + "testing" +) + +func findCmd(t *testing.T, name string) bool { + t.Helper() + root := NewRootCmd(Options{}) + for _, c := range root.Commands() { + if c.Name() == name { + return c.RunE != nil // a real command, not a stub group + } + } + return false +} + +func TestUpDownRegistered(t *testing.T) { + for _, name := range []string{"up", "down"} { + if !findCmd(t, name) { + t.Errorf("command %q is not registered as a real RunE command", name) + } + } +} + +func TestUpOutsideWorkspaceErrors(t *testing.T) { + t.Chdir(t.TempDir()) + root := NewRootCmd(Options{}) + root.SetArgs([]string{"up"}) + root.SetOut(&strings.Builder{}) + root.SetErr(&strings.Builder{}) + if err := root.Execute(); err == nil { + t.Fatal("up outside a workspace should error (no workspace.yaml)") + } +} + +func TestDownOutsideWorkspaceErrors(t *testing.T) { + t.Chdir(t.TempDir()) + root := NewRootCmd(Options{}) + root.SetArgs([]string{"down"}) + root.SetOut(&strings.Builder{}) + root.SetErr(&strings.Builder{}) + if err := root.Execute(); err == nil { + t.Fatal("down outside a workspace should error (no workspace.yaml)") + } +} diff --git a/internal/docker/compose.go b/internal/docker/compose.go index 17eacd7..846653b 100644 --- a/internal/docker/compose.go +++ b/internal/docker/compose.go @@ -84,6 +84,11 @@ func NewCompose(project, file, dir string) *Compose { } func (c *Compose) base() []string { + // File is optional for label-driven verbs (down/stop discover the stack from + // the project name's container labels); up/build require it. + if c.File == "" { + return []string{"compose", "-p", c.Project} + } return []string{"compose", "-p", c.Project, "-f", c.File} } diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 948fbab..172068c 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -49,6 +49,7 @@ type UpDeps struct { Build bool // compose up --build NoHooks bool // skip the hooks phase + NoPreflight bool // skip the preflight phase (fast inner loops) HealthTimeout time.Duration // per-shared-service gate cap (0 → health.Compile default) } @@ -72,12 +73,15 @@ func BuildUp(d UpDeps) ([]Phase, error) { return nil, err } - phases := []Phase{ - preflightPhase(d), + var phases []Phase + if !d.NoPreflight { + phases = append(phases, preflightPhase(d)) + } + phases = append(phases, networkPhase(d), generatePhase(d, gen), sharedPhase(d, projects), - } + ) for _, p := range projects { phases = append(phases, composeUpPhase(d, p)) }