diff --git a/internal/cli/delete.go b/internal/cli/delete.go index 246db4ac..8c2c8729 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -182,7 +182,7 @@ func runDelete(ctx context.Context, p *ui.Printer, pr prompter, o deleteOpts) er // 2. Uninstall the Helm release (best-effort — the credential is already // revoked; a leftover release is harmless and re-runnable). if ns != "" { - if uerr := uninstallChart(ctx, ns); uerr != nil { + if uerr := uninstallChart(ctx, ns, o.kubeconfigPath, o.contextOverride); uerr != nil { p.Warnf("Chart uninstall reported: %v", uerr) } else { p.Successf("Uninstalled the Helm release %s.", ns) @@ -213,7 +213,16 @@ func runDelete(ctx context.Context, p *ui.Printer, pr prompter, o deleteOpts) er // 5. Remove ~/.tracebloc (config + on-host datasets on a single-host install) // unless --keep-data. This is the one irreversible step on that path. if o.keepData { - p.Infof("Kept local data and config (~/.tracebloc) — --keep-data.") + // Spare ~/.tracebloc, but still clear the now-dangling active-client pointer: + // the credential is revoked, so leaving this host "enrolled" as the client + // would mislead a later sign-in / reinstall (§7.5). The token and on-host + // data stay; re-running `client create` re-adopts by cluster_id. + prof.ActiveClientID, prof.ActiveClientName, prof.ActiveClientNamespace = "", "", "" + if serr := cfg.Save(); serr != nil { + p.Warnf("Kept local data, but couldn't clear the active-client pointer (%v).", serr) + } else { + p.Infof("Kept local data and config (~/.tracebloc); cleared the active-client pointer — --keep-data.") + } } else { if derr := removeHostDataDir(); derr != nil { p.Warnf("Couldn't remove local data (%v) — remove it by hand: rm -rf %s", derr, hostDataDirDisplay()) diff --git a/internal/cli/delete_test.go b/internal/cli/delete_test.go index 8a563021..e1700dab 100644 --- a/internal/cli/delete_test.go +++ b/internal/cli/delete_test.go @@ -41,14 +41,19 @@ type fakeNodeboot struct { removeErr map[string]error // path → error to return from osRemoveAll executable string executableErr error + // Kubeconfig/context the uninstall seam was handed — so a test can prove the + // `tracebloc delete` --kubeconfig/--context flags actually reach helm. + uninstallKubeconfig string + uninstallContext string } func (f *fakeNodeboot) install(t *testing.T) { t.Helper() origU, origT, origP := uninstallChart, teardownCluster, pruneImages origExe, origRm := osExecutable, osRemoveAll - uninstallChart = func(_ context.Context, ns string) error { + uninstallChart = func(_ context.Context, ns, kubeconfig, kubeContext string) error { f.calls = append(f.calls, "uninstall:"+ns) + f.uninstallKubeconfig, f.uninstallContext = kubeconfig, kubeContext return f.uninstallErr } teardownCluster = func(_ context.Context, name string) error { @@ -222,6 +227,40 @@ func TestDelete_KeepData_SparesDataDir(t *testing.T) { if !strings.Contains(out.String(), "--keep-data") { t.Errorf("output should note --keep-data:\n%s", out.String()) } + // …but the now-dangling active-client pointer must be cleared even under + // --keep-data (the credential is revoked; a stale pointer would mislead a + // later sign-in / reinstall). + cfg, _ := config.Load() + if got := cfg.Current().ActiveClientID; got != "" { + t.Errorf("--keep-data should clear the active-client pointer, got %q", got) + } +} + +// --kubeconfig/--context must reach the helm uninstall — otherwise the release is +// uninstalled against the ambient current-context, which may be the wrong cluster. +func TestDelete_KubeconfigContext_ReachHelm(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): + w.WriteHeader(http.StatusOK) + } + }) + setActiveForDelete(t, "5", "gpu-box-01", "gpu-box-01") + fn := &fakeNodeboot{executable: filepath.Join(t.TempDir(), "tracebloc")} + fn.install(t) + + var out bytes.Buffer + err := runDelete(context.Background(), ui.New(&out), nil, + deleteOpts{yes: true, kubeconfigPath: "/tmp/kc.yaml", contextOverride: "k3d-tracebloc"}) + if err != nil { + t.Fatalf("offboard: %v", err) + } + if fn.uninstallKubeconfig != "/tmp/kc.yaml" || fn.uninstallContext != "k3d-tracebloc" { + t.Errorf("uninstall got kubeconfig=%q context=%q, want /tmp/kc.yaml + k3d-tracebloc", + fn.uninstallKubeconfig, fn.uninstallContext) + } } // (d) A running/online client → refuse unless --force. diff --git a/internal/nodeboot/nodeboot.go b/internal/nodeboot/nodeboot.go index a8551a14..8842dd2b 100644 --- a/internal/nodeboot/nodeboot.go +++ b/internal/nodeboot/nodeboot.go @@ -78,8 +78,20 @@ func TeardownCluster(ctx context.Context, name string) error { // UninstallChart removes the client's Helm release (release name = namespace, the // installer's convention). 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) +// +// kubeconfig and kubeContext target the release's cluster: helm otherwise acts on +// the ambient $KUBECONFIG + current-context, so an operator whose current context +// isn't the tracebloc cluster could uninstall the wrong release. Both are appended +// only when non-empty, preserving the default-context behavior. +func UninstallChart(ctx context.Context, namespace, kubeconfig, kubeContext string) error { + args := []string{"uninstall", namespace, "--namespace", namespace} + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if kubeContext != "" { + args = append(args, "--kube-context", kubeContext) + } + _, err := run(ctx, "helm", args...) // Match helm's release-not-found wording specifically ("... release: not // found"), not a bare "not found" — an unrelated failure whose output happens // to contain "not found" (e.g. "Kubernetes cluster unreachable: ... not diff --git a/internal/nodeboot/nodeboot_test.go b/internal/nodeboot/nodeboot_test.go index 119eb92f..4d007c8f 100644 --- a/internal/nodeboot/nodeboot_test.go +++ b/internal/nodeboot/nodeboot_test.go @@ -137,7 +137,7 @@ func TestUninstallChart(t *testing.T) { f := newFakeRunner() f.install(t) - if err := UninstallChart(context.Background(), "munich-radiology"); err != nil { + if err := UninstallChart(context.Background(), "munich-radiology", "", ""); err != nil { t.Fatalf("unexpected error: %v", err) } want := []string{"helm uninstall munich-radiology --namespace munich-radiology"} @@ -151,7 +151,7 @@ func TestUninstallChart(t *testing.T) { f.on("helm uninstall gone --namespace gone", "Error: uninstall: Release not loaded: gone: release: not found", errors.New("exit 1")) f.install(t) - if err := UninstallChart(context.Background(), "gone"); err != nil { + if err := UninstallChart(context.Background(), "gone", "", ""); err != nil { t.Fatalf("a missing release must be swallowed, got: %v", err) } }) @@ -161,7 +161,7 @@ func TestUninstallChart(t *testing.T) { f.on("helm uninstall ns --namespace ns", "Error: connection refused", errors.New("exit 1")) f.install(t) - if err := UninstallChart(context.Background(), "ns"); err == nil { + if err := UninstallChart(context.Background(), "ns", "", ""); err == nil { t.Fatal("want error for a non-not-found helm failure, got nil") } }) @@ -175,10 +175,23 @@ func TestUninstallChart(t *testing.T) { `Error: Kubernetes cluster unreachable: namespace "kube-system" not found`, errors.New("exit 1")) f.install(t) - if err := UninstallChart(context.Background(), "ns"); err == nil { + if err := UninstallChart(context.Background(), "ns", "", ""); err == nil { t.Fatal("a cluster-unreachable 'not found' must surface, got nil") } }) + + t.Run("kubeconfig + context are passed to helm", func(t *testing.T) { + f := newFakeRunner() + f.install(t) + + if err := UninstallChart(context.Background(), "ns", "/tmp/kc.yaml", "k3d-tracebloc"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"helm uninstall ns --namespace ns --kubeconfig /tmp/kc.yaml --kube-context k3d-tracebloc"} + if !reflect.DeepEqual(f.calls, want) { + t.Fatalf("calls = %v, want %v", f.calls, want) + } + }) } func TestPruneImages(t *testing.T) {