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
24 changes: 7 additions & 17 deletions internal/cli/home.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -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) ──

Expand Down
118 changes: 118 additions & 0 deletions internal/cli/home_local_fallback.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 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
}
return strings.Contains(strings.ToLower(string(b)), strings.ToLower(filepath.Clean(exe)))
}
Comment thread
cursor[bot] marked this conversation as resolved.
158 changes: 158 additions & 0 deletions internal/cli/home_local_fallback_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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")
}
// 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")
}
}
Loading