Skip to content

Commit fd97b4f

Browse files
gustavobertoiclaude
andcommitted
feat(cli): X7 (part) — workspace destroy teardown (spec 13)
Implements the workspace-scoped teardown verb (the safe, data-preserving core of spec-13 teardown): - `compose down`s every project stack (never `-v` — named volumes survive); - drops this workspace's `service_ref` + `port_alloc` ledger rows under the flock; - warm-stops any shared service now at zero refs via the existing tested `Manager.GC(stop=true)` — only services no OTHER workspace references, volumes intact; - removes generated `.devstack/` artifacts (workspace shared + per project). Machine-global state (shared network, local CA, alias symlinks) is left for other workspaces. Data loss is gated by confirmation (`--yes` to skip; `--json` requires `--yes` and fails fast before touching Docker). Best-effort: a failure on one project is recorded and the rest proceed, then a non-zero exit reports the count. The teardown mechanics live in `destroyWorkspace(...)` so they unit-test with injected mocks (compose down per project, ref/port rows cleared, orphaned shared warm-stopped, artifacts removed) without a daemon. Removed the `workspace` stub. Deferred (remaining X7): `--purge-data` (shared per-service named-volume removal needs a `docker volume rm` capability not in the read-only client) and `uninstall` (machine-global incl. CA removal across host/Firefox/Windows stores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 088dbab commit fd97b4f

5 files changed

Lines changed: 349 additions & 4 deletions

File tree

‎PROGRESS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ satisfied phases; `--json` matches the spec contract; `down` decrements refs;
7070
- [x] X2 `internal/health` full DAG — **DONE** (PR #37: BuildGraph/Cycle/Waves/RequireHealthchecks)
7171
- [~] 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.
7272
- [x] X4 profiles/selective-up — **DONE** (PR #53: internal/profile.Resolve — Q-PROFILE resolved). Saga --profile wiring is X5.
73-
- [ ] X5 orchestrate completion — TODO *(ready: X2,X3,X4 in; wire profile slicing + DAG-pruned health into the saga)*
73+
- [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.
7474
- [x] X6 `internal/doctor` full matrix + `--fix` — **DONE** (trust/dns/shared probes PRs #33/#35/#48 + safe reconcile `--fix` PR #49)
7575
- [ ] X7 `workspace destroy`/`uninstall` — BLOCKED (S5,X6)
7676
- [x] X8 self-update notifier — **DONE** (PR #25, `28c4a78`)

‎internal/cli/destroy.go‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package cli
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/open-source-cloud/devstack/internal/docker"
14+
"github.com/open-source-cloud/devstack/internal/generate"
15+
"github.com/open-source-cloud/devstack/internal/lock"
16+
"github.com/open-source-cloud/devstack/internal/orchestrate"
17+
)
18+
19+
// newWorkspaceCmd wires `devstack workspace <sub>` — workspace-scoped lifecycle
20+
// (spec 13 teardown). Today it carries `destroy`; `uninstall` (machine-global
21+
// teardown incl. CA removal) is a separate top-level verb.
22+
func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
23+
cmd := &cobra.Command{
24+
Use: "workspace",
25+
Short: "Workspace-level lifecycle (teardown)",
26+
}
27+
cmd.AddCommand(newWorkspaceDestroyCmd(g))
28+
return cmd
29+
}
30+
31+
// newWorkspaceDestroyCmd wires `devstack workspace destroy` (spec 13): tear down
32+
// THIS workspace's project stacks, release its ref/port ledger rows, warm-stop
33+
// any shared service left at zero refs, and remove generated `.devstack/`
34+
// artifacts. It leaves machine-global state (the shared network, the CA, alias
35+
// symlinks) intact for other workspaces, and — by design in this first cut —
36+
// PRESERVES data: named volumes and provisioned DBs/roles survive (shared
37+
// per-service volume removal is `uninstall`/`db gc` territory).
38+
func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
39+
var yes bool
40+
cmd := &cobra.Command{
41+
Use: "destroy",
42+
Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)",
43+
Long: "destroy reverses what `up` created for this workspace: it `compose down`s every\n" +
44+
"project stack, drops the workspace's ref + port ledger rows (under the lock),\n" +
45+
"warm-stops any shared service now at zero references, and removes the generated\n" +
46+
"`.devstack/` artifacts.\n\n" +
47+
"It is data-preserving: named volumes and provisioned databases SURVIVE, and the\n" +
48+
"shared network / local CA / alias symlinks are left for other workspaces. Full\n" +
49+
"data + machine-global removal is `uninstall`.",
50+
Args: cobra.NoArgs,
51+
RunE: func(cmd *cobra.Command, _ []string) error {
52+
// Fail fast before touching Docker: a scripted (--json) run can't answer
53+
// an interactive prompt, so it must pass --yes explicitly.
54+
if g.JSON && !yes {
55+
return fmt.Errorf("refusing to destroy without confirmation: pass --yes for --json/non-interactive use")
56+
}
57+
58+
d, closeFn, err := buildUpDeps(cmd)
59+
if err != nil {
60+
return err
61+
}
62+
defer closeFn()
63+
64+
projects := sortedProjectNames(d.Model)
65+
if !yes {
66+
prompt := fmt.Sprintf(
67+
"This tears down workspace %q (%d project stack(s)) and releases its refs/ports.\n"+
68+
"Volumes and databases are PRESERVED. Type 'yes' to continue: ",
69+
d.Model.Workspace.Name, len(projects))
70+
if !confirm(cmd, prompt) {
71+
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
72+
return nil
73+
}
74+
}
75+
76+
res := destroyWorkspace(cmd.Context(), d, projects)
77+
if g.JSON {
78+
if err := writeJSON(cmd, res); err != nil {
79+
return err
80+
}
81+
} else {
82+
w := cmd.OutOrStdout()
83+
for _, p := range res.Projects {
84+
fmt.Fprintf(w, "[ok] down %s\n", p)
85+
}
86+
for _, s := range res.SharedStopped {
87+
fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s)
88+
}
89+
for _, e := range res.Errors {
90+
fmt.Fprintf(w, "[warn] %s\n", e)
91+
}
92+
fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (volumes/DBs preserved)\n",
93+
d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped))
94+
}
95+
if len(res.Errors) > 0 {
96+
return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors))
97+
}
98+
return nil
99+
},
100+
}
101+
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)")
102+
return cmd
103+
}
104+
105+
// DestroyResult is the machine-readable outcome of `workspace destroy`.
106+
type DestroyResult struct {
107+
Workspace string `json:"workspace"`
108+
Projects []string `json:"projects"` // project stacks brought down
109+
SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped
110+
Errors []string `json:"errors,omitempty"`
111+
}
112+
113+
// destroyWorkspace performs the teardown mechanics (no prompting) so it is
114+
// unit-testable with injected mocks. It is best-effort: a failure on one project
115+
// is recorded and the rest proceed, so a partially-broken workspace can still be
116+
// cleaned up.
117+
func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string) DestroyResult {
118+
res := DestroyResult{Workspace: d.Model.Workspace.Name}
119+
runner := d.Runner
120+
if runner == nil {
121+
runner = docker.ExecRunner{}
122+
}
123+
124+
// 1. compose down each project stack (containers + project default network;
125+
// named volumes survive — never -v here).
126+
for _, p := range projects {
127+
outDir := filepath.Join(d.Model.ProjectDir(p), generate.GenDir)
128+
composeFile := filepath.Join(outDir, generate.ComposeFile)
129+
if _, err := os.Stat(composeFile); err != nil {
130+
composeFile = "" // label-driven `compose -p devstack-<p> down`
131+
}
132+
cp := docker.Compose{Project: "devstack-" + p, File: composeFile, Dir: outDir, Runner: runner}
133+
if err := cp.Down(ctx, false); err != nil {
134+
res.Errors = append(res.Errors, fmt.Sprintf("down %s: %v", p, err))
135+
continue
136+
}
137+
res.Projects = append(res.Projects, p)
138+
}
139+
140+
// 2. drop this workspace's ledger rows (refs + ports) under the flock.
141+
if err := lock.WithLock(ctx, d.LockPath, func() error {
142+
for _, p := range projects {
143+
if _, err := d.DB.RemoveProjectRefs(p); err != nil {
144+
return err
145+
}
146+
if err := d.DB.ReleasePortsFor(p); err != nil {
147+
return err
148+
}
149+
}
150+
return nil
151+
}); err != nil {
152+
res.Errors = append(res.Errors, fmt.Sprintf("ledger cleanup: %v", err))
153+
}
154+
155+
// 3. warm-stop any shared service now at zero refs (reversible; volumes
156+
// survive). GC enumerates by ledger ∩ live labels, so it only touches
157+
// services no OTHER workspace references.
158+
if gc, err := d.Manager.GC(ctx, true); err != nil {
159+
res.Errors = append(res.Errors, fmt.Sprintf("shared gc: %v", err))
160+
} else {
161+
res.SharedStopped = gc.Stopped
162+
}
163+
164+
// 4. remove generated artifacts (.devstack): the workspace-root shared dir and
165+
// each project's dir. Best-effort — a missing dir is fine.
166+
_ = os.RemoveAll(filepath.Join(d.Model.Root, generate.GenDir))
167+
for _, p := range projects {
168+
_ = os.RemoveAll(filepath.Join(d.Model.ProjectDir(p), generate.GenDir))
169+
}
170+
return res
171+
}
172+
173+
// confirm prompts on stdout and returns true only if the user types "yes".
174+
func confirm(cmd *cobra.Command, prompt string) bool {
175+
fmt.Fprint(cmd.OutOrStdout(), prompt)
176+
line, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
177+
return strings.EqualFold(strings.TrimSpace(line), "yes")
178+
}

