From 40937df581bb94600bbdb5c26198f3b62376a169 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Mon, 6 Jul 2026 21:54:29 +0200 Subject: [PATCH 1/4] feat(cli): auto-discover the client's namespace + first-touch surface rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the out-of-box dead end: on a standard install the client lives in its slug namespace, but data/cluster commands searched only the kubeconfig default ("default") and errored — telling customers to run helm install, the exact thing the CLI promises they never do. Namespace discovery (§7.3): - New cluster.FindClientNamespaces: cluster-wide scan for jobs-manager Deployments (same selector as single-namespace discovery). - discoverRelease fallback in the shared resolve seam: a miss in the DEFAULT namespace scans; exactly one client → visible-note retarget (never silent), several → named + pick via --namespace/client use, none/RBAC-denied → the original error stands. - §7.5 guard: the scan NEVER engages for an explicit --namespace/ --context or an active-client binding miss (which keeps the §7.3 "runs elsewhere" message) — pinned by activeClientBinding.allowScan() + table test. cluster info now binds the active client exactly like the data commands, so the pre-flight targets what data ingest will. Surface rewrite: - Root: tagline/Long/home screen rewritten around the real scope (two contexts: your account / this machine's client); login leads Get started; protocol jargon + internal issue links removed. - ingest/data-ingest collision: validate moved to `data validate`; top-level `ingest` is a hidden deprecated alias, and a bare path there redirects: did you mean `tracebloc data ingest `. - No-client error copy: --namespace / installer one-liner (https) / cluster doctor — never helm. Sentinel renamed to "no tracebloc client found"; doctor no longer tells you to run doctor. - Noun sweep: parent (client) release → client; vendors → other collaborators; PVC jargon out of Shorts/flag help; README command names updated. Verified live on a real cluster (client in a non-default namespace): data list / cluster info auto-find it; explicit -n default gets the actionable error. go build/vet/test green; new tests cover the scan (single/multi/none/explicit), the alias moves, and the allowScan contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/client.go | 2 +- internal/cli/cluster.go | 46 ++++++++---- internal/cli/clustertarget.go | 53 ++++++++++++- internal/cli/clustertarget_test.go | 116 +++++++++++++++++++++++++++++ internal/cli/data.go | 28 +++---- internal/cli/data_delete.go | 6 +- internal/cli/data_list.go | 8 +- internal/cli/data_test.go | 12 +++ internal/cli/doctor.go | 2 +- internal/cli/ingest.go | 39 +++++----- internal/cli/root.go | 59 +++++++-------- internal/cli/root_test.go | 2 +- internal/cluster/discover.go | 51 +++++++++++-- internal/cluster/discover_test.go | 48 +++++++++++- internal/doctor/doctor.go | 8 +- internal/push/orphan.go | 2 +- internal/push/walk.go | 3 +- 17 files changed, 380 insertions(+), 105 deletions(-) diff --git a/internal/cli/client.go b/internal/cli/client.go index 239052d..a8e39be 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -38,7 +38,7 @@ var readInClusterClient = cluster.DiscoverInClusterClient func newClientCmd() *cobra.Command { cmd := &cobra.Command{ Use: "client", - Short: "Provision and manage tracebloc clients (machines)", + 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`" + `).`, } diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 418fe1a..85cbe68 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "time" @@ -28,8 +29,8 @@ func newClusterCmd() *cobra.Command { Long: `Commands for inspecting the Kubernetes cluster the CLI is configured to talk to. -Use ` + "`cluster info`" + ` to verify which cluster, namespace, and parent -tracebloc release the next ` + "`data ingest`" + ` will target. Useful as a +Use ` + "`cluster info`" + ` to verify which cluster, namespace, and +client the next ` + "`data ingest`" + ` will target. Useful as a pre-flight before doing anything destructive (e.g. ingesting into the wrong cluster).`, } @@ -64,13 +65,13 @@ func newClusterInfoCmd() *cobra.Command { cmd := &cobra.Command{ Use: "info", - Short: "Show the cluster, namespace, parent release, and ingestor SA token state", - Long: `Discovers the tracebloc parent client release in the configured + Short: "Show the cluster, namespace, client install, and ingestor token state", + Long: `Discovers the tracebloc client installed in the configured cluster + namespace and prints: • Which kubeconfig context the CLI used • The namespace it resolved to - • The parent release name + chart version + appVersion + • The client's release name + chart version + appVersion • The jobs-manager Service the next data ingest would POST to • The ingestor ServiceAccount the post-install hook would auth as • The cluster's configured INGESTOR_IMAGE_DIGEST default @@ -84,7 +85,7 @@ token I expect" without leaking it to terminal scrollback. Exit codes: 0 cluster discovered + token mintable; CLI is ready - 4 cluster reachable but no tracebloc parent release found + 4 cluster reachable but no tracebloc client found 5 cluster reachable + release found but no usable SA token`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -103,10 +104,10 @@ Exit codes: cmd.Flags().StringVar(&contextOverride, "context", "", "name of the kubeconfig context to use (default: kubeconfig's current-context)") cmd.Flags().StringVarP(&nsOverride, "namespace", "n", "", - "namespace where the parent tracebloc/client release is installed (default: the context's namespace, or 'default')") + "namespace where your tracebloc client is installed (default: the context's namespace, or 'default')") cmd.Flags().StringVar(&ingestorSAName, "ingestor-sa", "", "override the ingestor ServiceAccount name (default: \"ingestor\", the chart default; "+ - "set this if you customized `ingestionAuthz.serviceAccountName` in the parent client chart)") + "set this if you customized `ingestionAuthz.serviceAccountName` in your client's install)") cmd.Flags().Int64Var(&tokenExpiry, "token-expiry-seconds", 600, "requested SA token expiration in seconds (default 600 = 10 min; ignored for static-secret fallback)") @@ -122,11 +123,17 @@ func runClusterInfo( ) error { p.Banner("tracebloc", "cluster diagnostics") - resolved, err := cluster.Load(cluster.KubeconfigOptions{ + // Bind the active client's namespace exactly like the data commands do, + // so this pre-flight targets what `data ingest` will actually target — + // and so the multi-client "set your active client" remediation works + // here too, not just on the data path. + opts := cluster.KubeconfigOptions{ Path: kubeconfigPath, Context: contextOverride, Namespace: nsOverride, - }) + } + binding := bindActiveClientNamespace(&opts) + resolved, err := cluster.Load(opts) if err != nil { // Kubeconfig errors are exit-code-3 territory (file/parse // problem, same conceptual class as `ingest validate`'s @@ -144,15 +151,22 @@ func runClusterInfo( p.Field("server", resolved.ServerURL) p.Field("namespace", resolved.Namespace) - // Discover the parent release. - release, err := cluster.DiscoverParentRelease(ctx, cs, resolved.Namespace) + // Discover the client's release — with the cluster-wide fallback scan + // when the namespace is just the kubeconfig default, so diagnostics find + // the client in its slug namespace instead of dead-ending on "default". + release, nsUsed, err := discoverRelease(ctx, p, cs, resolved.Namespace, binding.allowScan()) if err != nil { - // 4 = "cluster reachable, but no tracebloc release here." + // 4 = "cluster reachable, but no tracebloc client here." // Distinct from the kubeconfig error (3) so callers can - // branch: 3 means "fix your kubeconfig", 4 means "install - // the parent chart first". + // branch: 3 means "fix your kubeconfig", 4 means "no client + // installed on this cluster". A binding miss gets the §7.3 + // "runs elsewhere" explanation, same as the data commands. + if errors.Is(err, cluster.ErrNoParentRelease) { + return binding.explain(&exitError{code: 4, err: &noParentReleaseError{err}}) + } return &exitError{code: 4, err: err} } + resolved.Namespace = nsUsed // Apply the SA-name override here. Discovery doesn't read the // name from the cluster (see #7); customers with a non-default @@ -161,7 +175,7 @@ func runClusterInfo( release.IngestorSAName = ingestorSAOverride } - p.Section("Parent release") + p.Section("Client install") p.Field("name", release.ReleaseName) p.Field("chart version", release.ChartVersion) p.Field("app version", release.AppVersion) diff --git a/internal/cli/clustertarget.go b/internal/cli/clustertarget.go index e24dc5d..e4dee66 100644 --- a/internal/cli/clustertarget.go +++ b/internal/cli/clustertarget.go @@ -4,11 +4,13 @@ import ( "context" "errors" "fmt" + "strings" "k8s.io/client-go/kubernetes" "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" + "github.com/tracebloc/cli/internal/ui" ) // noParentReleaseError marks the exit-4 case where the reached cluster @@ -42,7 +44,7 @@ type clusterTarget struct { // `cluster doctor` is deliberately NOT a caller — it has a different exit // contract (2/3 escalation, with discovery reported as a check Result rather // than a hard error). -func resolveClusterTarget(ctx context.Context, opts cluster.KubeconfigOptions, needPVC bool) (*clusterTarget, error) { +func resolveClusterTarget(ctx context.Context, p *ui.Printer, opts cluster.KubeconfigOptions, b activeClientBinding, needPVC bool) (*clusterTarget, error) { resolved, err := cluster.Load(opts) if err != nil { return nil, &exitError{code: 3, err: fmt.Errorf("loading kubeconfig: %w", err)} @@ -51,7 +53,12 @@ func resolveClusterTarget(ctx context.Context, opts cluster.KubeconfigOptions, n if err != nil { return nil, &exitError{code: 3, err: err} } - release, err := cluster.DiscoverParentRelease(ctx, cs, resolved.Namespace) + // The cluster-wide fallback scan only engages when the target namespace is + // the kubeconfig's default — i.e. nobody chose it: not the user (explicit + // --namespace/--context) and not the active-client binding. A binding miss + // must NOT silently redirect to some other client (§7.5 — that could be a + // different machine's client); it keeps the §7.3 "runs elsewhere" message. + release, nsUsed, err := discoverRelease(ctx, p, cs, resolved.Namespace, b.allowScan()) if err != nil { // Only a genuine "namespace has no release" maps to the §7.3 // "runs elsewhere" rewrite; an API/RBAC list failure or an @@ -61,6 +68,10 @@ func resolveClusterTarget(ctx context.Context, opts cluster.KubeconfigOptions, n } return nil, &exitError{code: 4, err: err} } + // The scan may have retargeted discovery to the namespace that actually + // hosts the client; everything downstream (PVC discovery, dataset listing, + // prints) keys on Resolved.Namespace, so it must follow. + resolved.Namespace = nsUsed t := &clusterTarget{Resolved: resolved, Clientset: cs, Release: release} if needPVC { pvc, err := cluster.DiscoverSharedPVC(ctx, cs, resolved.Namespace) @@ -72,12 +83,41 @@ func resolveClusterTarget(ctx context.Context, opts cluster.KubeconfigOptions, n return t, nil } +// discoverRelease wraps DiscoverParentRelease with the cluster-wide fallback +// scan: when allowScan is set and the target namespace hosts no client, every +// namespace is scanned for one. Exactly one → target it, with a visible note +// (never a silent redirect); several → name them and ask the user to pick; +// none, or a scan failure (e.g. RBAC forbids the cluster-wide list) → the +// original discovery error stands. Returns the namespace actually used. +func discoverRelease(ctx context.Context, p *ui.Printer, cs kubernetes.Interface, namespace string, allowScan bool) (*cluster.ParentRelease, string, error) { + release, err := cluster.DiscoverParentRelease(ctx, cs, namespace) + if err == nil || !allowScan || !errors.Is(err, cluster.ErrNoParentRelease) { + return release, namespace, err + } + found, scanErr := cluster.FindClientNamespaces(ctx, cs) + if scanErr != nil || len(found) == 0 { + return nil, namespace, err + } + if len(found) > 1 { + return nil, namespace, fmt.Errorf( + "%w in namespace %q, but tracebloc clients are running in: %s. "+ + "Pass --namespace to pick one, or set your active client with `tracebloc client use`.", + cluster.ErrNoParentRelease, namespace, strings.Join(found, ", ")) + } + if p != nil { + p.Infof("No client in namespace %q — using the one in %q (override with --namespace).", namespace, found[0]) + } + release, err = cluster.DiscoverParentRelease(ctx, cs, found[0]) + return release, found[0], err +} + // activeClientBinding records that a data command defaulted its target // namespace to the active client's cached namespace (§7.3), so a subsequent // "no release here" failure can be explained as "the active client runs // elsewhere" rather than a bare discovery error. type activeClientBinding struct { applied bool + explicit bool // user pinned --namespace/--context themselves name string namespace string } @@ -89,7 +129,7 @@ type activeClientBinding struct { // backward compatible for anyone who hasn't run `client use`/`create`. func bindActiveClientNamespace(opts *cluster.KubeconfigOptions) activeClientBinding { if opts.Namespace != "" || opts.Context != "" { - return activeClientBinding{} // user was explicit — don't second-guess + return activeClientBinding{explicit: true} // user was explicit — don't second-guess } cfg, err := config.Load() if err != nil { @@ -103,6 +143,13 @@ func bindActiveClientNamespace(opts *cluster.KubeconfigOptions) activeClientBind return activeClientBinding{applied: true, name: p.ActiveClientName, namespace: p.ActiveClientNamespace} } +// allowScan reports whether the cluster-wide fallback scan may engage: only +// when the target namespace is the kubeconfig's default — i.e. nobody chose +// it. An explicit --namespace/--context is never second-guessed, and a +// binding miss must NOT silently retarget to some other client (§7.5 — it +// could be a different machine's); it keeps the §7.3 "runs elsewhere" message. +func (b activeClientBinding) allowScan() bool { return !b.applied && !b.explicit } + // explain rewrites a "no tracebloc release in namespace" failure (exit 4) into // §7.3's "client runs on another machine" guidance when the target namespace // came from the active-client binding: the cluster the kubeconfig reaches diff --git a/internal/cli/clustertarget_test.go b/internal/cli/clustertarget_test.go index d0deb0f..d62411d 100644 --- a/internal/cli/clustertarget_test.go +++ b/internal/cli/clustertarget_test.go @@ -1,13 +1,20 @@ package cli import ( + "bytes" + "context" "errors" "strings" "testing" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "github.com/tracebloc/cli/internal/api" "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" + "github.com/tracebloc/cli/internal/ui" ) func TestSetActiveClient_CachesNamespaceAndName(t *testing.T) { @@ -112,3 +119,112 @@ func TestActiveClientBinding_Explain(t *testing.T) { t.Error("unbound explain should pass the error through") } } + +// jmDep builds a chart-labeled jobs-manager Deployment in the given namespace, +// for the fallback-scan tests (mirrors the cluster package's fixture). +func jmDep(namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tracebloc-jobs-manager", + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "client", + "app.kubernetes.io/instance": "tracebloc", + "app.kubernetes.io/managed-by": "Helm", + "helm.sh/chart": "client-1.6.0", + }, + }, + } +} + +// The cluster-wide fallback scan (§7.3): a default-namespace miss must find +// the single client in its slug namespace and retarget — visibly, not +// silently — instead of dead-ending on "default". +func TestDiscoverRelease_ScanFindsSingleClientElsewhere(t *testing.T) { + cs := fake.NewSimpleClientset(jmDep("lukas-01")) + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + release, nsUsed, err := discoverRelease(context.Background(), p, cs, "default", true) + if err != nil { + t.Fatalf("expected scan to find the client, got: %v", err) + } + if nsUsed != "lukas-01" { + t.Errorf("nsUsed = %q, want lukas-01", nsUsed) + } + if release == nil || release.ReleaseName != "tracebloc" { + t.Errorf("release = %+v", release) + } + // never a silent redirect + if !strings.Contains(buf.String(), "lukas-01") { + t.Errorf("expected a visible note about the redirect, got: %q", buf.String()) + } +} + +func TestDiscoverRelease_ScanMultipleNamespacesRefuses(t *testing.T) { + cs := fake.NewSimpleClientset(jmDep("alpha"), jmDep("beta")) + _, _, err := discoverRelease(context.Background(), nil, cs, "default", true) + if err == nil { + t.Fatal("expected an error for multiple client namespaces") + } + for _, want := range []string{"alpha", "beta", "--namespace", "client use"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %q, got: %s", want, err) + } + } + if !errors.Is(err, cluster.ErrNoParentRelease) { + t.Errorf("multi-namespace refusal should stay errors.Is-identifiable, got: %v", err) + } +} + +func TestDiscoverRelease_NoScanWhenExplicit(t *testing.T) { + // The client exists in lukas-01, but the caller pinned the namespace — + // the scan must NOT engage and the plain discovery error stands. + cs := fake.NewSimpleClientset(jmDep("lukas-01")) + _, nsUsed, err := discoverRelease(context.Background(), nil, cs, "default", false) + if err == nil { + t.Fatal("expected the namespace miss to stand when scan is disallowed") + } + if nsUsed != "default" { + t.Errorf("nsUsed = %q, want default (no retarget)", nsUsed) + } + if !errors.Is(err, cluster.ErrNoParentRelease) { + t.Errorf("expected ErrNoParentRelease, got: %v", err) + } +} + +func TestDiscoverRelease_ScanFindsNothingKeepsOriginalError(t *testing.T) { + cs := fake.NewSimpleClientset() // empty cluster + _, _, err := discoverRelease(context.Background(), nil, cs, "default", true) + if err == nil { + t.Fatal("expected an error on an empty cluster") + } + if !errors.Is(err, cluster.ErrNoParentRelease) { + t.Errorf("expected ErrNoParentRelease, got: %v", err) + } + // The no-client error must stay customer-actionable without Helm. + if strings.Contains(err.Error(), "helm") { + t.Errorf("error must not tell customers to run helm: %s", err) + } +} + +// The §7.5 contract at the caller level: the scan may engage ONLY when nobody +// chose the namespace — never for an explicit flag, never for a binding miss +// (which could silently retarget a different machine's client). +func TestActiveClientBinding_AllowScan(t *testing.T) { + cases := []struct { + name string + b activeClientBinding + want bool + }{ + {"kubeconfig default (nobody chose)", activeClientBinding{}, true}, + {"explicit --namespace/--context", activeClientBinding{explicit: true}, false}, + {"active-client binding applied", activeClientBinding{applied: true}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.b.allowScan(); got != c.want { + t.Errorf("allowScan() = %v, want %v", got, c.want) + } + }) + } +} diff --git a/internal/cli/data.go b/internal/cli/data.go index e7e7ec2..076fa2f 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -34,13 +34,14 @@ func newDataCmd() *cobra.Command { cmd := &cobra.Command{ Use: "data", Aliases: []string{"dataset"}, - Short: "Manage datasets in the parent client release", - Long: `Commands for staging and managing datasets on the cluster's -shared PVC. + Short: "Manage the datasets in your client", + Long: `Commands for staging and managing the datasets your client holds — +the data models train on. It stays on your infrastructure. -` + "`data ingest`" + ` stages a local dataset to the cluster's shared -PVC, submits the ingestion run to jobs-manager, and watches the -ingestor Job to completion (streaming logs + the final summary). +` + "`data ingest`" + ` stages a local dataset into your client's storage, +submits the ingestion run, and watches it to completion (streaming +logs + the final summary). ` + "`data validate`" + ` checks an ingest.yaml +locally first. ` + "`tracebloc cluster info`" + ` is the pre-flight you'd typically run before the first ingest.`, @@ -48,6 +49,7 @@ before the first ingest.`, cmd.AddCommand(newDataIngestCmd()) cmd.AddCommand(newDataListCmd()) cmd.AddCommand(newDataDeleteCmd()) + cmd.AddCommand(newIngestValidateCmd()) return cmd } @@ -126,8 +128,8 @@ func newDataIngestCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ingest ", Aliases: []string{"push"}, - Short: "Stage a local dataset to the cluster's shared PVC", - Long: `Stages a local dataset to the parent client release's shared PVC, + Short: "Stage a local dataset into your client's storage", + Long: `Stages a local dataset into your client's shared storage, submits an ingestion run to jobs-manager, and watches the ingestor Job to completion. Supports 9 task categories (image classification, object/keypoint detection, text classification, masked language @@ -153,7 +155,7 @@ Exit codes: 2 schema validation failed (synthesized spec rejected) or v0.1-unsupported category passed 3 local-layout or kubeconfig error - 4 cluster reachable but parent release / shared PVC missing + 4 cluster reachable but no tracebloc client / shared storage missing 5 ingestor SA token couldn't be obtained, or jobs-manager rejected the token (401/403) 7 pre-flight succeeded but staging the files failed @@ -220,7 +222,7 @@ Exit codes: cmd.Flags().StringVar(&contextOverride, "context", "", "name of the kubeconfig context to use (default: kubeconfig's current-context)") cmd.Flags().StringVarP(&nsOverride, "namespace", "n", "", - "namespace where the parent tracebloc/client release is installed") + "namespace where your tracebloc client is installed") // Required spec flags. We DON'T mark them required-at-cobra-level // because cobra's "required flag" error message is terse and @@ -257,7 +259,7 @@ Exit codes: "emit a machine-readable JSON result on stdout (human output → stderr; implies --no-input)") cmd.Flags().StringVar(&ingestorSAName, "ingestor-sa", "", "override the ingestor ServiceAccount name (default: \"ingestor\"); "+ - "set this if you customized ingestionAuthz.serviceAccountName in the parent client chart") + "set this if you customized ingestionAuthz.serviceAccountName in your client's install") cmd.Flags().StringVar(&stagePodImage, "stage-pod-image", "", "override the ephemeral stage Pod's image (default: digest-pinned alpine 3.20 baked into the CLI). "+ "Pin by digest in your override too — tag-only refs drift silently.") @@ -376,7 +378,7 @@ This uploads a dataset from your machine into your tracebloc workspace so models can be trained on it. Your files are sent to the Kubernetes cluster your workspace was installed on — tracebloc checks them and loads them into a table your training runs read from. Your data stays on that cluster the whole time; -contributors train against it without ever seeing the raw files.`)) +other collaborators train against it without ever seeing the raw files.`)) a.Printer.Hintf("Learn more: https://docs.tracebloc.io") // 0. Guided mode: prompt for any missing core inputs before @@ -595,7 +597,7 @@ contributors train against it without ever seeing the raw files.`)) // Bound before we waste time provisioning a Pod that can't mount it. opts := cluster.KubeconfigOptions{Path: a.Kubeconfig, Context: a.Context, Namespace: a.Namespace} binding := bindActiveClientNamespace(&opts) - target, err := resolveClusterTarget(ctx, opts, true) + target, err := resolveClusterTarget(ctx, a.Printer, opts, binding, true) if err != nil { return binding.explain(err) } diff --git a/internal/cli/data_delete.go b/internal/cli/data_delete.go index a0d94ac..e06831a 100644 --- a/internal/cli/data_delete.go +++ b/internal/cli/data_delete.go @@ -57,7 +57,7 @@ Exit codes: 0 artifacts removed (or --dry-run, or the user declined) 2 invalid table name 3 kubeconfig error, or refused (no confirmation off a terminal) - 4 cluster reachable but parent release / shared PVC missing + 4 cluster reachable but no tracebloc client / shared storage missing 7 teardown failed mid-flight (table drop or PVC rm errored)`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -84,7 +84,7 @@ Exit codes: cmd.Flags().StringVar(&contextOverride, "context", "", "name of the kubeconfig context to use (default: kubeconfig's current-context)") cmd.Flags().StringVarP(&nsOverride, "namespace", "n", "", - "namespace where the parent tracebloc/client release is installed") + "namespace where your tracebloc client is installed") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting anything") cmd.Flags().BoolVarP(&yes, "yes", "y", false, @@ -116,7 +116,7 @@ undone — re-ingesting the data is the only way back.`) // running teardown against a cluster with no tracebloc install. opts := cluster.KubeconfigOptions{Path: a.Kubeconfig, Context: a.Context, Namespace: a.Namespace} binding := bindActiveClientNamespace(&opts) - target, err := resolveClusterTarget(ctx, opts, true) + target, err := resolveClusterTarget(ctx, a.Printer, opts, binding, true) if err != nil { return binding.explain(err) } diff --git a/internal/cli/data_list.go b/internal/cli/data_list.go index 92c299c..f76f342 100644 --- a/internal/cli/data_list.go +++ b/internal/cli/data_list.go @@ -42,7 +42,7 @@ func newDataListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List datasets ingested in the cluster", - Long: `Lists the datasets ingested into the parent client release — + Long: `Lists the datasets ingested into your client — the tables in ` + push.IngestionDatabase + ` on the cluster. With no flags it uses your current kubeconfig context and its namespace; @@ -53,7 +53,7 @@ https://ai.tracebloc.io/metadata. Exit codes: 0 listed successfully (including an empty list) 3 kubeconfig error - 4 cluster reachable but no parent release in the namespace + 4 cluster reachable but no tracebloc client in the namespace 7 couldn't query the cluster for datasets`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -81,7 +81,7 @@ Exit codes: cmd.Flags().StringVar(&contextOverride, "context", "", "name of the kubeconfig context to use (default: kubeconfig's current-context)") cmd.Flags().StringVarP(&nsOverride, "namespace", "n", "", - "namespace where the parent tracebloc/client release is installed") + "namespace where your tracebloc client is installed") cmd.Flags().BoolVar(&outputJSON, "output-json", false, "emit the dataset list as JSON on stdout (human output → stderr)") @@ -113,7 +113,7 @@ func runDataList(ctx context.Context, a runDataListArgs) (err error) { opts := cluster.KubeconfigOptions{Path: a.Kubeconfig, Context: a.Context, Namespace: a.Namespace} binding := bindActiveClientNamespace(&opts) - target, err := resolveClusterTarget(ctx, opts, false) + target, err := resolveClusterTarget(ctx, p, opts, binding, false) if err != nil { return binding.explain(err) } diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 299849c..cd972f0 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -314,6 +314,18 @@ func TestAliasResolution(t *testing.T) { args: []string{"data", "delete", "--help"}, want: "Removes the in-cluster artifacts", }, + { + // `ingest validate` moved under data (top-level `ingest` is a + // hidden deprecated alias) — both paths must keep resolving. + name: "data validate canonical", + args: []string{"data", "validate", "--help"}, + want: "validates it against the bundled", + }, + { + name: "ingest validate alias resolves", + args: []string{"ingest", "validate", "--help"}, + want: "validates it against the bundled", + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9fae112..1bbce5a 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -66,7 +66,7 @@ Exit codes: cmd.Flags().StringVar(&contextOverride, "context", "", "name of the kubeconfig context to use (default: kubeconfig's current-context)") cmd.Flags().StringVarP(&nsOverride, "namespace", "n", "", - "namespace where the parent tracebloc/client release is installed (default: the context's namespace, or 'default')") + "namespace where your tracebloc client is installed (default: the context's namespace, or 'default')") return cmd } diff --git a/internal/cli/ingest.go b/internal/cli/ingest.go index 93b3662..174ec25 100644 --- a/internal/cli/ingest.go +++ b/internal/cli/ingest.go @@ -3,28 +3,35 @@ package cli import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/tracebloc/cli/internal/schema" ) -// newIngestCmd implements the `tracebloc ingest` subtree. Today it -// has only one verb — `validate` — which runs the schema check -// locally without touching the cluster. Future verbs (status, retry, -// cancel) hang off this same parent in later phases. +// newIngestCmd implements the `tracebloc ingest` subtree — kept as a HIDDEN +// alias for one deprecation cycle: its only verb, `validate`, moved to +// `tracebloc data validate`, so the top level no longer carries two different +// things named "ingest" (`ingest validate` vs `data ingest`). Scripts using +// the old path keep working; help and docs point at the new one. func newIngestCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "ingest", - Short: "Inspect and manage ingestion configurations", - Long: `Commands for working with ingest.yaml files and ingestion runs. - -Today only ` + "`validate`" + ` is implemented — it runs the same schema check -the cluster's jobs-manager runs, but locally and instantly. Use it to -catch typos and missing fields before submitting an ingestion. - -Future verbs (status, retry, cancel) will land alongside the -push/list/show commands in later phases.`, + Use: "ingest", + Short: "Deprecated alias for `tracebloc data validate`", + Hidden: true, + // A bare path here is almost always someone meaning `data ingest` + // (the home screen advertises it) — redirect instead of dumping a + // confusing deprecation usage block. + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf( + "`tracebloc ingest` doesn't stage datasets — did you mean:\n"+ + " tracebloc data ingest %s", + strings.Join(args, " ")) + } + return cmd.Help() + }, } cmd.AddCommand(newIngestValidateCmd()) @@ -49,9 +56,7 @@ ingest.v1.json schema (synced from tracebloc/data-ingestors). Prints violations in the same JSON-pointer-prefixed format the cluster's jobs-manager uses, and exits non-zero if any are found. -Useful as a pre-flight before ` + "`tracebloc data ingest`" + ` lands in a -future phase; for now, customers running the Helm chart can validate -their ` + "`ingest.yaml`" + ` before invoking ` + "`helm install`" + `, getting +Useful as a pre-flight before running ` + "`tracebloc data ingest`" + ` — millisecond local feedback instead of a multi-second cluster round trip. diff --git a/internal/cli/root.go b/internal/cli/root.go index 2b7868d..56ce963 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,32 +41,26 @@ func NewRootCmd(info BuildInfo) *cobra.Command { root := &cobra.Command{ Use: "tracebloc", - Short: "tracebloc — interactive data ingestion for your cluster", - Long: `tracebloc is the customer-facing CLI for the tracebloc declarative -ingestion path. It wraps the same POST /internal/submit-ingestion-run -protocol the tracebloc/ingestor Helm chart uses, so any cluster running -the parent tracebloc/client chart can be targeted directly from a -developer's workstation. - -The dominant workflow: - - tracebloc data ingest ./my-data \ - --table cats_dogs_train \ - --category image_classification \ - --intent train \ - --label-column label - -The CLI handles cluster discovery (via kubeconfig), staging the data -on the cluster's shared PVC, submitting the ingestion request, -watching the resulting Job, and reporting the outcome. Customers never -touch Helm, never edit YAML, never run kubectl cp manually. - -This binary implements the full v0.1 ingestion path: ` + "`data ingest`" + ` -(the dominant workflow above), ` + "`ingest validate`" + ` for a local -schema check, ` + "`cluster info`" + ` for discovery diagnostics, plus -` + "`version`" + ` and ` + "`completion`" + `. See -https://github.com/tracebloc/client/issues/147 for the v0.1 roadmap and -what's planned next.`, + Short: "tracebloc — connect this machine to tracebloc and manage its data", + Long: `The tracebloc CLI connects machines to tracebloc as clients and +manages the datasets that models train on. Your data stays on your +infrastructure — models from other collaborators come to it, once you +approve them. + +Two kinds of commands: + + Your account (sign in first): login, logout, auth, client + This machine's client: data, cluster + +A typical first session: + + tracebloc login # sign in or create your account (browser) + tracebloc data ingest ./my-data # stage a dataset into your client + tracebloc data list # see what's in the cluster + +The CLI finds your cluster through your kubeconfig, stages data onto +the cluster's shared storage, and reports progress as it goes. No +Helm, no YAML, no kubectl needed.`, // Silence cobra's auto-printed errors + usage on every error; // we already print structured errors in handlers, and the @@ -107,13 +101,14 @@ what's planned next.`, return cmd.Help() // an arg that wasn't a known subcommand } p := printerFor(cmd) - p.Banner("tracebloc", "interactive data ingestion for your cluster") + p.Banner("tracebloc", "connect this machine to tracebloc and manage its data") p.Section("Get started") - p.Infof("tracebloc data ingest — stage + ingest a dataset interactively (or use --help to see flags)") - p.Infof("tracebloc data list — list datasets ingested in the cluster") - p.Infof("tracebloc data delete — delete an ingested dataset (its table + files)") - p.Infof("tracebloc cluster info — check the CLI can reach your cluster") - p.Infof("tracebloc ingest validate f.yaml — validate an ingest.yaml locally") + p.Infof("tracebloc login — sign in to tracebloc (browser)") + p.Infof("tracebloc data ingest ./data — stage a dataset into your client") + p.Infof("tracebloc data list — datasets in the cluster") + p.Infof("tracebloc data delete
— delete an ingested dataset") + p.Infof("tracebloc client list — your clients and their status") + p.Infof("tracebloc cluster doctor — diagnose connection issues") p.Newline() p.Hintf("Add --help to any command for the full flag list.") return nil diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 218be56..64711ba 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -41,7 +41,7 @@ func TestRootCmd_HomeScreen(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("bare root failed: %v\n%s", err, out.String()) } - for _, want := range []string{"tracebloc", "data ingest", "data list", "data delete", "cluster info"} { + for _, want := range []string{"tracebloc", "login", "data ingest", "data list", "data delete", "cluster doctor"} { if !strings.Contains(out.String(), want) { t.Errorf("home screen missing %q:\n%s", want, out.String()) } diff --git a/internal/cluster/discover.go b/internal/cluster/discover.go index e46d55f..3f6ad69 100644 --- a/internal/cluster/discover.go +++ b/internal/cluster/discover.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "strings" appsv1 "k8s.io/api/apps/v1" @@ -15,7 +16,7 @@ import ( // has no tracebloc release" case — distinct from an API/RBAC list failure or an // ambiguous multiple-release match. Callers use errors.Is to tell "this cluster // doesn't host the release" apart from "couldn't determine the release." -var ErrNoParentRelease = errors.New("no tracebloc parent client release found") +var ErrNoParentRelease = errors.New("no tracebloc client found") // ParentRelease describes the tracebloc parent client chart release // discovered in the customer's cluster. The information comes from @@ -120,12 +121,16 @@ func DiscoverParentRelease(ctx context.Context, cs kubernetes.Interface, namespa switch len(jmDeps) { case 0: + // Customer-actionable, no Helm: the CLI's own contract is that + // customers never touch Helm, so the remediation is the flag, + // the installer, or the doctor — not a helm invocation. return nil, fmt.Errorf( - "%w in namespace %q "+ - "(no chart-managed Deployment named *-jobs-manager). "+ - "Install with `helm install tracebloc/client --namespace %s` first, "+ - "or pass --namespace to point at the namespace where it's running.", - ErrNoParentRelease, namespace, namespace, + "%w in namespace %q. "+ + "If your client runs in another namespace, pass --namespace; "+ + "if this cluster has no tracebloc client yet, run the installer: "+ + "bash <(curl -fsSL https://tracebloc.io/i.sh). "+ + "Diagnose with `tracebloc cluster doctor`.", + ErrNoParentRelease, namespace, ) case 1: // happy path @@ -135,9 +140,9 @@ func DiscoverParentRelease(ctx context.Context, cs kubernetes.Interface, namespa names = append(names, d.Name) } return nil, fmt.Errorf( - "found %d tracebloc parent releases in namespace %q (%s); "+ + "found %d tracebloc clients in namespace %q (%s); "+ "this CLI doesn't yet support disambiguating between multiple. "+ - "Pass --namespace to target a namespace with exactly one release.", + "Pass --namespace to target a namespace with exactly one client.", len(jmDeps), namespace, strings.Join(names, ", "), ) } @@ -188,6 +193,36 @@ func DiscoverParentRelease(ctx context.Context, cs kubernetes.Interface, namespa return release, nil } +// FindClientNamespaces scans every namespace the kubeconfig user may list for +// jobs-manager Deployments (the same selector + name filter DiscoverParentRelease +// uses) and returns the sorted, de-duplicated namespaces hosting one. It backs +// the fallback that makes `data list`/`cluster info` work out of the box when +// the client lives in its slug namespace rather than the kubeconfig's default +// (§7.3): a miss in the default namespace triggers this scan instead of a dead +// end. An RBAC-restricted user (cluster-wide list forbidden) gets the error +// back; callers treat that as "scan unavailable" and keep the original message. +func FindClientNamespaces(ctx context.Context, cs kubernetes.Interface) ([]string, error) { + deps, err := cs.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/name=client,app.kubernetes.io/managed-by=Helm", + }) + if err != nil { + return nil, fmt.Errorf("scanning the cluster for tracebloc clients: %w", err) + } + seen := make(map[string]bool) + var namespaces []string + for _, d := range deps.Items { + if d.Name != "jobs-manager" && !strings.HasSuffix(d.Name, "-jobs-manager") { + continue + } + if !seen[d.Namespace] { + seen[d.Namespace] = true + namespaces = append(namespaces, d.Namespace) + } + } + sort.Strings(namespaces) + return namespaces, nil +} + // InClusterClient identifies a tracebloc client already installed on the cluster: // its CLIENT_ID (the UUID auth username the pod authenticates with) and the // namespace its release occupies. diff --git a/internal/cluster/discover_test.go b/internal/cluster/discover_test.go index 64ebdb6..23e2d8e 100644 --- a/internal/cluster/discover_test.go +++ b/internal/cluster/discover_test.go @@ -152,7 +152,7 @@ func TestDiscoverParentRelease_NoReleaseFound(t *testing.T) { // The error message has to be customer-actionable. Pin the // key remediation phrase so a future refactor that loses it // (or worse, replaces it with a stack trace) fails this test. - for _, want := range []string{"no tracebloc parent client release found", "helm install"} { + for _, want := range []string{"no tracebloc client found", "--namespace", "https://tracebloc.io/i.sh", "cluster doctor"} { if !strings.Contains(err.Error(), want) { t.Errorf("expected error to mention %q, got: %s", want, err) } @@ -284,3 +284,49 @@ func TestChartVersionFromLabel(t *testing.T) { }) } } + +// FindClientNamespaces backs the cluster-wide fallback scan (§7.3): a miss in +// the kubeconfig's default namespace must find the client in its slug +// namespace instead of dead-ending. These pin the scan's filtering + ordering. +func TestFindClientNamespaces(t *testing.T) { + cs := fake.NewSimpleClientset( + jobsManagerDeployment("tracebloc", "lukas-01", "client-1.6.0", "1.6.0", ""), + jobsManagerDeployment("tracebloc", "zeta-ns", "client-1.6.0", "1.6.0", ""), + // a chart-labeled sibling that is NOT a jobs-manager must not count + siblingDeployment("mysql-client", "other-ns"), + ) + got, err := FindClientNamespaces(context.Background(), cs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"lukas-01", "zeta-ns"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("expected sorted namespaces %v, got %v", want, got) + } +} + +func TestFindClientNamespaces_NoneFound(t *testing.T) { + cs := fake.NewSimpleClientset(siblingDeployment("mysql-client", "somewhere")) + got, err := FindClientNamespaces(context.Background(), cs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected no namespaces, got %v", got) + } +} + +// siblingDeployment builds a chart-labeled Deployment that is not a +// jobs-manager — the scan must ignore it. +func siblingDeployment(name, namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "client", + "app.kubernetes.io/managed-by": "Helm", + }, + }, + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index d69ce9c..43d8b0f 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -123,10 +123,14 @@ func Run(ctx context.Context, cs kubernetes.Interface, opts Options) []Result { func checkReachable(release *cluster.ParentRelease, err error, ns string) Result { const name = "Cluster reachable" if err != nil { + // The discovery error's remediation tail points at cluster doctor — + // which is what's running. Strip it so doctor never tells the user + // to run doctor. + detail := strings.TrimSuffix(strings.TrimSpace(err.Error()), "Diagnose with `tracebloc cluster doctor`.") return Result{ Name: name, Status: StatusFail, - Detail: err.Error(), + Detail: strings.TrimSpace(detail), Remedy: "Check your kubeconfig/context and that the tracebloc client chart is installed here: kubectl get deploy -n " + ns, } } @@ -418,7 +422,7 @@ func checkImagePull(ctx context.Context, cs kubernetes.Interface, ns string, rel Name: name, Status: StatusWarn, Detail: "couldn't read jobs-manager to resolve image pull secrets — skipping", - Remedy: "Check the parent client release is installed in " + ns + ".", + Remedy: "Check a tracebloc client is installed in " + ns + ".", } } secrets := dep.Spec.Template.Spec.ImagePullSecrets diff --git a/internal/push/orphan.go b/internal/push/orphan.go index 65ab4c0..483793a 100644 --- a/internal/push/orphan.go +++ b/internal/push/orphan.go @@ -147,7 +147,7 @@ func FormatOrphansWarning(orphans []Orphan) string { } var s string s += fmt.Sprintf("WARNING: %d orphan stage Pod%s detected in this namespace — likely "+ - "leftover from a previously crashed `dataset push`:\n", + "leftover from a previously crashed `data ingest`:\n", len(orphans), pluralS(len(orphans))) names := make([]string, 0, len(orphans)) for _, o := range orphans { diff --git a/internal/push/walk.go b/internal/push/walk.go index a6bb163..0709bcc 100644 --- a/internal/push/walk.go +++ b/internal/push/walk.go @@ -152,8 +152,7 @@ func Discover(rootDir string) (*LocalLayout, error) { return nil, fmt.Errorf( "missing labels.csv in %q. The CLI expects "+ "/labels.csv + /images/ for image_classification; "+ - "see https://github.com/tracebloc/client/issues/147 for the "+ - "v0.1 layout contract.", + "see https://docs.tracebloc.io for the dataset layout.", abs) } return nil, fmt.Errorf("stat labels.csv: %w", err) From fa5415ee22c2e8573a2d355b1ed314895144e148 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Mon, 6 Jul 2026 21:54:29 +0200 Subject: [PATCH 2/4] feat(installer): ship a `tb` short alias next to the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kubectl→k pattern for the most-typed word in the product: the shell installer symlinks $PREFIX/tb → tracebloc (skipped if an unrelated tb already sits there); the PowerShell installer drops a tb.cmd shim. Both announce the alias in the install output + README. Requested by Lukas (2026-07-06). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 ++++++++------- scripts/install.ps1 | 16 +++++++++++++++- scripts/install.sh | 15 ++++++++++++++- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4cf03df..22579fa 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ The customer-facing CLI for the tracebloc declarative ingestion path. Wraps the ## Status -**v0.3.0 is released** — the latest stable [release](https://github.com/tracebloc/cli/releases/latest), cut from `develop`. It builds on v0.2.0's guided `dataset push` and `dataset rm` with a new `dataset list` command plus home-screen / output polish (clearer copy, guided-first framing). The binary implements `version`, `completion`, `ingest validate`, `cluster info`, and the full `dataset push` / `dataset list` / `dataset rm` flow — local schema validation, cluster discovery, data staging, submission, and Job watching, end to end. +**v0.3.0 is released** — the latest stable [release](https://github.com/tracebloc/cli/releases/latest), cut from `develop`. It builds on v0.2.0's guided `data ingest` and `dataset rm` with a new `dataset list` command plus home-screen / output polish (clearer copy, guided-first framing). The binary implements `version`, `completion`, `data validate`, `cluster info`, and the full `data ingest` / `dataset list` / `dataset rm` flow — local schema validation, cluster discovery, data staging, submission, and Job watching, end to end. -`dataset push` covers **9 of 10 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `masked_language_modeling`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)); `instance_segmentation` is not yet implemented. +`data ingest` covers **9 of 10 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `masked_language_modeling`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)); `instance_segmentation` is not yet implemented. The release pipeline ships [`v0.3.0`](https://github.com/tracebloc/cli/releases/latest) as **cosign-signed, multi-arch binaries** — Linux (`amd64`, `arm64`, `386`, `arm`), macOS (`amd64`, `arm64`), and Windows (`amd64`, `arm64`) — each with `SHA256SUMS` and the install scripts. Install via [Customer experience](#customer-experience) or [build from source](#building-from-source). (A Homebrew tap and the `install.tracebloc.io` vanity URL are later follow-ups; the GitHub release URL serves installs today.) @@ -24,7 +24,7 @@ The CLI is a sibling interface to the chart, not a replacement. Both translate t Customer interfaces (pick one or many): ┌─────────────────────────────────────────────────┐ │ Web UI Studio for clicking through │ ← future -│ CLI `tracebloc dataset push ./data` │ ← this repo +│ CLI `tracebloc data ingest ./data` │ ← this repo │ Python SDK `IngestConfig(...).submit()` │ ← future │ K8s CRD `kubectl apply` Ingestion CR │ ← future │ Helm chart tracebloc/ingestor │ ← today @@ -50,9 +50,10 @@ The protocol — the v1 schema + the POST endpoint — is the stable point. Ever curl -fsSL https://github.com/tracebloc/cli/releases/latest/download/install.sh | sh # Install — Windows (PowerShell) irm https://github.com/tracebloc/cli/releases/latest/download/install.ps1 | iex +# The installer also creates a short alias: tb works everywhere tracebloc does. # Per dataset -tracebloc dataset push ./my-data \ +tracebloc data ingest ./my-data \ --table cats_dogs_train \ --category image_classification \ --intent train \ @@ -108,15 +109,15 @@ All v0.1 phases are merged: | Phase | Ticket | What | Status | |---|---|---|---| | 0 | [#148](https://github.com/tracebloc/client/issues/148) | Repo bootstrap + Go module + CI + `tracebloc version` | ✅ | -| 1 | [#149](https://github.com/tracebloc/client/issues/149) | Embed `ingest.v1.json` + `tracebloc ingest validate ` (local-only) | ✅ | +| 1 | [#149](https://github.com/tracebloc/client/issues/149) | Embed `ingest.v1.json` + `tracebloc data validate ` (local-only) | ✅ | | 2 | [#150](https://github.com/tracebloc/client/issues/150) | Cluster discovery + ingestor SA token via TokenRequest | ✅ | | 3 | [#151](https://github.com/tracebloc/client/issues/151) | Stage data into the shared PVC via ephemeral Pod | ✅ | | 4 | [#152](https://github.com/tracebloc/client/issues/152) | Submit to jobs-manager + watch ingestor Job + summary | ✅ | | 5 | [#153](https://github.com/tracebloc/client/issues/153) | GitHub Releases + install.sh distribution (Homebrew tap deferred) | ✅ — [`v0.1.0`](https://github.com/tracebloc/cli/releases/tag/v0.1.0) released (stable, 8-platform) | -Beyond the original phases, `dataset push` was widened from image-classification-only to 9 of 10 modalities, and the test suite gained unit-coverage wins plus a kind-based integration harness for the real-I/O seams. +Beyond the original phases, `data ingest` was widened from image-classification-only to 9 of 10 modalities, and the test suite gained unit-coverage wins plus a kind-based integration harness for the real-I/O seams. -**v0.2.0** added a friendlier guided `dataset push` and `dataset rm` on the home screen (#44, #47). **v0.3.0** added the `dataset list` command (#53) plus home-screen / output-spacing polish and feedback-copy refinements (#52, #56). **Next:** cloud-source ingestion (S3/GCS/HTTPS) for datasets above the 1 GiB local cap; `semantic_segmentation` ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)) and `instance_segmentation`. Smaller follow-ups are tracked as [open issues](https://github.com/tracebloc/cli/issues). +**v0.2.0** added a friendlier guided `data ingest` and `dataset rm` on the home screen (#44, #47). **v0.3.0** added the `dataset list` command (#53) plus home-screen / output-spacing polish and feedback-copy refinements (#52, #56). **Next:** cloud-source ingestion (S3/GCS/HTTPS) for datasets above the 1 GiB local cap; `semantic_segmentation` ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)) and `instance_segmentation`. Smaller follow-ups are tracked as [open issues](https://github.com/tracebloc/cli/issues). Epic: [tracebloc/client#147](https://github.com/tracebloc/client/issues/147). diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9c17f11..373fafc 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -185,8 +185,20 @@ try { $target = Join-Path $InstallPrefix $BinaryName Move-Item -Path (Join-Path $tmpDir $binaryFile) -Destination $target -Force + # Short alias: `tb` — a cmd shim next to the exe (symlinks need admin or + # dev-mode on Windows). Skipped if an unrelated tb.cmd already exists. + $tbShim = Join-Path $InstallPrefix 'tb.cmd' + $shimBody = "@echo off`r`n`"$target`" %*`r`n" + if (-not (Test-Path $tbShim) -or ((Get-Content $tbShim -Raw -ErrorAction SilentlyContinue) -like "*$BinaryName*")) { + Set-Content -Path $tbShim -Value $shimBody -Encoding ascii + $tbNote = ' (short alias: tb)' + } else { + Write-Host "Note: $tbShim already exists and isn't ours — skipping the tb alias." + $tbNote = '' + } + Write-Host "" - Write-Host "✓ tracebloc CLI installed: $target" + Write-Host "✓ tracebloc CLI installed: $target$tbNote" Write-Host "" Write-Host "Verify with:" Write-Host " $target version" @@ -216,6 +228,8 @@ try { Write-Host "First steps:" Write-Host " tracebloc cluster info # confirm the CLI can reach your cluster" Write-Host " tracebloc data ingest --help # stage a dataset onto the cluster" + Write-Host "" + Write-Host "Short alias: tb works everywhere tracebloc does (tb data ingest .\data)" } finally { # Always clean up the temp dir, even on early exit / Ctrl-C. diff --git a/scripts/install.sh b/scripts/install.sh index 772874e..af3a1f5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -399,8 +399,19 @@ fi chmod +x "$TMP/$BINARY_FILE" mv "$TMP/$BINARY_FILE" "$PREFIX/$BINARY_NAME" +# Short alias: `tb` — the kubectl→k pattern for the most-typed word in the +# product. A symlink next to the binary, created only when the name is free +# (or already ours): never clobber an unrelated tool the user has as `tb`. +if [ ! -e "$PREFIX/tb" ] || [ "$(readlink "$PREFIX/tb" 2>/dev/null)" = "$PREFIX/$BINARY_NAME" ]; then + ln -sf "$PREFIX/$BINARY_NAME" "$PREFIX/tb" + TB_ALIAS_NOTE=" (short alias: tb)" +else + echo "Note: $PREFIX/tb already exists and isn't ours — skipping the tb alias." + TB_ALIAS_NOTE="" +fi + echo "" -echo "✓ tracebloc CLI installed: $PREFIX/$BINARY_NAME" +echo "✓ tracebloc CLI installed: $PREFIX/$BINARY_NAME$TB_ALIAS_NOTE" echo "" echo "Verify with:" echo " $PREFIX/$BINARY_NAME version" @@ -494,3 +505,5 @@ fi echo "First steps:" echo " $BINARY_NAME cluster info # confirm the CLI can reach your cluster" echo " $BINARY_NAME data ingest --help # stage a dataset onto the cluster" +echo "" +echo "Short alias: tb works everywhere tracebloc does (tb data ingest ./data)" From 213df99e3477d4e242233e0bc3c6e1c4275ecccc Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Mon, 6 Jul 2026 22:00:09 +0200 Subject: [PATCH 3/4] fix(installer): tb alias is best-effort; harness covers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification harness runs install.sh under a restricted PATH that didn't include ln → rc=127 on the happy path. Two-sided fix: the alias block now degrades silently when ln is unavailable or fails (an alias must never fail an install), and the harness whitelists ln+readlink and asserts the tb symlink exists and points at the binary on the happy path (12 checks now). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/install.sh | 16 ++++++++++------ scripts/tests/install-verify.sh | 9 ++++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index af3a1f5..ab6cdbe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -402,12 +402,16 @@ mv "$TMP/$BINARY_FILE" "$PREFIX/$BINARY_NAME" # Short alias: `tb` — the kubectl→k pattern for the most-typed word in the # product. A symlink next to the binary, created only when the name is free # (or already ours): never clobber an unrelated tool the user has as `tb`. -if [ ! -e "$PREFIX/tb" ] || [ "$(readlink "$PREFIX/tb" 2>/dev/null)" = "$PREFIX/$BINARY_NAME" ]; then - ln -sf "$PREFIX/$BINARY_NAME" "$PREFIX/tb" - TB_ALIAS_NOTE=" (short alias: tb)" -else - echo "Note: $PREFIX/tb already exists and isn't ours — skipping the tb alias." - TB_ALIAS_NOTE="" +TB_ALIAS_NOTE="" +if command -v ln >/dev/null 2>&1; then + if [ ! -e "$PREFIX/tb" ] || [ "$(readlink "$PREFIX/tb" 2>/dev/null)" = "$PREFIX/$BINARY_NAME" ]; then + # Best-effort: an alias must never be able to fail the install. + if ln -sf "$PREFIX/$BINARY_NAME" "$PREFIX/tb" 2>/dev/null; then + TB_ALIAS_NOTE=" (short alias: tb)" + fi + else + echo "Note: $PREFIX/tb already exists and isn't ours — skipping the tb alias." + fi fi echo "" diff --git a/scripts/tests/install-verify.sh b/scripts/tests/install-verify.sh index f7ca660..ed388ad 100755 --- a/scripts/tests/install-verify.sh +++ b/scripts/tests/install-verify.sh @@ -58,7 +58,7 @@ EOF chmod +x "$BIN/curl" # coreutils the installer needs, so PATH=$BIN alone works (host cosign hidden). - for t in bash sh env mkdir mktemp cp cat awk grep sed head tr uname chmod mv rm sleep printf install dirname basename sha256sum shasum; do + for t in bash sh env mkdir mktemp cp cat awk grep sed head tr uname chmod mv rm sleep printf install dirname basename sha256sum shasum ln readlink; do p="$(command -v "$t" 2>/dev/null)" && ln -sf "$p" "$BIN/$t" done @@ -85,6 +85,13 @@ if [ "$rc" = 0 ] && grep -q "cosign signature valid" "$SBX/out" && [ -x "$DEST/t else bad "cosign present + valid sig installs (rc=$rc)"; sed 's/^/ /' "$SBX/out" fi +# the tb short alias rides along on the happy path (best-effort, but with ln +# available — as here — it must exist and point at the binary) +if [ "$(readlink "$DEST/tb" 2>/dev/null)" = "$DEST/tracebloc" ]; then + ok "tb short alias created next to the binary" +else + bad "tb short alias missing or wrong target ($(readlink "$DEST/tb" 2>/dev/null || echo none))" +fi drop_sandbox # ── 2. cosign present + FAILED signature → aborts ─────────────────────────── From 31e8a1f711be4f39733b5c7bf457bea35af774e9 Mon Sep 17 00:00:00 2001 From: Asad Iqbal Date: Tue, 7 Jul 2026 12:05:40 +0500 Subject: [PATCH 4/4] fix(cli): cluster info shows the retargeted namespace; tb alias best-effort on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups on this PR: - cluster info printed the "namespace" field before the cluster-wide fallback scan retargets resolved.Namespace, so when the scan finds the client in its slug namespace the pre-flight reported "default" while the Client-install section and ingestor-SA path below showed the real namespace. Print the field after the retarget — the command's whole job is to report what the next `data ingest` will target. - install.ps1's tb alias wasn't actually best-effort: under $ErrorActionPreference='Stop' a Set-Content failure aborted the whole install (the third commit only hardened the Unix side). Wrap it in try/catch so a write failure just skips the alias. Also tighten the "is this tb.cmd ours?" guard from a loose "*tracebloc.exe*" substring (which would clobber any unrelated tb.cmd merely mentioning the name) to an exact match on our quoted binary path — matching install.sh's strict readlink-target equality. Co-Authored-By: Claude Opus 4.8 --- internal/cli/cluster.go | 5 ++++- scripts/install.ps1 | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 85cbe68..76a0952 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -149,7 +149,6 @@ func runClusterInfo( p.Section("Kubeconfig") p.Field("context", resolved.Context) p.Field("server", resolved.ServerURL) - p.Field("namespace", resolved.Namespace) // Discover the client's release — with the cluster-wide fallback scan // when the namespace is just the kubeconfig default, so diagnostics find @@ -167,6 +166,10 @@ func runClusterInfo( return &exitError{code: 4, err: err} } resolved.Namespace = nsUsed + // Printed after discovery so it reflects the namespace the scan actually + // retargeted to — this pre-flight's whole job is to report what the next + // `data ingest` will target, so it must not show the pre-scan default. + p.Field("namespace", resolved.Namespace) // Apply the SA-name override here. Discovery doesn't read the // name from the cluster (see #7); customers with a non-default diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 373fafc..ab8f84b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -186,15 +186,22 @@ try { Move-Item -Path (Join-Path $tmpDir $binaryFile) -Destination $target -Force # Short alias: `tb` — a cmd shim next to the exe (symlinks need admin or - # dev-mode on Windows). Skipped if an unrelated tb.cmd already exists. + # dev-mode on Windows). Best-effort: an alias must never fail the install + # (mirrors install.sh), and we never clobber an unrelated tb.cmd — "ours" + # means it invokes exactly our binary path, not merely mentions the name. $tbShim = Join-Path $InstallPrefix 'tb.cmd' $shimBody = "@echo off`r`n`"$target`" %*`r`n" - if (-not (Test-Path $tbShim) -or ((Get-Content $tbShim -Raw -ErrorAction SilentlyContinue) -like "*$BinaryName*")) { - Set-Content -Path $tbShim -Value $shimBody -Encoding ascii - $tbNote = ' (short alias: tb)' + $tbNote = '' + $tbExisting = if (Test-Path $tbShim) { Get-Content $tbShim -Raw -ErrorAction SilentlyContinue } else { $null } + if (-not (Test-Path $tbShim) -or ($tbExisting -like ('*"' + $target + '"*'))) { + try { + Set-Content -Path $tbShim -Value $shimBody -Encoding ascii -ErrorAction Stop + $tbNote = ' (short alias: tb)' + } catch { + Write-Host "Note: couldn't create the tb alias ($($_.Exception.Message)) — skipping." + } } else { Write-Host "Note: $tbShim already exists and isn't ours — skipping the tb alias." - $tbNote = '' } Write-Host ""