diff --git a/PROGRESS.md b/PROGRESS.md index 0922015..1507f54 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -70,7 +70,7 @@ satisfied phases; `--json` matches the spec contract; `down` decrements refs; - [x] X2 `internal/health` full DAG — **DONE** (PR #37: BuildGraph/Cycle/Waves/RequireHealthchecks) - [~] X3 hooks full — **PARTIAL**: workspace + project preUp/postUp wired into the saga (PR #46). Remaining: firstRun/postPull (need provision scope_key) + --skip-hooks/--force-hooks flags. - [x] X4 profiles/selective-up — **DONE** (PR #53: internal/profile.Resolve — Q-PROFILE resolved). Saga --profile wiring is X5. -- [ ] X5 orchestrate completion — TODO *(ready: X2,X3,X4 in; wire profile slicing + DAG-pruned health into the saga)* +- [x] X5 orchestrate completion — **DONE** (PR #55: `up --profile` service-slicing wired into BuildUp — inactive projects drop out, compose-up restricted to active services, shared phase + health gate pruned to `active.Shared`). Follow-ups (small): spec-native `COMPOSE_PROFILES`/`profiles:` emission, `memoryBudgetMB` warning. - [x] X6 `internal/doctor` full matrix + `--fix` — **DONE** (trust/dns/shared probes PRs #33/#35/#48 + safe reconcile `--fix` PR #49) - [ ] X7 `workspace destroy`/`uninstall` — BLOCKED (S5,X6) - [x] X8 self-update notifier — **DONE** (PR #25, `28c4a78`) diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go new file mode 100644 index 0000000..e3f120e --- /dev/null +++ b/internal/cli/destroy.go @@ -0,0 +1,178 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/orchestrate" +) + +// newWorkspaceCmd wires `devstack workspace ` — workspace-scoped lifecycle +// (spec 13 teardown). Today it carries `destroy`; `uninstall` (machine-global +// teardown incl. CA removal) is a separate top-level verb. +func newWorkspaceCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "workspace", + Short: "Workspace-level lifecycle (teardown)", + } + cmd.AddCommand(newWorkspaceDestroyCmd(g)) + return cmd +} + +// newWorkspaceDestroyCmd wires `devstack workspace destroy` (spec 13): tear down +// THIS workspace's project stacks, release its ref/port ledger rows, warm-stop +// any shared service left at zero refs, and remove generated `.devstack/` +// artifacts. It leaves machine-global state (the shared network, the CA, alias +// symlinks) intact for other workspaces, and — by design in this first cut — +// PRESERVES data: named volumes and provisioned DBs/roles survive (shared +// per-service volume removal is `uninstall`/`db gc` territory). +func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "destroy", + Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)", + Long: "destroy reverses what `up` created for this workspace: it `compose down`s every\n" + + "project stack, drops the workspace's ref + port ledger rows (under the lock),\n" + + "warm-stops any shared service now at zero references, and removes the generated\n" + + "`.devstack/` artifacts.\n\n" + + "It is data-preserving: named volumes and provisioned databases SURVIVE, and the\n" + + "shared network / local CA / alias symlinks are left for other workspaces. Full\n" + + "data + machine-global removal is `uninstall`.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Fail fast before touching Docker: a scripted (--json) run can't answer + // an interactive prompt, so it must pass --yes explicitly. + if g.JSON && !yes { + return fmt.Errorf("refusing to destroy without confirmation: pass --yes for --json/non-interactive use") + } + + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + + projects := sortedProjectNames(d.Model) + if !yes { + prompt := fmt.Sprintf( + "This tears down workspace %q (%d project stack(s)) and releases its refs/ports.\n"+ + "Volumes and databases are PRESERVED. Type 'yes' to continue: ", + d.Model.Workspace.Name, len(projects)) + if !confirm(cmd, prompt) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + + res := destroyWorkspace(cmd.Context(), d, projects) + if g.JSON { + if err := writeJSON(cmd, res); err != nil { + return err + } + } else { + w := cmd.OutOrStdout() + for _, p := range res.Projects { + fmt.Fprintf(w, "[ok] down %s\n", p) + } + for _, s := range res.SharedStopped { + fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s) + } + for _, e := range res.Errors { + fmt.Fprintf(w, "[warn] %s\n", e) + } + fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (volumes/DBs preserved)\n", + d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped)) + } + if len(res.Errors) > 0 { + return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors)) + } + return nil + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)") + return cmd +} + +// DestroyResult is the machine-readable outcome of `workspace destroy`. +type DestroyResult struct { + Workspace string `json:"workspace"` + Projects []string `json:"projects"` // project stacks brought down + SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped + Errors []string `json:"errors,omitempty"` +} + +// destroyWorkspace performs the teardown mechanics (no prompting) so it is +// unit-testable with injected mocks. It is best-effort: a failure on one project +// is recorded and the rest proceed, so a partially-broken workspace can still be +// cleaned up. +func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string) DestroyResult { + res := DestroyResult{Workspace: d.Model.Workspace.Name} + runner := d.Runner + if runner == nil { + runner = docker.ExecRunner{} + } + + // 1. compose down each project stack (containers + project default network; + // named volumes survive — never -v here). + for _, p := range projects { + outDir := filepath.Join(d.Model.ProjectDir(p), generate.GenDir) + composeFile := filepath.Join(outDir, generate.ComposeFile) + if _, err := os.Stat(composeFile); err != nil { + composeFile = "" // label-driven `compose -p devstack-

down` + } + cp := docker.Compose{Project: "devstack-" + p, File: composeFile, Dir: outDir, Runner: runner} + if err := cp.Down(ctx, false); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("down %s: %v", p, err)) + continue + } + res.Projects = append(res.Projects, p) + } + + // 2. drop this workspace's ledger rows (refs + ports) under the flock. + if err := lock.WithLock(ctx, d.LockPath, func() error { + for _, p := range projects { + if _, err := d.DB.RemoveProjectRefs(p); err != nil { + return err + } + if err := d.DB.ReleasePortsFor(p); err != nil { + return err + } + } + return nil + }); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("ledger cleanup: %v", err)) + } + + // 3. warm-stop any shared service now at zero refs (reversible; volumes + // survive). GC enumerates by ledger ∩ live labels, so it only touches + // services no OTHER workspace references. + if gc, err := d.Manager.GC(ctx, true); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("shared gc: %v", err)) + } else { + res.SharedStopped = gc.Stopped + } + + // 4. remove generated artifacts (.devstack): the workspace-root shared dir and + // each project's dir. Best-effort — a missing dir is fine. + _ = os.RemoveAll(filepath.Join(d.Model.Root, generate.GenDir)) + for _, p := range projects { + _ = os.RemoveAll(filepath.Join(d.Model.ProjectDir(p), generate.GenDir)) + } + return res +} + +// confirm prompts on stdout and returns true only if the user types "yes". +func confirm(cmd *cobra.Command, prompt string) bool { + fmt.Fprint(cmd.OutOrStdout(), prompt) + line, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + return strings.EqualFold(strings.TrimSpace(line), "yes") +} diff --git a/internal/cli/destroy_test.go b/internal/cli/destroy_test.go new file mode 100644 index 0000000..6ff6a71 --- /dev/null +++ b/internal/cli/destroy_test.go @@ -0,0 +1,169 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "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/orchestrate" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/workspace" + "github.com/open-source-cloud/devstack/templates" +) + +// destroyFakeRunner records compose invocations so teardown can assert on them. +type destroyFakeRunner struct{ cmds [][]string } + +func (f *destroyFakeRunner) Run(_ context.Context, _ []string, _, name string, args ...string) error { + f.cmds = append(f.cmds, append([]string{name}, args...)) + return nil +} +func (f *destroyFakeRunner) Output(_ context.Context, _ []string, _, name string, args ...string) ([]byte, error) { + f.cmds = append(f.cmds, append([]string{name}, args...)) + return nil, nil +} +func (f *destroyFakeRunner) saw(needles ...string) bool { + for _, c := range f.cmds { + joined := strings.Join(c, " ") + all := true + for _, n := range needles { + if !strings.Contains(joined, n) { + all = false + break + } + } + if all { + return true + } + } + return false +} + +// destroyFixture builds a one-project workspace whose shared postgres is live and +// referenced by the project, with a generated .devstack/ on disk. +func destroyFixture(t *testing.T) (orchestrate.UpDeps, *destroyFakeRunner) { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nshared:\n postgres: { template: postgres, params: { version: \"16\" } }\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web:\n template: node.vite\n uses: [workspace.shared.postgres]\n") + // Generated artifacts that destroy must remove. + write(filepath.Join("app", generate.GenDir, generate.ComposeFile), "services: {}\n") + write(filepath.Join(generate.GenDir, "shared", generate.ComposeFile), "services: {}\n") + + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + if err != nil { + t.Fatalf("state: %v", err) + } + t.Cleanup(func() { db.Close() }) + + mc := &docker.MockClient{ + Context: "ctx", + Containers: []docker.Container{{ + ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", + Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "postgres"}, + }}, + } + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + fr := &destroyFakeRunner{} + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath, Runner: fr} + d := orchestrate.UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, LockPath: lockPath, Runner: fr, + } + return d, fr +} + +func TestWorkspaceDestroyRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"workspace", "destroy"}) + if err != nil || c.Name() != "destroy" || c.RunE == nil { + t.Fatalf("workspace destroy not registered as a real command: %v", err) + } +} + +func TestWorkspaceDestroyJSONRequiresYes(t *testing.T) { + t.Chdir(t.TempDir()) + var out strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"workspace", "destroy", "--json"}) + root.SetOut(&out) + root.SetErr(&out) + // The --json-without-yes guard fires before any Docker/workspace access. + if err := root.Execute(); err == nil { + t.Fatal("workspace destroy --json without --yes must error") + } +} + +func TestDestroyWorkspaceTeardown(t *testing.T) { + d, fr := destroyFixture(t) + ctx := context.Background() + + // Seed live state: a ref row for the project + a port allocation it owns. + if err := d.Manager.RegisterUp(ctx, "app"); err != nil { + t.Fatalf("register up: %v", err) + } + if _, err := d.DB.AllocatePort("app", "web", 30000, 30100, func(int) bool { return true }); err != nil { + t.Fatalf("alloc port: %v", err) + } + if n, _ := d.DB.RefCount("shared-postgres"); n != 1 { + t.Fatalf("precondition: ref count = %d, want 1", n) + } + + res := destroyWorkspace(ctx, d, []string{"app"}) + if len(res.Errors) != 0 { + t.Fatalf("destroy errors: %v", res.Errors) + } + + // Project stack was composed down (never with --volumes). + if !fr.saw("-p devstack-app", "down") { + t.Errorf("project stack was not composed down: %v", fr.cmds) + } + if fr.saw("down", "--volumes") { + t.Error("destroy must never pass --volumes (data preservation)") + } + // Project is in the result. + if len(res.Projects) != 1 || res.Projects[0] != "app" { + t.Errorf("projects = %v, want [app]", res.Projects) + } + // Ref rows + port rows for the project are gone. + if n, _ := d.DB.RefCount("shared-postgres"); n != 0 { + t.Errorf("ref count after destroy = %d, want 0", n) + } + if _, ok, _ := d.DB.PortFor("app", "web"); ok { + t.Error("port allocation for app/web should be released") + } + // The now-orphaned shared service was warm-stopped (compose stop on the shared + // stack), not downed. + if !fr.saw("-p "+generate.SharedStackName, "stop") { + t.Errorf("orphaned shared service was not warm-stopped: %v", fr.cmds) + } + if len(res.SharedStopped) != 1 || res.SharedStopped[0] != generate.SharedAlias("postgres") { + t.Errorf("shared stopped = %v, want [%s]", res.SharedStopped, generate.SharedAlias("postgres")) + } + // Generated artifacts were removed. + if _, err := os.Stat(filepath.Join(d.Model.Root, generate.GenDir)); !os.IsNotExist(err) { + t.Error("workspace .devstack/ should be removed") + } + if _, err := os.Stat(filepath.Join(d.Model.ProjectDir("app"), generate.GenDir)); !os.IsNotExist(err) { + t.Error("project .devstack/ should be removed") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index aea45d7..45cc6fe 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -90,6 +90,7 @@ func NewRootCmd(opts Options) *cobra.Command { newTemplateCmd(g), newSharedCmd(g), newWsCmd(g), + newWorkspaceCmd(g), newSelfCmd(g), newStoreCmd(g), newAliasCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 2667c50..b4360cb 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -33,8 +33,5 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { stub("shell", "Open a shell in a service container", "M2"), stub("logs", "Stream service logs", "M2"), stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"), - stub("workspace", "Workspace-level lifecycle", "M6", - stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"), - ), ) }