‎internal/cli/destroy_test.go‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/open-source-cloud/devstack/internal/config"
11+
"github.com/open-source-cloud/devstack/internal/docker"
12+
"github.com/open-source-cloud/devstack/internal/generate"
13+
"github.com/open-source-cloud/devstack/internal/orchestrate"
14+
"github.com/open-source-cloud/devstack/internal/state"
15+
"github.com/open-source-cloud/devstack/internal/template"
16+
"github.com/open-source-cloud/devstack/internal/workspace"
17+
"github.com/open-source-cloud/devstack/templates"
18+
)
19+
20+
// destroyFakeRunner records compose invocations so teardown can assert on them.
21+
type destroyFakeRunner struct{ cmds [][]string }
22+
23+
func (f *destroyFakeRunner) Run(_ context.Context, _ []string, _, name string, args ...string) error {
24+
f.cmds = append(f.cmds, append([]string{name}, args...))
25+
return nil
26+
}
27+
func (f *destroyFakeRunner) Output(_ context.Context, _ []string, _, name string, args ...string) ([]byte, error) {
28+
f.cmds = append(f.cmds, append([]string{name}, args...))
29+
return nil, nil
30+
}
31+
func (f *destroyFakeRunner) saw(needles ...string) bool {
32+
for _, c := range f.cmds {
33+
joined := strings.Join(c, " ")
34+
all := true
35+
for _, n := range needles {
36+
if !strings.Contains(joined, n) {
37+
all = false
38+
break
39+
}
40+
}
41+
if all {
42+
return true
43+
}
44+
}
45+
return false
46+
}
47+
48+
// destroyFixture builds a one-project workspace whose shared postgres is live and
49+
// referenced by the project, with a generated .devstack/ on disk.
50+
func destroyFixture(t *testing.T) (orchestrate.UpDeps, *destroyFakeRunner) {
51+
t.Helper()
52+
root := t.TempDir()
53+
write := func(rel, body string) {
54+
p := filepath.Join(root, rel)
55+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
56+
t.Fatal(err)
57+
}
58+
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
59+
t.Fatal(err)
60+
}
61+
}
62+
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")
63+
write("app/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web:\n template: node.vite\n uses: [workspace.shared.postgres]\n")
64+
// Generated artifacts that destroy must remove.
65+
write(filepath.Join("app", generate.GenDir, generate.ComposeFile), "services: {}\n")
66+
write(filepath.Join(generate.GenDir, "shared", generate.ComposeFile), "services: {}\n")
67+
68+
m, err := config.LoadAt(root)
69+
if err != nil {
70+
t.Fatalf("load: %v", err)
71+
}
72+
db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx")
73+
if err != nil {
74+
t.Fatalf("state: %v", err)
75+
}
76+
t.Cleanup(func() { db.Close() })
77+
78+
mc := &docker.MockClient{
79+
Context: "ctx",
80+
Containers: []docker.Container{{
81+
ID: "pg1", Name: "devstack-shared-postgres-1", State: "running",
82+
Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "postgres"},
83+
}},
84+
}
85+
src := template.NewFSSource(templates.FS)
86+
lockPath := filepath.Join(root, "lock")
87+
fr := &destroyFakeRunner{}
88+
mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath, Runner: fr}
89+
d := orchestrate.UpDeps{
90+
Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, LockPath: lockPath, Runner: fr,
91+
}
92+
return d, fr
93+
}
94+
95+
func TestWorkspaceDestroyRegistered(t *testing.T) {
96+
root := NewRootCmd(Options{})
97+
c, _, err := root.Find([]string{"workspace", "destroy"})
98+
if err != nil || c.Name() != "destroy" || c.RunE == nil {
99+
t.Fatalf("workspace destroy not registered as a real command: %v", err)
100+
}
101+
}
102+
103+
func TestWorkspaceDestroyJSONRequiresYes(t *testing.T) {
104+
t.Chdir(t.TempDir())
105+
var out strings.Builder
106+
root := NewRootCmd(Options{})
107+
root.SetArgs([]string{"workspace", "destroy", "--json"})
108+
root.SetOut(&out)
109+
root.SetErr(&out)
110+
// The --json-without-yes guard fires before any Docker/workspace access.
111+
if err := root.Execute(); err == nil {
112+
t.Fatal("workspace destroy --json without --yes must error")
113+
}
114+
}
115+
116+
func TestDestroyWorkspaceTeardown(t *testing.T) {
117+
d, fr := destroyFixture(t)
118+
ctx := context.Background()
119+
120+
// Seed live state: a ref row for the project + a port allocation it owns.
121+
if err := d.Manager.RegisterUp(ctx, "app"); err != nil {
122+
t.Fatalf("register up: %v", err)
123+
}
124+
if _, err := d.DB.AllocatePort("app", "web", 30000, 30100, func(int) bool { return true }); err != nil {
125+
t.Fatalf("alloc port: %v", err)
126+
}
127+
if n, _ := d.DB.RefCount("shared-postgres"); n != 1 {
128+
t.Fatalf("precondition: ref count = %d, want 1", n)
129+
}
130+
131+
res := destroyWorkspace(ctx, d, []string{"app"})
132+
if len(res.Errors) != 0 {
133+
t.Fatalf("destroy errors: %v", res.Errors)
134+
}
135+
136+
// Project stack was composed down (never with --volumes).
137+
if !fr.saw("-p devstack-app", "down") {
138+
t.Errorf("project stack was not composed down: %v", fr.cmds)
139+
}
140+
if fr.saw("down", "--volumes") {
141+
t.Error("destroy must never pass --volumes (data preservation)")
142+
}
143+
// Project is in the result.
144+
if len(res.Projects) != 1 || res.Projects[0] != "app" {
145+
t.Errorf("projects = %v, want [app]", res.Projects)
146+
}
147+
// Ref rows + port rows for the project are gone.
148+
if n, _ := d.DB.RefCount("shared-postgres"); n != 0 {
149+
t.Errorf("ref count after destroy = %d, want 0", n)
150+
}
151+
if _, ok, _ := d.DB.PortFor("app", "web"); ok {
152+
t.Error("port allocation for app/web should be released")
153+
}
154+
// The now-orphaned shared service was warm-stopped (compose stop on the shared
155+
// stack), not downed.
156+
if !fr.saw("-p "+generate.SharedStackName, "stop") {
157+
t.Errorf("orphaned shared service was not warm-stopped: %v", fr.cmds)
158+
}
159+
if len(res.SharedStopped) != 1 || res.SharedStopped[0] != generate.SharedAlias("postgres") {
160+
t.Errorf("shared stopped = %v, want [%s]", res.SharedStopped, generate.SharedAlias("postgres"))
161+
}
162+
// Generated artifacts were removed.
163+
if _, err := os.Stat(filepath.Join(d.Model.Root, generate.GenDir)); !os.IsNotExist(err) {
164+
t.Error("workspace .devstack/ should be removed")
165+
}
166+
if _, err := os.Stat(filepath.Join(d.Model.ProjectDir("app"), generate.GenDir)); !os.IsNotExist(err) {
167+
t.Error("project .devstack/ should be removed")
168+
}
169+
}

‎internal/cli/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func NewRootCmd(opts Options) *cobra.Command {
9090
newTemplateCmd(g),
9191
newSharedCmd(g),
9292
newWsCmd(g),
93+
newWorkspaceCmd(g),
9394
newSelfCmd(g),
9495
newStoreCmd(g),
9596
newAliasCmd(g),

‎internal/cli/stubs.go‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,5 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3333
stub("shell", "Open a shell in a service container", "M2"),
3434
stub("logs", "Stream service logs", "M2"),
3535
stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"),
36-
stub("workspace", "Workspace-level lifecycle", "M6",
37-
stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"),
38-
),
3936
)
4037
}

0 commit comments

Comments
 (0)