Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions internal/cli/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/open-source-cloud/devstack/internal/generate"
"github.com/open-source-cloud/devstack/internal/lock"
"github.com/open-source-cloud/devstack/internal/orchestrate"
"github.com/open-source-cloud/devstack/internal/resource"
)

// newWorkspaceCmd wires `devstack workspace <sub>` — workspace-scoped lifecycle
Expand All @@ -36,7 +37,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command {
// 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
var yes, purgeData bool
cmd := &cobra.Command{
Use: "destroy",
Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)",
Expand All @@ -63,17 +64,21 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {

projects := sortedProjectNames(d.Model)
if !yes {
dataLine := "Volumes and databases are PRESERVED."
if purgeData {
dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc (DATA DESTROYED)."
}
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))
"%s Type 'yes' to continue: ",
d.Model.Workspace.Name, len(projects), dataLine)
if !confirm(cmd, prompt) {
fmt.Fprintln(cmd.OutOrStdout(), "aborted")
return nil
}
}

res := destroyWorkspace(cmd.Context(), d, projects)
res := destroyWorkspace(cmd.Context(), d, projects, purgeData)
if g.JSON {
if err := writeJSON(cmd, res); err != nil {
return err
Expand All @@ -86,11 +91,18 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
for _, s := range res.SharedStopped {
fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s)
}
for _, p := range res.PurgedResources {
fmt.Fprintf(w, "[ok] dropped %s %s\n", p["kind"], p["name"])
}
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))
dataNote := "volumes/DBs preserved"
if purgeData {
dataNote = fmt.Sprintf("%d resource(s) purged", len(res.PurgedResources))
}
fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (%s)\n",
d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped), dataNote)
}
if len(res.Errors) > 0 {
return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors))
Expand All @@ -99,28 +111,63 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command {
},
}
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)")
cmd.Flags().BoolVar(&purgeData, "purge-data", false, "also DROP every provisioned resource (databases/buckets/…) — DESTRUCTIVE")
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"`
Workspace string `json:"workspace"`
Projects []string `json:"projects"` // project stacks brought down
SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped
PurgedResources []map[string]string `json:"purged_resources,omitempty"` // --purge-data: resources dropped
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 {
func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string, purgeData bool) DestroyResult {
res := DestroyResult{Workspace: d.Model.Workspace.Name}
runner := d.Runner
if runner == nil {
runner = docker.ExecRunner{}
}

// 0. --purge-data (opt-in, DESTRUCTIVE): while the shared engine is still up,
// DROP every resource this workspace provisioned, then remove its ledger rows.
// The data-preserving default skips this entirely (spec 27).
if purgeData {
for _, p := range projects {
rows, err := d.DB.ProvisionedFor(p)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("list resources for %s: %v", p, err))
continue
}
for _, row := range rows {
if row.Kind == "role" {
continue // dropped alongside its database
}
r := resource.Resource{Engine: engineForKindGuess(row.Kind), Kind: row.Kind, Name: row.Name, Owner: p}
if err := orchestrate.DropResource(ctx, d, r, true); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("drop %s %s: %v", row.Kind, row.Name, err))
continue
}
res.PurgedResources = append(res.PurgedResources, map[string]string{"project": p, "kind": row.Kind, "name": row.Name})
}
// Remove any straggler rows (e.g. redis_index) and the overlay ports.
if err := lock.WithLock(ctx, d.LockPath, func() error {
if _, err := d.DB.RemoveProvisionedForProject(p); err != nil {
return err
}
return nil
}); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("clear provisioned rows for %s: %v", p, err))
}
}
}

// 1. compose down each project stack (containers + project default network;
// named volumes survive — never -v here).
for _, p := range projects {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestDestroyWorkspaceTeardown(t *testing.T) {
t.Fatalf("precondition: ref count = %d, want 1", n)
}

res := destroyWorkspace(ctx, d, []string{"app"})
res := destroyWorkspace(ctx, d, []string{"app"}, false)
if len(res.Errors) != 0 {
t.Fatalf("destroy errors: %v", res.Errors)
}
Expand Down
Loading
Loading