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
2 changes: 1 addition & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
178 changes: 178 additions & 0 deletions internal/cli/destroy.go
Original file line number Diff line number Diff line change
@@ -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 <sub>` — 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-<p> 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")
}
169 changes: 169 additions & 0 deletions internal/cli/destroy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newTemplateCmd(g),
newSharedCmd(g),
newWsCmd(g),
newWorkspaceCmd(g),
newSelfCmd(g),
newStoreCmd(g),
newAliasCmd(g),
Expand Down
3 changes: 0 additions & 3 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)
}
Loading