Skip to content

Commit aee6f9d

Browse files
gustavobertoiclaude
andcommitted
feat(resource): Stage B step 6 — resource list|show|create|rm|gc + workspace destroy --purge-data (spec 27)
- internal/orchestrate: imperative CreateResource/DropResource/GCResources ops (lock -> overlay -> provisioner -> ledger -> event), mirroring the saga phase. - internal/cli: engine-agnostic 'resource' command group (list/show are lock-free reads; create/rm/gc drive the ops; secrets masked unless --show-secrets), and a new destructive 'workspace destroy --purge-data' flag beside the data-preserving default. Registered under root. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3037573 commit aee6f9d

7 files changed

Lines changed: 950 additions & 12 deletions

File tree

internal/cli/destroy.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/open-source-cloud/devstack/internal/generate"
1515
"github.com/open-source-cloud/devstack/internal/lock"
1616
"github.com/open-source-cloud/devstack/internal/orchestrate"
17+
"github.com/open-source-cloud/devstack/internal/resource"
1718
)
1819

1920
// newWorkspaceCmd wires `devstack workspace <sub>` — workspace-scoped lifecycle
@@ -36,7 +37,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
3637
// PRESERVES data: named volumes and provisioned DBs/roles survive (shared
3738
// per-service volume removal is `uninstall`/`db gc` territory).
3839
func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
39-
var yes bool
40+
var yes, purgeData bool
4041
cmd := &cobra.Command{
4142
Use: "destroy",
4243
Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)",
@@ -63,17 +64,21 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
6364

6465
projects := sortedProjectNames(d.Model)
6566
if !yes {
67+
dataLine := "Volumes and databases are PRESERVED."
68+
if purgeData {
69+
dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc (DATA DESTROYED)."
70+
}
6671
prompt := fmt.Sprintf(
6772
"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))
73+
"%s Type 'yes' to continue: ",
74+
d.Model.Workspace.Name, len(projects), dataLine)
7075
if !confirm(cmd, prompt) {
7176
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
7277
return nil
7378
}
7479
}
7580

76-
res := destroyWorkspace(cmd.Context(), d, projects)
81+
res := destroyWorkspace(cmd.Context(), d, projects, purgeData)
7782
if g.JSON {
7883
if err := writeJSON(cmd, res); err != nil {
7984
return err
@@ -86,11 +91,18 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
8691
for _, s := range res.SharedStopped {
8792
fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s)
8893
}
94+
for _, p := range res.PurgedResources {
95+
fmt.Fprintf(w, "[ok] dropped %s %s\n", p["kind"], p["name"])
96+
}
8997
for _, e := range res.Errors {
9098
fmt.Fprintf(w, "[warn] %s\n", e)
9199
}
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))
100+
dataNote := "volumes/DBs preserved"
101+
if purgeData {
102+
dataNote = fmt.Sprintf("%d resource(s) purged", len(res.PurgedResources))
103+
}
104+
fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (%s)\n",
105+
d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped), dataNote)
94106
}
95107
if len(res.Errors) > 0 {
96108
return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors))
@@ -99,28 +111,63 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
99111
},
100112
}
101113
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)")
114+
cmd.Flags().BoolVar(&purgeData, "purge-data", false, "also DROP every provisioned resource (databases/buckets/…) — DESTRUCTIVE")
102115
return cmd
103116
}
104117

105118
// DestroyResult is the machine-readable outcome of `workspace destroy`.
106119
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"`
120+
Workspace string `json:"workspace"`
121+
Projects []string `json:"projects"` // project stacks brought down
122+
SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped
123+
PurgedResources []map[string]string `json:"purged_resources,omitempty"` // --purge-data: resources dropped
124+
Errors []string `json:"errors,omitempty"`
111125
}
112126

113127
// destroyWorkspace performs the teardown mechanics (no prompting) so it is
114128
// unit-testable with injected mocks. It is best-effort: a failure on one project
115129
// is recorded and the rest proceed, so a partially-broken workspace can still be
116130
// cleaned up.
117-
func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string) DestroyResult {
131+
func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string, purgeData bool) DestroyResult {
118132
res := DestroyResult{Workspace: d.Model.Workspace.Name}
119133
runner := d.Runner
120134
if runner == nil {
121135
runner = docker.ExecRunner{}
122136
}
123137

138+
// 0. --purge-data (opt-in, DESTRUCTIVE): while the shared engine is still up,
139+
// DROP every resource this workspace provisioned, then remove its ledger rows.
140+
// The data-preserving default skips this entirely (spec 27).
141+
if purgeData {
142+
for _, p := range projects {
143+
rows, err := d.DB.ProvisionedFor(p)
144+
if err != nil {
145+
res.Errors = append(res.Errors, fmt.Sprintf("list resources for %s: %v", p, err))
146+
continue
147+
}
148+
for _, row := range rows {
149+
if row.Kind == "role" {
150+
continue // dropped alongside its database
151+
}
152+
r := resource.Resource{Engine: engineForKindGuess(row.Kind), Kind: row.Kind, Name: row.Name, Owner: p}
153+
if err := orchestrate.DropResource(ctx, d, r, true); err != nil {
154+
res.Errors = append(res.Errors, fmt.Sprintf("drop %s %s: %v", row.Kind, row.Name, err))
155+
continue
156+
}
157+
res.PurgedResources = append(res.PurgedResources, map[string]string{"project": p, "kind": row.Kind, "name": row.Name})
158+
}
159+
// Remove any straggler rows (e.g. redis_index) and the overlay ports.
160+
if err := lock.WithLock(ctx, d.LockPath, func() error {
161+
if _, err := d.DB.RemoveProvisionedForProject(p); err != nil {
162+
return err
163+
}
164+
return nil
165+
}); err != nil {
166+
res.Errors = append(res.Errors, fmt.Sprintf("clear provisioned rows for %s: %v", p, err))
167+
}
168+
}
169+
}
170+
124171
// 1. compose down each project stack (containers + project default network;
125172
// named volumes survive — never -v here).
126173
for _, p := range projects {

internal/cli/destroy_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func TestDestroyWorkspaceTeardown(t *testing.T) {
128128
t.Fatalf("precondition: ref count = %d, want 1", n)
129129
}
130130

131-
res := destroyWorkspace(ctx, d, []string{"app"})
131+
res := destroyWorkspace(ctx, d, []string{"app"}, false)
132132
if len(res.Errors) != 0 {
133133
t.Fatalf("destroy errors: %v", res.Errors)
134134
}

0 commit comments

Comments
 (0)