From 9a54abef7bc06e8ef2e9c6f90435a218e5c6175f Mon Sep 17 00:00:00 2001 From: Asad Iqbal Date: Tue, 7 Jul 2026 12:27:39 +0500 Subject: [PATCH] =?UTF-8?q?feat(cli):=20rebase=20prototype=20onto=20develo?= =?UTF-8?q?p=20(#142)=20=E2=80=94=20surface=20change=20+=20delete=20+=20no?= =?UTF-8?q?deboot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop's #142 landed the §7.3 namespace-discovery + cluster-info binding (the pieces this branch had folded from #133), so those are dropped as redundant. What remains unique to this prototype (RFC-0001 §15, cli#136) is re-applied cleanly on top of #142: - `client use` removed; `client list` hidden (installer #303 pre-flight only); `client delete` added — deprovision + helm uninstall + local cluster delete. - internal/nodeboot (TeardownCluster + UninstallChart) + api.DeleteClient. - RFC-0001 §15 amendment (PROPOSED). Rebuilt via reset-to-develop + re-apply to avoid a semantic mis-merge (the auto-merge had silently reverted the surface to develop's create/list/use). Co-Authored-By: Claude Opus 4.8 --- .../0001-cli-auth-and-client-provisioning.md | 65 ++++++++ internal/api/client.go | 15 ++ internal/cli/client.go | 154 +++++++++++++----- internal/cli/client_test.go | 56 +++++-- internal/nodeboot/nodeboot.go | 78 +++++++++ internal/nodeboot/nodeboot_test.go | 91 +++++++++++ 6 files changed, 412 insertions(+), 47 deletions(-) create mode 100644 internal/nodeboot/nodeboot.go create mode 100644 internal/nodeboot/nodeboot_test.go diff --git a/docs/rfcs/0001-cli-auth-and-client-provisioning.md b/docs/rfcs/0001-cli-auth-and-client-provisioning.md index 0fdc7c9..61046c0 100644 --- a/docs/rfcs/0001-cli-auth-and-client-provisioning.md +++ b/docs/rfcs/0001-cli-auth-and-client-provisioning.md @@ -45,6 +45,18 @@ > server-side revoke needs the `POST /auth/revoke` endpoint (backend#887, not built) > + a CLI `logout`→call (backend#845 shipped only the `revoke()` primitive) — > §6.3/§7.5/§9/§13/C.6. The earlier "revokes server-side via #845" claim overstated it. +> +> **Rev 8 (2026-07-06) — PROPOSED amendment, not yet accepted ([cli#136], draft).** +> Simplifies the client command surface to a strict **one machine = one client = +> one cluster** model and reaffirms the installer as the create front door. See +> **§15** for the full amendment. Net: `client use` is **removed** and the D3 +> arrow-key picker is dropped (nothing to select); `client list` is **hidden** +> (kept only for the installer's client#303 pre-flight); `client create` stays +> **provision-only** (the one-line installer bootstraps the cluster, then calls it — +> the CLI does **not** reimplement k3s/k3d/GPU/proxy); `client delete` gains +> ownership of the inverse **teardown** (deprovision + Helm uninstall + local +> cluster delete), folding in R12. This narrows §6.2/§7.1/§7.3 and supersedes the +> §7.3 "selected-vs-connected" picker work (the closed [cli#133]). ## 0. Decisions settled in this revision @@ -1258,6 +1270,59 @@ class Meta: // `login --env X` switches current_env without clearing the other profiles. ``` +## 15. Amendment (Rev 8) — CLI client-surface simplification + +> **Status: PROPOSED, not yet accepted** — prototyped on [cli#136] (draft). +> This section narrows the command surface in §6.2 / §7.1 / §7.3 and the D3 +> decision. Where it conflicts with the body above, this amendment wins **once +> accepted**; until then the body is the design of record. + +### 15.1 Motivation + +Live testing surfaced that the account-context, multi-client surface (`list`, +`use`, the D3 picker, selected-vs-connected) is confusing on the box that *is* +a cluster: an operator saw ~18 unrelated clients, a stale "active" pointer to a +client running on another machine, and `cluster info` targeting the wrong +namespace. The product reality is stricter than the RFC assumed: **one machine +runs one cluster, which holds one client** (the §3.1 / Q5 invariant). There is +nothing to *select*, so the selection surface is cost without benefit. + +### 15.2 Decisions + +| # | Was (body) | Amended | +|---|---|---| +| A1 | `client use []` selects among clients; D3 arrow-key picker (§6.2/§7.1). | **Removed.** One client per machine — nothing to select. Drop `use` and the picker. | +| A2 | `client list` is the account-wide fleet view (§8.4). | **Hidden** (not user-facing). Kept callable only for the installer's one-client-per-machine pre-flight (client#303, `provision.sh:_account_owns_namespace`). | +| A3 | `create` "operates against an already-reachable cluster… never creates a cluster" (§6.2); installer bootstraps first (§6.4). | **Reaffirmed, explicitly.** The one-line installer is the create front door: it bootstraps the cluster (k3s/k3d + GPU + proxy — its existing `scripts/lib/*`) and then calls `client create` to provision. The Go CLI does **not** reimplement cluster bootstrap (a `create → installer` call would recurse; reimplementing the installer in Go is a large, duplicative risk). | +| A4 | Clean uninstall deferred to a `--uninstall` flag (R12, §6.2). | **`client delete` owns the inverse teardown**: deprovision the backend client (`DELETE /edge-device//`, C.3), `helm uninstall`, delete the local cluster, clear the local pointer. Confirms; `--yes` skips; `403 → ask-an-admin`. This is R12, realized as `delete`. | + +### 15.3 What stays + +- **Auth** (§6.1/§6.3), **provisioning + cluster_id anchor** (§6.6/§7.2/§7.9), + **the installer/CLI credential contract** (§6.4) — all unchanged. `create` + is still get-or-create keyed on the cluster. +- **§7.3 binding for the *data* + `cluster info` commands** — they still default + their namespace to the **active client** (now set only by `create`, since + `use` is gone) and give the "runs on another machine" guidance when the + reachable cluster doesn't host it. +- The **1:1 client↔cluster invariant** and **re-parenting deferred** (Q5) are + unchanged; a rebuilt cluster still mints a new client (R3). + +### 15.4 Consequences / open items + +- The §7.3 "selected-vs-connected" picker + connection-state-on-`use` work is + **superseded** (the closed [cli#133]); only its `cluster info` binding + the + `explain`-on-`ErrNoParentRelease` cleanup were folded into [cli#136]. +- `client delete`'s teardown is **k3d/Linux + shell-out** in the prototype, and + is unit-tested only — it needs a real run against a throwaway cluster before + release. +- **Tickets to open on acceptance:** (a) the CLI surface change (this amendment) + under the epic; (b) e2e-verify `client delete` teardown; (c) confirm the + installer's client#303 pre-flight still works against a hidden `list`. + +[cli#133]: https://github.com/tracebloc/cli/pull/133 +[cli#136]: https://github.com/tracebloc/cli/pull/136 + [backend#830]: https://github.com/tracebloc/backend/issues/830 [backend#835]: https://github.com/tracebloc/backend/issues/835 [backend#836]: https://github.com/tracebloc/backend/issues/836 diff --git a/internal/api/client.go b/internal/api/client.go index cb0dff1..64917db 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -186,6 +186,21 @@ func (c *Client) patch(ctx context.Context, path string, body any) (int, []byte, return c.bodyRequest(ctx, http.MethodPatch, path, body) } +// DeleteClient deprovisions an EdgeDevice by its numeric id (RFC-0001 C.3, +// DELETE /edge-device//). 2xx or 404 both mean "gone" (idempotent); a 403 +// surfaces as an APIError so the caller can route it to "ask an admin". +func (c *Client) DeleteClient(ctx context.Context, id int) error { + path := fmt.Sprintf("/edge-device/%d/", id) + status, raw, err := c.bodyRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + if status == http.StatusNotFound || (status >= 200 && status < 300) { + return nil + } + return &APIError{StatusCode: status, Body: string(raw), URL: c.BaseURL + path} +} + // bodyRequest sends an authenticated JSON-body request (POST/PATCH) and returns // the status code + raw response. Shared so POST and PATCH stay identical on // auth, content-type, and the 426 upgrade-required handling. diff --git a/internal/cli/client.go b/internal/cli/client.go index a8e39be..7014833 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -18,6 +18,7 @@ import ( "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" "github.com/tracebloc/cli/internal/geo" + "github.com/tracebloc/cli/internal/nodeboot" "github.com/tracebloc/cli/internal/slug" "github.com/tracebloc/cli/internal/ui" ) @@ -32,17 +33,30 @@ var readClusterID = cluster.ClusterID // anchor. A package var so tests can stub it without a reachable cluster. var readInClusterClient = cluster.DiscoverInClusterClient -// newClientCmd wires the `tracebloc client` subtree — provisioning + selecting -// the client (machine) this host enrolls as. Consumes the backend provisioning -// endpoints (backend#836) with the user token from `tracebloc login`. +// nodeboot hooks — package vars so tests can stub the k3d/helm shell-outs. +// PROTOTYPE (RFC-0001 §15, cli#136): the one-line installer stays the create +// front door; `client delete` owns the inverse teardown. +var ( + teardownCluster = nodeboot.TeardownCluster + uninstallChart = nodeboot.UninstallChart +) + +// newClientCmd wires the `tracebloc client` subtree. PROTOTYPE (RFC-0001 §15): +// `use` is withdrawn (one machine owns one client — nothing to select), and +// `list` is hidden (kept callable for the installer's client#303 pre-flight, +// off the user-facing surface). `create` provisions this machine's client (the +// installer bootstraps the cluster first); `delete` tears it all down. func newClientCmd() *cobra.Command { cmd := &cobra.Command{ Use: "client", - Short: "Provision and manage the clients in your account", - Long: `Provision a tracebloc client for this machine and list/select clients -in your account. Requires sign-in first (` + "`tracebloc login`" + `).`, + Short: "Provision or remove this machine's tracebloc client", + Long: `Provision this machine as a tracebloc client, or tear it (and its +local cluster) down. Requires sign-in first (` + "`tracebloc login`" + `). + +To set up a machine from scratch, run the one-line installer — it bootstraps +the cluster and provisions the client for you.`, } - cmd.AddCommand(newClientCreateCmd(), newClientListCmd(), newClientUseCmd()) + cmd.AddCommand(newClientCreateCmd(), newClientDeleteCmd(), newClientListCmd()) return cmd } @@ -78,11 +92,16 @@ type clientCreateOpts struct { yes bool } +// newClientListCmd is hidden (RFC-0001 §15): `use` is gone so users have +// nothing to select, but the installer's client#303 one-client-per-machine +// pre-flight (provision.sh:_account_owns_namespace) still shells out to +// `client list`. Keep it callable, off the user-facing surface. func newClientListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List the clients in your account", + Hidden: true, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runClientList(cmd.Context(), printerFor(cmd)) @@ -90,15 +109,99 @@ func newClientListCmd() *cobra.Command { } } -func newClientUseCmd() *cobra.Command { - return &cobra.Command{ - Use: "use ", - Short: "Enroll this machine as an existing client", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runClientUse(cmd.Context(), printerFor(cmd), args[0]) +// newClientDeleteCmd implements `tracebloc client delete` (RFC-0001 §15): the +// inverse of the installer's setup — deprovision this machine's client, +// uninstall its Helm release, delete the local cluster. +func newClientDeleteCmd() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete this machine's client, its chart release, and its local cluster", + Long: `Tears down the client this machine is enrolled as: deprovisions the +backend client, uninstalls the Helm release, and deletes the local cluster. +Destructive and not undoable.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var pr prompter + if !yes && isInteractiveTTY() { + pr = surveyPrompter{} + } + return runClientDelete(cmd.Context(), printerFor(cmd), pr, yes) }, } + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + return cmd +} + +func runClientDelete(ctx context.Context, p *ui.Printer, pr prompter, yes bool) error { + client, cfg, err := authedClient() + if err != nil { + return &exitError{code: 1, err: err} + } + prof := cfg.Current() + if prof.ActiveClientID == "" { + return &exitError{code: 1, err: errors.New("no active client on this machine — nothing to delete")} + } + id, cerr := strconv.Atoi(prof.ActiveClientID) + if cerr != nil { + return &exitError{code: 1, err: fmt.Errorf("stored active client id %q is not numeric: %w", prof.ActiveClientID, cerr)} + } + ns, name := prof.ActiveClientNamespace, prof.ActiveClientName + if name == "" { + name = prof.ActiveClientID + } + + p.Banner("tracebloc", "delete this machine's client") + p.Warnf("This deletes client %q (namespace %s), its Helm release, and the local cluster %q. Not undoable.", + name, ns, nodeboot.ClusterName) + + if !yes { + if pr == nil { + return &exitError{code: 1, err: errors.New( + "refusing to delete without confirmation: pass --yes or run on a terminal")} + } + ok, perr := pr.Confirm(fmt.Sprintf("Delete client %q and its cluster?", name), false) + if perr != nil { + return mapClientErr(perr) + } + if !ok { + p.Infof("Cancelled — nothing was deleted.") + return nil + } + } + + // 1. Deprovision the backend client (403 → ask an admin). + if derr := client.DeleteClient(ctx, id); derr != nil { + var ae *api.APIError + if errors.As(derr, &ae) && ae.StatusCode == http.StatusForbidden { + return askAnAdmin(ctx, p, client) + } + return &exitError{code: 1, err: fmt.Errorf("deprovisioning the client: %w", derr)} + } + p.Successf("Deprovisioned client %q.", name) + + // 2. Uninstall the chart release (best-effort — the backend record is gone). + if ns != "" { + if uerr := uninstallChart(ctx, ns); uerr != nil { + p.Warnf("Chart uninstall reported: %v", uerr) + } else { + p.Successf("Uninstalled the Helm release %s.", ns) + } + } + + // 3. Tear down the local cluster. + if terr := teardownCluster(ctx, nodeboot.ClusterName); terr != nil { + p.Warnf("Cluster teardown reported: %v", terr) + } else { + p.Successf("Deleted local cluster %q.", nodeboot.ClusterName) + } + + // 4. Clear the local pointer so a stale client isn't left selected. + prof.ActiveClientID, prof.ActiveClientNamespace, prof.ActiveClientName = "", "", "" + if serr := cfg.Save(); serr != nil { + p.Hintf("Couldn't clear the local active-client pointer: %v", serr) + } + return nil } // clientPrompter returns the interactive prompter on a TTY, else nil (so @@ -626,29 +729,6 @@ func clientStateLabel(status int) string { } } -func runClientUse(ctx context.Context, p *ui.Printer, id string) error { - client, cfg, err := authedClient() - if err != nil { - return &exitError{code: 1, err: err} - } - clients, err := client.ListClients(ctx) - if err != nil { - return &exitError{code: 1, err: err} - } - for _, c := range clients { - if strconv.Itoa(c.ID) == id { - setActiveClient(cfg.Current(), &c) - if serr := cfg.Save(); serr != nil { - return &exitError{code: 1, err: serr} - } - p.Successf("This machine is now set to enroll as client %s (%s).", id, c.Name) - return nil - } - } - return &exitError{code: 1, err: fmt.Errorf( - "no client %s in your account — run `tracebloc client list` to see the ids", id)} -} - // setActiveClient points this env's profile at c, caching its namespace and // display name alongside the id so the data commands can bind to the active // client's cluster (§7.3) without a backend round-trip. Callers Save() after. diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index fb3fa8a..0481109 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -17,6 +17,7 @@ import ( "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" "github.com/tracebloc/cli/internal/geo" + "github.com/tracebloc/cli/internal/nodeboot" "github.com/tracebloc/cli/internal/ui" ) @@ -54,6 +55,13 @@ func withClientBackend(t *testing.T, h http.HandlerFunc) { return nil, nil } t.Cleanup(func() { readInClusterClient = origLive }) + + // Stub the delete-side nodeboot shell-outs (k3d/helm) to no-ops so tests + // never spawn real processes. Tests asserting teardown override these. + otc, ouc := teardownCluster, uninstallChart + teardownCluster = func(context.Context, string) error { return nil } + uninstallChart = func(context.Context, string) error { return nil } + t.Cleanup(func() { teardownCluster, uninstallChart = otc, ouc }) } // stubClusterID overrides the cluster-anchor read for a single test. @@ -259,19 +267,47 @@ func TestClientList(t *testing.T) { } } -func TestClientUse(t *testing.T) { - withClientBackend(t, func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`[{"id":7,"first_name":"gamma","namespace":"gamma"}]`)) +func TestClientDelete(t *testing.T) { + deleted := 0 + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && r.URL.Path == "/edge-device/7/" { + deleted++ + w.WriteHeader(http.StatusNoContent) + return + } + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) }) - if err := runClientUse(context.Background(), ui.New(&bytes.Buffer{}), "7"); err != nil { - t.Fatal(err) - } cfg, _ := config.Load() - if cfg.Current().ActiveClientID != "7" { - t.Errorf("active = %q, want 7", cfg.Current().ActiveClientID) + setActiveClient(cfg.Current(), &api.ProvisionedClient{ID: 7, Name: "box", Namespace: "ns7"}) + _ = cfg.Save() + + var uninstalledNS, torndown string + uninstallChart = func(_ context.Context, ns string) error { uninstalledNS = ns; return nil } + teardownCluster = func(_ context.Context, name string) error { torndown = name; return nil } + + var out bytes.Buffer + if err := runClientDelete(context.Background(), ui.New(&out), nil, true); err != nil { + t.Fatalf("delete: %v", err) + } + if deleted != 1 { + t.Errorf("expected 1 backend DELETE, got %d", deleted) + } + if uninstalledNS != "ns7" || torndown != nodeboot.ClusterName { + t.Errorf("teardown wrong: uninstalled=%q cluster=%q", uninstalledNS, torndown) + } + cfg, _ = config.Load() + if cfg.Current().ActiveClientID != "" { + t.Errorf("active pointer not cleared: %q", cfg.Current().ActiveClientID) } - if err := runClientUse(context.Background(), ui.New(&bytes.Buffer{}), "99"); err == nil { - t.Error("expected an error for an unknown client id") +} + +func TestClientDelete_NoActiveClient(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("no backend call expected, got %s %s", r.Method, r.URL.Path) + }) + err := runClientDelete(context.Background(), ui.New(&bytes.Buffer{}), nil, true) + if err == nil || !strings.Contains(err.Error(), "no active client") { + t.Errorf("want no-active-client error, got %v", err) } } diff --git a/internal/nodeboot/nodeboot.go b/internal/nodeboot/nodeboot.go new file mode 100644 index 0000000..85113a5 --- /dev/null +++ b/internal/nodeboot/nodeboot.go @@ -0,0 +1,78 @@ +// Package nodeboot orchestrates the node-level bootstrap that `client create` +// now owns (prototype): standing up a local k3d cluster and installing the +// tracebloc/client Helm chart, plus the inverse teardown for `client delete`. +// +// PROTOTYPE SCOPE. This shells out to the same proven tools the bash installer +// uses (k3d, helm) rather than reimplementing cluster bootstrap natively, and +// covers the k3d-on-Linux path only. It deliberately does NOT reproduce the +// installer's GPU detection, proxy plumbing, values generation, or reboot +// policy (scripts/lib/* in tracebloc/client) — those remain the installer's job +// until this direction is formalized (reverses RFC-0001 §6.4's installer/CLI +// split; tracked as a prototype, not the design of record). +package nodeboot + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// ClusterName mirrors the bash installer's default k3d cluster name +// (scripts/lib/common.sh) so the CLI-driven teardown targets the same cluster. +const ClusterName = "tracebloc" + +// Runner executes an external command and returns its combined output. A +// package var so tests can substitute a fake without spawning real processes. +var Runner = func(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + return string(out), err +} + +// run is the internal helper: runs a command, wrapping a failure with the tool +// name + its output so the caller surfaces an actionable error. +func run(ctx context.Context, name string, args ...string) (string, error) { + out, err := Runner(ctx, name, args...) + if err != nil { + return out, fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(out)) + } + return out, nil +} + +// ClusterExists reports whether a k3d cluster named `name` is already present. +func ClusterExists(ctx context.Context, name string) (bool, error) { + out, err := run(ctx, "k3d", "cluster", "list", "--no-headers") + if err != nil { + return false, err + } + for _, line := range strings.Split(out, "\n") { + if f := strings.Fields(line); len(f) > 0 && f[0] == name { + return true, nil + } + } + return false, nil +} + +// TeardownCluster deletes the k3d cluster. A missing cluster is not an error +// (the delete is idempotent — the end state is "gone" either way). +func TeardownCluster(ctx context.Context, name string) error { + exists, err := ClusterExists(ctx, name) + if err != nil { + return err + } + if !exists { + return nil + } + _, err = run(ctx, "k3d", "cluster", "delete", name) + return err +} + +// UninstallChart removes the client's Helm release. A missing release is not an +// error (idempotent teardown). +func UninstallChart(ctx context.Context, namespace string) error { + _, err := run(ctx, "helm", "uninstall", namespace, "--namespace", namespace) + if err != nil && strings.Contains(err.Error(), "not found") { + return nil + } + return err +} diff --git a/internal/nodeboot/nodeboot_test.go b/internal/nodeboot/nodeboot_test.go new file mode 100644 index 0000000..072b6b4 --- /dev/null +++ b/internal/nodeboot/nodeboot_test.go @@ -0,0 +1,91 @@ +package nodeboot + +import ( + "context" + "errors" + "strings" + "testing" +) + +// fakeRunner records invocations and returns canned output per command prefix. +type fakeRunner struct { + calls []string + outputs map[string]string // key: joined-command prefix → output + err error +} + +func (f *fakeRunner) run(_ context.Context, name string, args ...string) (string, error) { + cmd := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, cmd) + for prefix, out := range f.outputs { + if strings.HasPrefix(cmd, prefix) { + return out, f.err + } + } + return "", f.err +} + +func withFakeRunner(t *testing.T, f *fakeRunner) { + t.Helper() + orig := Runner + Runner = f.run + t.Cleanup(func() { Runner = orig }) +} + +func TestClusterExists(t *testing.T) { + f := &fakeRunner{outputs: map[string]string{"k3d cluster list": "tracebloc 1/1\nother 1/1"}} + withFakeRunner(t, f) + + got, err := ClusterExists(context.Background(), "tracebloc") + if err != nil || !got { + t.Errorf("ClusterExists(tracebloc) = %v, %v; want true, nil", got, err) + } + got, err = ClusterExists(context.Background(), "absent") + if err != nil || got { + t.Errorf("ClusterExists(absent) = %v, %v; want false, nil", got, err) + } +} + +func TestTeardownCluster_DeletesWhenPresent(t *testing.T) { + f := &fakeRunner{outputs: map[string]string{"k3d cluster list": "tracebloc 1/1"}} + withFakeRunner(t, f) + + if err := TeardownCluster(context.Background(), "tracebloc"); err != nil { + t.Fatal(err) + } + if !contains(f.calls, "k3d cluster delete tracebloc") { + t.Errorf("expected a delete call, got %v", f.calls) + } +} + +func TestTeardownCluster_NoopWhenAbsent(t *testing.T) { + f := &fakeRunner{outputs: map[string]string{"k3d cluster list": "othercluster 1/1"}} + withFakeRunner(t, f) + + if err := TeardownCluster(context.Background(), "tracebloc"); err != nil { + t.Fatal(err) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "k3d cluster delete") { + t.Errorf("must not delete an absent cluster, got %v", f.calls) + } + } +} + +func TestUninstallChart_MissingReleaseIsNotAnError(t *testing.T) { + f := &fakeRunner{err: errors.New(`Error: uninstall: Release not loaded: ns1: release: not found`)} + withFakeRunner(t, f) + + if err := UninstallChart(context.Background(), "ns1"); err != nil { + t.Errorf("a not-found release should be a no-op, got %v", err) + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +}