From b1c1eb3ffa1d47b5588b4e7f4ac9ff79727baf88 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Fri, 24 Jul 2026 16:59:35 +0200 Subject: [PATCH 1/2] fix(home): adopt a LOCAL cluster's release when the client pointer is missing (#401) Home's env verdict hung entirely on ActiveClientNamespace -- written only by 'client create', which the Windows installer never runs -- so a healthy installed environment read as 'No secure environment on this machine yet' while doctor said Ready (field case). The ownership gate stays intact: the fallback adopts a discovered release ONLY when the kubeconfig server is local (loopback / k3d wildcard / host.docker.internal) -- a cluster that IS this machine, so no shared-cluster stranger can be greeted (section 7.5 preserved); namespace-only discovery, no cluster scan; every error degrades to the honest no-release. Also: tb alias detection accepts the Windows tb.cmd shim (install.ps1 cannot symlink without admin), so remedies echo 'tb' on Windows too. Both moved to home_local_fallback.go (home.go file budget). Co-Authored-By: Claude Opus 4.8 --- internal/cli/home.go | 24 ++-- internal/cli/home_local_fallback.go | 115 ++++++++++++++++++ internal/cli/home_local_fallback_test.go | 141 +++++++++++++++++++++++ 3 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 internal/cli/home_local_fallback.go create mode 100644 internal/cli/home_local_fallback_test.go diff --git a/internal/cli/home.go b/internal/cli/home.go index d4be98c2..757a1a1f 100644 --- a/internal/cli/home.go +++ b/internal/cli/home.go @@ -489,7 +489,11 @@ func realProbeEnv(ctx context.Context) envProbe { // the remembered-name fallback) — and skip the cluster I/O entirely, which // also keeps the common unprovisioned re-entry instant. if !binding.applied { - return envProbe{local: localNoRelease} + // #401: an empty pointer isn't proof of "no environment" — the Windows + // installer never writes it. localEnvFallback adopts a release only on + // a LOCAL (loopback/k3d) cluster, so the shared-cluster guarantee above + // is preserved; everything else still reads as no-release. + return localEnvFallback(ctx) } resolved, err := loadClusterFn(opts) if err != nil { @@ -645,22 +649,8 @@ func sanitizeInvoked(argv0 string) string { return binTracebloc } -// tbAliasAvailable reports whether a real tracebloc-owned `tb` alias sits next to -// this binary (the installer symlinks it there, cli#142). Reuses delete.go's -// aliasStatus so "is it ours" is judged exactly as offboarding judges it — we -// only advertise `tb` when it genuinely points at this CLI. -func tbAliasAvailable() bool { - exe, err := osExecutable() - if err != nil { - return false - } - tb := filepath.Join(filepath.Dir(exe), binTB) - if tb == exe { - return false - } - _, ours := aliasStatus(tb, exe) - return ours -} +// tbAliasAvailable moved to home_local_fallback.go (#401): it now also accepts +// the Windows tb.cmd shim, which the symlink-only test could never match. // ── Rendering (pure) ── diff --git a/internal/cli/home_local_fallback.go b/internal/cli/home_local_fallback.go new file mode 100644 index 00000000..bd84ec77 --- /dev/null +++ b/internal/cli/home_local_fallback.go @@ -0,0 +1,115 @@ +package cli + +// Home-screen fallbacks for machines the provisioning pointer never reached +// (#401). Split from home.go to respect its file budget. + +import ( + "context" + "net" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/tracebloc/cli/internal/cluster" +) + +// localEnvFallback answers "is there a secure environment on THIS machine?" +// when the active-client pointer is empty — the state every pre-#388 Windows +// install is in permanently, because only `client create` writes the pointer +// and the Windows installer never ran it. Field case: `doctor` said "Ready to +// run training" while home said "No secure environment on this machine yet". +// +// The ownership gate in realProbeEnv exists so a status screen never greets a +// SHARED cluster's unrelated client as yours (§7.5). This fallback keeps that +// guarantee by adopting a discovered release only when the kubeconfig's server +// is LOCAL (loopback / host.docker.internal / a k3d wildcard bind) — a cluster +// that is this machine by definition, so whatever tracebloc release runs there +// is this machine's environment. Remote/shared clusters return the same honest +// no-release the gate always produced, and every error degrades to no-release +// (never "offline" — an unprovisioned machine with no reachable local cluster +// most likely has no environment at all). +func localEnvFallback(ctx context.Context) envProbe { + resolved, err := loadClusterFn(cluster.KubeconfigOptions{}) + if err != nil { + return envProbe{local: localNoRelease} + } + if !isLocalServerURL(resolved.ServerURL) { + return envProbe{local: localNoRelease} + } + resolved.RestConfig.Timeout = homeProbeTimeout + cs, err := newClientsetFn(resolved) + if err != nil { + return envProbe{local: localNoRelease} + } + // Namespace-only discovery — never the cluster-wide scan, mirroring the + // gate's no-silent-retarget rule even on a local cluster. + release, nsUsed, err := discoverRelease(ctx, nil, cs, resolved.Namespace, false) + if err != nil { + return envProbe{local: localNoRelease} + } + ep := envProbe{name: release.ReleaseName} + if jobsManagerReady(ctx, cs, nsUsed, release) { + ep.local = localLive + } else { + ep.local = localDegraded + } + if ep.local == localLive { + if c, ok := machineCapacity(ctx, cs); ok { + ep.compute, ep.hasCompute = c, true + } + } + return ep +} + +// isLocalServerURL reports whether a kubeconfig server URL points at THIS +// machine. Covers loopback names/addresses, the wildcard binds k3d writes when +// no host is pinned, and Docker Desktop's host alias (the same signals +// doctor's reachability remedy keys on). +func isLocalServerURL(serverURL string) bool { + u, err := url.Parse(serverURL) + if err != nil { + return false + } + host := u.Hostname() + switch strings.ToLower(host) { + case "localhost", "host.docker.internal", "0.0.0.0", "::": + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() || ip.IsUnspecified() + } + return false +} + +// tbAliasAvailable reports whether a real tracebloc-owned `tb` alias sits next +// to this binary, so examples/remedies can echo the short name. On unix the +// installer symlinks `tb` → tracebloc and delete.go's aliasStatus judges +// ownership exactly as offboarding does. On Windows symlinks need admin, so +// install.ps1 writes a `tb.cmd` shim instead — a regular file the symlink test +// can never accept (#401): ours = the shim invokes this binary. +func tbAliasAvailable() bool { + exe, err := osExecutable() + if err != nil { + return false + } + dir := filepath.Dir(exe) + tb := filepath.Join(dir, binTB) + if tb != exe { + if _, ours := aliasStatus(tb, exe); ours { + return true + } + } + return tbCmdAliasOurs(dir, exe) +} + +// tbCmdAliasOurs reports whether a tb.cmd shim in dir invokes exe (matched on +// the binary's basename, case-insensitively — .cmd is a Windows artifact). +func tbCmdAliasOurs(dir, exe string) bool { + b, err := os.ReadFile(filepath.Join(dir, binTB+".cmd")) + if err != nil { + return false + } + base := strings.TrimSuffix(filepath.Base(exe), ".exe") + return strings.Contains(strings.ToLower(string(b)), strings.ToLower(base)) +} diff --git a/internal/cli/home_local_fallback_test.go b/internal/cli/home_local_fallback_test.go new file mode 100644 index 00000000..bf3fa51c --- /dev/null +++ b/internal/cli/home_local_fallback_test.go @@ -0,0 +1,141 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" + + "github.com/tracebloc/cli/internal/cluster" +) + +// fallbackRelease seeds the objects DiscoverParentRelease keys on (mirrors +// internal/cluster's discovery fixtures) plus a Ready jobs-manager so the +// probe classifies localLive. +func fallbackRelease(ns string) []interface{} { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tracebloc-jobs-manager", + Namespace: ns, + Labels: map[string]string{ + "app.kubernetes.io/name": "client", + "app.kubernetes.io/instance": "tracebloc", + "app.kubernetes.io/managed-by": "Helm", + "app.kubernetes.io/version": "1.9.5", + "helm.sh/chart": "client-1.9.5", + }, + }, + Status: appsv1.DeploymentStatus{ReadyReplicas: 1, Replicas: 1}, + } + svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "jobs-manager", Namespace: ns}} + return []interface{}{dep, svc} +} + +func stubFallbackSeams(t *testing.T, serverURL string, cs kubernetes.Interface, loadErr error) { + t.Helper() + origLoad, origCS := loadClusterFn, newClientsetFn + t.Cleanup(func() { loadClusterFn, newClientsetFn = origLoad, origCS }) + loadClusterFn = func(cluster.KubeconfigOptions) (*cluster.ResolvedConfig, error) { + if loadErr != nil { + return nil, loadErr + } + return &cluster.ResolvedConfig{ + Namespace: "tracebloc", + ServerURL: serverURL, + RestConfig: &rest.Config{Host: serverURL}, + }, nil + } + newClientsetFn = func(*cluster.ResolvedConfig) (kubernetes.Interface, error) { + if cs == nil { + return nil, errors.New("no clientset expected on this path") + } + return cs, nil + } +} + +// #401: an empty active-client pointer must not read as "no environment" when +// a tracebloc release runs on a LOCAL cluster (the pre-#388 Windows install +// state: doctor said Ready, home said run-the-installer). +func TestLocalEnvFallback_AdoptsLocalRelease(t *testing.T) { + o := fallbackRelease("tracebloc") + cs := fake.NewClientset(o[0].(*appsv1.Deployment), o[1].(*corev1.Service)) + stubFallbackSeams(t, "https://127.0.0.1:6550", cs, nil) + ep := localEnvFallback(context.Background()) + if ep.local != localLive || ep.name != "tracebloc" { + t.Fatalf("=> %+v, want localLive named tracebloc", ep) + } +} + +// The §7.5 ownership guarantee survives: a REMOTE cluster in the kubeconfig is +// never adopted without the pointer — same honest no-release as before, and +// the clientset is never even dialed. +func TestLocalEnvFallback_RemoteClusterStaysGated(t *testing.T) { + stubFallbackSeams(t, "https://k8s.corp.example:6443", nil, nil) + if ep := localEnvFallback(context.Background()); ep.local != localNoRelease { + t.Fatalf("=> %+v, want localNoRelease (gate holds for remote clusters)", ep) + } +} + +func TestLocalEnvFallback_NoKubeconfigIsNoRelease(t *testing.T) { + stubFallbackSeams(t, "", nil, errors.New("no kubeconfig")) + if ep := localEnvFallback(context.Background()); ep.local != localNoRelease { + t.Fatalf("=> %+v, want localNoRelease", ep) + } +} + +func TestIsLocalServerURL(t *testing.T) { + local := []string{ + "https://127.0.0.1:6550", + "https://localhost:6443", + "https://[::1]:6443", + "https://0.0.0.0:6443", // k3d wildcard bind + "https://host.docker.internal:6550", + } + remote := []string{ + "https://10.2.3.4:6443", + "https://k8s.corp.example:443", + "https://192.168.1.20:6443", + "", "not a url", + } + for _, u := range local { + if !isLocalServerURL(u) { + t.Errorf("isLocalServerURL(%q) = false, want true", u) + } + } + for _, u := range remote { + if isLocalServerURL(u) { + t.Errorf("isLocalServerURL(%q) = true, want false", u) + } + } +} + +// #401: the Windows installer writes a tb.cmd shim (symlinks need admin); the +// alias check must accept it so remedies echo `tb` on Windows too. +func TestTbCmdAliasOurs(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "tracebloc.exe") + if tbCmdAliasOurs(dir, exe) { + t.Fatal("no shim present must be false") + } + shim := "@echo off\r\n\"" + exe + "\" %*\r\n" + if err := os.WriteFile(filepath.Join(dir, "tb.cmd"), []byte(shim), 0o755); err != nil { + t.Fatal(err) + } + if !tbCmdAliasOurs(dir, exe) { + t.Fatal("a shim invoking this binary must be ours") + } + if err := os.WriteFile(filepath.Join(dir, "tb.cmd"), []byte("@echo off\r\nsome-other-tool %*\r\n"), 0o755); err != nil { + t.Fatal(err) + } + if tbCmdAliasOurs(dir, exe) { + t.Fatal("a shim invoking a different tool is not ours") + } +} From f9351eedbef1c578825a42d7f0796ab1613a2252 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Fri, 24 Jul 2026 17:03:40 +0200 Subject: [PATCH 2/2] tb.cmd ownership: full-path match, not basename (Bugbot) A shim mentioning 'tracebloc' anywhere -- a comment, or an invocation of a DIFFERENT tracebloc install -- claimed ownership. Ours = the shim contains THIS exe's full path (case-insensitive; install.ps1 writes an absolute target, the same bar aliasStatus applies to symlinks). Tests for both false-claim shapes added. Co-Authored-By: Claude Opus 4.8 --- internal/cli/home_local_fallback.go | 11 +++++++---- internal/cli/home_local_fallback_test.go | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/cli/home_local_fallback.go b/internal/cli/home_local_fallback.go index bd84ec77..405660fc 100644 --- a/internal/cli/home_local_fallback.go +++ b/internal/cli/home_local_fallback.go @@ -103,13 +103,16 @@ func tbAliasAvailable() bool { return tbCmdAliasOurs(dir, exe) } -// tbCmdAliasOurs reports whether a tb.cmd shim in dir invokes exe (matched on -// the binary's basename, case-insensitively — .cmd is a Windows artifact). +// tbCmdAliasOurs reports whether a tb.cmd shim in dir invokes THIS binary by +// its full path (install.ps1 writes an absolute target — the same ownership +// bar aliasStatus applies to symlinks). Matching just the basename would claim +// any third-party shim that merely mentions "tracebloc", or one invoking a +// different tracebloc at another path (Bugbot). Case-insensitive: .cmd is a +// Windows artifact and NTFS paths are case-insensitive. func tbCmdAliasOurs(dir, exe string) bool { b, err := os.ReadFile(filepath.Join(dir, binTB+".cmd")) if err != nil { return false } - base := strings.TrimSuffix(filepath.Base(exe), ".exe") - return strings.Contains(strings.ToLower(string(b)), strings.ToLower(base)) + return strings.Contains(strings.ToLower(string(b)), strings.ToLower(filepath.Clean(exe))) } diff --git a/internal/cli/home_local_fallback_test.go b/internal/cli/home_local_fallback_test.go index bf3fa51c..b8e38949 100644 --- a/internal/cli/home_local_fallback_test.go +++ b/internal/cli/home_local_fallback_test.go @@ -138,4 +138,21 @@ func TestTbCmdAliasOurs(t *testing.T) { if tbCmdAliasOurs(dir, exe) { t.Fatal("a shim invoking a different tool is not ours") } + // Bugbot: mentioning the name is not ownership — neither a comment that + // says "tracebloc" nor an invocation of a DIFFERENT tracebloc install. + if err := os.WriteFile(filepath.Join(dir, "tb.cmd"), + []byte("@echo off\r\nrem tracebloc helper\r\nsome-other-tool %*\r\n"), 0o755); err != nil { + t.Fatal(err) + } + if tbCmdAliasOurs(dir, exe) { + t.Fatal("a shim merely mentioning tracebloc is not ours") + } + other := filepath.Join(dir, "elsewhere", "tracebloc.exe") + if err := os.WriteFile(filepath.Join(dir, "tb.cmd"), + []byte("@echo off\r\n\""+other+"\" %*\r\n"), 0o755); err != nil { + t.Fatal(err) + } + if tbCmdAliasOurs(dir, exe) { + t.Fatal("a shim invoking a different tracebloc install is not ours") + } }