From 291525b3e0eadc6ecccf9a79b095831909f8009a Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 15:39:00 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20X7=20=E2=80=94=20`uninstall`=20mac?= =?UTF-8?q?hine-global=20teardown=20(spec=2013)=20=E2=80=94=20X7=20COMPLET?= =?UTF-8?q?E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes every machine-global devstack artifact, in spec order, holding the flock: 1. `compose down -v` every managed stack (containers + their named volumes, including the shared Postgres/Redis/MinIO data) — enumerated from the SDK by tool label, grouped by Compose project; 2. `docker network rm devstack_shared` (compose refuses external networks — a not-found is the desired end state, not a warning); 3. remove the root CA from every trust store via `trust.Uninstall` (mkcert -uninstall: host + Firefox/NSS + Windows) — the load-bearing security step, warns loudly if it can't be cleared; 4. remove the marker-fenced /etc/hosts block; 5. remove alias symlinks (+ registry entries); 6. remove the ledger / template cache / config (XDG data/cache/config dirs). Best-effort: each step records a warning and the rest proceed (a half-broken install must still be removable); a non-zero exit reports the warning count. Gated by confirmation (type `uninstall`; `--yes` skips; `--json` requires `--yes`). Network/volume removal shells the docker CLI via the existing Runner — the SDK Client stays read-only. The sequencer is `runUninstall(ctx, uninstallEnv{...})`, unit-tested with a mock client + fake runner + fake trust runner + temp XDG/hosts paths: every stack down -v, network removed, CA cleared, hosts block gone, dirs removed; and a not-found network counts as removed (no warning). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/root.go | 1 + internal/cli/uninstall.go | 244 +++++++++++++++++++++++++++++++++ internal/cli/uninstall_test.go | 150 ++++++++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 internal/cli/uninstall.go create mode 100644 internal/cli/uninstall_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 45cc6fe..f60a83e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,6 +91,7 @@ func NewRootCmd(opts Options) *cobra.Command { newSharedCmd(g), newWsCmd(g), newWorkspaceCmd(g), + newUninstallCmd(g), newSelfCmd(g), newStoreCmd(g), newAliasCmd(g), diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go new file mode 100644 index 0000000..2d87ba5 --- /dev/null +++ b/internal/cli/uninstall.go @@ -0,0 +1,244 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/alias" + "github.com/open-source-cloud/devstack/internal/dns" + "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/trust" + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// composeProjectLabel is the standard Compose label devstack stacks carry; it +// groups containers/volumes into a project for label-driven teardown. +const composeProjectLabel = "com.docker.compose.project" + +// newUninstallCmd wires `devstack uninstall` (spec 13): the reverse of everything +// the tool creates, machine-wide. Strict order — network/volumes before the +// ledger that records them, the CA last (the security-critical one). Gated by an +// explicit data-loss confirmation; the whole operation holds the flock. +func newUninstallCmd(g *GlobalOpts) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove EVERY machine-global devstack artifact (stacks, network, volumes, CA, hosts, aliases, ledger)", + Long: "uninstall reverses everything devstack created on this machine:\n" + + " 1. compose down -v every managed stack (containers + their volumes, incl. shared DB data)\n" + + " 2. remove the external `devstack_shared` network (compose won't — devstack owns it)\n" + + " 3. remove the local root CA from every trust store (host + Firefox/NSS + Windows)\n" + + " 4. remove the marker-fenced /etc/hosts entries\n" + + " 5. remove alias symlinks\n" + + " 6. remove the ledger, template cache and config (XDG data/cache/config)\n\n" + + "It does NOT touch your committed workspace.yaml/devstack.yaml. This DESTROYS DATA\n" + + "(database volumes included) and is irreversible.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !yes { + if g.JSON { + return fmt.Errorf("refusing to uninstall without confirmation: pass --yes for --json/non-interactive use") + } + if !confirm(cmd, "This DESTROYS all devstack data on this machine (database volumes included) and\n"+ + "removes the local CA from your trust stores. Type 'uninstall' to continue: ") { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + + ctx := cmd.Context() + client, err := docker.NewClient(ctx) + if err != nil { + return fmt.Errorf("docker client: %w", err) + } + defer client.Close() + + var res UninstallResult + lockPath := lockPathForUninstall() + err = lock.WithLock(ctx, lockPath, func() error { + res = runUninstall(ctx, uninstallEnv{ + Client: client, + Runner: docker.ExecRunner{}, + Trust: trust.New(), + HostsPath: dns.DefaultHostsPath, + Dirs: []string{xdg.DataHome(), xdg.CacheHome(), xdg.ConfigHome()}, + }) + return nil + }) + if err != nil { + return err + } + + if g.JSON { + if err := writeJSON(cmd, res); err != nil { + return err + } + } else { + renderUninstall(cmd, res) + } + if len(res.Warnings) > 0 { + return fmt.Errorf("uninstall completed with %d warning(s) — see above", len(res.Warnings)) + } + return nil + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)") + return cmd +} + +// uninstallEnv holds the (injectable) collaborators so the sequencer is testable +// with mocks + temp paths. +type uninstallEnv struct { + Client docker.Client + Runner docker.Runner + Trust *trust.Trust + HostsPath string + Dirs []string // XDG dirs to remove (data/cache/config) — devstack-namespaced +} + +// UninstallResult is the machine-readable outcome. +type UninstallResult struct { + ProjectsDown []string `json:"projects_down"` + NetworkRemoved bool `json:"network_removed"` + CACleared bool `json:"ca_cleared"` + HostsCleared bool `json:"hosts_cleared"` + AliasesRemoved []string `json:"aliases_removed"` + DirsRemoved []string `json:"dirs_removed"` + Warnings []string `json:"warnings,omitempty"` +} + +// runUninstall executes the teardown sequence, best-effort: each step records a +// warning on failure and the rest proceed (a half-broken install must still be +// removable). It returns what it actually did. +func runUninstall(ctx context.Context, env uninstallEnv) UninstallResult { + var res UninstallResult + + // 1. compose down -v every managed stack (containers + their named volumes, + // including the shared Postgres/Redis/MinIO data). + for _, project := range managedProjects(ctx, env, &res) { + cp := docker.Compose{Project: project, Runner: env.Runner} // label-driven (no -f) + if err := cp.Down(ctx, true); err != nil { // -v: this is the data-destroying step + res.Warnings = append(res.Warnings, fmt.Sprintf("compose down %s: %v", project, err)) + continue + } + res.ProjectsDown = append(res.ProjectsDown, project) + } + + // 2. remove the external network (compose refuses external networks). + if err := env.Runner.Run(ctx, nil, "", "docker", "network", "rm", generate.SharedNetwork); err != nil { + if isNotFound(err) { + res.NetworkRemoved = true // already gone — the desired end state + } else { + res.Warnings = append(res.Warnings, fmt.Sprintf("network rm %s: %v", generate.SharedNetwork, err)) + } + } else { + res.NetworkRemoved = true + } + + // 3. remove the root CA from every trust store (host + Firefox/NSS + Windows). + // The load-bearing security step — warn loudly if it can't be cleared. + if env.Trust != nil { + if err := env.Trust.Uninstall(ctx); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("CA NOT cleared from trust stores: %v (a CA left behind is a security risk — run `mkcert -uninstall` manually)", err)) + } else { + res.CACleared = true + } + } + + // 4. remove the marker-fenced /etc/hosts entries. + if removed, err := dns.Remove(env.HostsPath); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("/etc/hosts cleanup: %v (try `sudo devstack uninstall`)", err)) + } else { + res.HostsCleared = removed + } + + // 5. remove alias symlinks (and their registry entries). + if reg, err := alias.Load(); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("alias registry: %v", err)) + } else { + for _, name := range append([]string(nil), reg.Aliases...) { + if err := alias.Remove(name); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("remove alias %s: %v", name, err)) + continue + } + res.AliasesRemoved = append(res.AliasesRemoved, name) + } + } + + // 6. remove the ledger, template cache and config (XDG, devstack-namespaced). + for _, dir := range env.Dirs { + if err := os.RemoveAll(dir); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("remove %s: %v", dir, err)) + continue + } + res.DirsRemoved = append(res.DirsRemoved, dir) + } + return res +} + +// managedProjects returns the distinct Compose project names of every managed +// container (running or stopped). A daemon error is recorded as a warning. +func managedProjects(ctx context.Context, env uninstallEnv, res *UninstallResult) []string { + cs, err := env.Client.ListManaged(ctx, map[string]string{generate.LabelManaged: "true"}) + if err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("list managed containers: %v", err)) + return nil + } + seen := map[string]bool{} + for _, c := range cs { + if p := c.Labels[composeProjectLabel]; p != "" && !seen[p] { + seen[p] = true + } + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out +} + +func renderUninstall(cmd *cobra.Command, res UninstallResult) { + w := cmd.OutOrStdout() + for _, p := range res.ProjectsDown { + fmt.Fprintf(w, "[ok] removed stack %s (with volumes)\n", p) + } + if res.NetworkRemoved { + fmt.Fprintf(w, "[ok] removed network %s\n", generate.SharedNetwork) + } + if res.CACleared { + fmt.Fprintln(w, "[ok] cleared the local CA from all trust stores") + } + if res.HostsCleared { + fmt.Fprintln(w, "[ok] removed /etc/hosts entries") + } + for _, a := range res.AliasesRemoved { + fmt.Fprintf(w, "[ok] removed alias %s\n", a) + } + for _, d := range res.DirsRemoved { + fmt.Fprintf(w, "[ok] removed %s\n", d) + } + for _, warn := range res.Warnings { + fmt.Fprintf(w, "[warn] %s\n", warn) + } + fmt.Fprintln(w, "uninstall complete.") +} + +// isNotFound reports whether a docker CLI error is a benign "no such object". +func isNotFound(err error) bool { + s := strings.ToLower(err.Error()) + return strings.Contains(s, "not found") || strings.Contains(s, "no such") +} + +// lockPathForUninstall returns the flock path (same as the rest of the tool). +func lockPathForUninstall() string { + return filepath.Join(xdg.RuntimeDir(), "devstack.lock") +} diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go new file mode 100644 index 0000000..cad73c0 --- /dev/null +++ b/internal/cli/uninstall_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/trust" +) + +// uninstallTrustRunner makes trust.Uninstall find mkcert and succeed. +type uninstallTrustRunner struct{ ran bool } + +func (r *uninstallTrustRunner) Output(context.Context, string, ...string) ([]byte, error) { + return []byte("/ca/root"), nil +} +func (r *uninstallTrustRunner) Run(_ context.Context, _ string, args ...string) error { + if len(args) > 0 && args[0] == "-uninstall" { + r.ran = true + } + return nil +} +func (r *uninstallTrustRunner) LookPath(string) (string, error) { return "/usr/bin/mkcert", nil } + +func TestUninstallRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"uninstall"}) + if err != nil || c.Name() != "uninstall" || c.RunE == nil { + t.Fatalf("uninstall not registered as a real command: %v", err) + } +} + +func TestUninstallJSONRequiresYes(t *testing.T) { + t.Chdir(t.TempDir()) + var out strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"uninstall", "--json"}) + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err == nil { + t.Fatal("uninstall --json without --yes must error") + } +} + +func TestRunUninstallSequence(t *testing.T) { + // Devstack-namespaced XDG dirs to remove + an /etc/hosts stand-in. + data := filepath.Join(t.TempDir(), "data") + cache := filepath.Join(t.TempDir(), "cache") + config := filepath.Join(t.TempDir(), "config") + for _, d := range []string{data, cache, config} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "marker"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + hosts := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(hosts, []byte( + "127.0.0.1 localhost\n# >>> devstack >>> (managed — do not edit)\n127.0.0.1 app.localhost\n# <<< devstack <<<\n"), 0o644); err != nil { + t.Fatal(err) + } + + mc := &docker.MockClient{ + Context: "ctx", + Containers: []docker.Container{ + {ID: "a", Name: "devstack-app-web-1", State: "running", Labels: map[string]string{ + generate.LabelManaged: "true", "com.docker.compose.project": "devstack-app"}}, + {ID: "p", Name: "devstack-shared-postgres-1", State: "running", Labels: map[string]string{ + generate.LabelManaged: "true", "com.docker.compose.project": generate.SharedStackName}}, + }, + } + fr := &destroyFakeRunner{} + tr := &trust.Trust{Runner: &uninstallTrustRunner{}} + + res := runUninstall(context.Background(), uninstallEnv{ + Client: mc, Runner: fr, Trust: tr, HostsPath: hosts, + Dirs: []string{data, cache, config}, + }) + + if len(res.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", res.Warnings) + } + // Every managed stack composed down WITH volumes. + for _, p := range []string{"devstack-app", generate.SharedStackName} { + if !fr.saw("-p "+p, "down", "--volumes") { + t.Errorf("stack %s not composed down -v: %v", p, fr.cmds) + } + } + if len(res.ProjectsDown) != 2 { + t.Errorf("projects down = %v, want 2", res.ProjectsDown) + } + // External network removed. + if !fr.saw("network", "rm", generate.SharedNetwork) || !res.NetworkRemoved { + t.Errorf("network not removed: %v", fr.cmds) + } + // CA cleared via mkcert -uninstall. + if !res.CACleared { + t.Error("CA was not cleared") + } + // /etc/hosts marker block removed. + if !res.HostsCleared { + t.Error("hosts block not cleared") + } + if b, _ := os.ReadFile(hosts); strings.Contains(string(b), "app.localhost") { + t.Errorf("devstack hosts entries survived:\n%s", b) + } + // XDG dirs removed. + if len(res.DirsRemoved) != 3 { + t.Errorf("dirs removed = %v, want 3", res.DirsRemoved) + } + for _, d := range []string{data, cache, config} { + if _, err := os.Stat(d); !os.IsNotExist(err) { + t.Errorf("dir %s should be removed", d) + } + } +} + +func TestRunUninstallNetworkAlreadyGone(t *testing.T) { + // A "network not found" is the desired end state, not a warning. + fr := ¬FoundRunner{} + res := runUninstall(context.Background(), uninstallEnv{ + Client: &docker.MockClient{Context: "ctx"}, Runner: fr, + Trust: &trust.Trust{Runner: &uninstallTrustRunner{}}, + HostsPath: filepath.Join(t.TempDir(), "nohosts"), Dirs: nil, + }) + if !res.NetworkRemoved { + t.Error("a not-found network should count as removed (desired end state)") + } + for _, w := range res.Warnings { + if strings.Contains(w, "network rm") { + t.Errorf("not-found network should not warn: %v", res.Warnings) + } + } +} + +// notFoundRunner fails network rm with a not-found error, succeeds otherwise. +type notFoundRunner struct{ destroyFakeRunner } + +func (r *notFoundRunner) Run(ctx context.Context, env []string, dir, name string, args ...string) error { + _ = r.destroyFakeRunner.Run(ctx, env, dir, name, args...) + if len(args) >= 2 && args[0] == "network" && args[1] == "rm" { + return &docker.CmdError{Cmd: "docker network rm", Stderr: "Error: No such network: devstack_shared"} + } + return nil +}