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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.20
0.10.21
10 changes: 7 additions & 3 deletions internal/cli/client_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

func newClientStatusCmd() *cobra.Command {
var wait, seal bool
var wait, seal, iKnowTarget bool
var timeout time.Duration
var kubeconfigPath, contextOverride, nsOverride string
cmd := &cobra.Command{
Expand Down Expand Up @@ -63,15 +63,15 @@ Exit codes with --seal:
if cmd.Flags().Changed("timeout") && !wait && !seal {
return &exitError{code: exitFailure, err: errors.New("--timeout has no effect without --wait or --seal")}
}
for _, name := range []string{"kubeconfig", "context", "namespace"} {
for _, name := range []string{"kubeconfig", "context", "namespace", knowTargetFlag} {
if cmd.Flags().Changed(name) && !seal {
return &exitError{code: exitFailure, err: fmt.Errorf("--%s has no effect without --seal", name)}
}
}
if seal {
return runSealCheck(cmd.Context(), printerFor(cmd),
cluster.KubeconfigOptions{Path: kubeconfigPath, Context: contextOverride, Namespace: nsOverride},
timeout)
timeout, iKnowTarget)
}
return runClientStatus(cmd.Context(), printerFor(cmd), wait, timeout)
},
Expand All @@ -85,6 +85,10 @@ Exit codes with --seal:
"with --seal: "+kubeconfigFlagUsage,
"with --seal: "+contextFlagUsage)
addNamespaceFlag(cmd, &nsOverride, "with --seal: "+namespaceFlagUsage)
// Registered directly (not addKnowTargetFlag) so the help carries the same
// "with --seal:" prefix as the other cluster-side flags above: it is inert
// without --seal, and the rejection loop enforces that.
cmd.Flags().BoolVar(&iKnowTarget, knowTargetFlag, false, "with --seal: "+knowTargetFlagUsage)
return cmd
}

Expand Down
278 changes: 239 additions & 39 deletions internal/cli/clusterguard.go

Large diffs are not rendered by default.

343 changes: 326 additions & 17 deletions internal/cli/clusterguard_test.go

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions internal/cli/clustertarget.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ type clusterTarget struct {
// the choice must be impossible to omit rather than an opt-in helper a new command
// forgets to call. `true` runs guardActiveClientCluster before the target is handed
// back; `false` is a read.
func resolveClusterTarget(ctx context.Context, p *ui.Printer, opts cluster.KubeconfigOptions, b activeClientBinding, needPVC, leadRedirect, mutates bool) (*clusterTarget, error) {
//
// ackTarget carries the mutating command's --i-know-the-target flag through to the
// guard: it lets an operator proceed against a cluster whose identity can't be
// verified (backend#2983). It is only read when mutates is true; reads pass false.
func resolveClusterTarget(ctx context.Context, p *ui.Printer, opts cluster.KubeconfigOptions, b activeClientBinding, needPVC, leadRedirect, mutates, ackTarget bool) (*clusterTarget, error) {
resolved, err := loadClusterFn(opts)
if err != nil {
return nil, &exitError{code: exitLocalEnv, err: fmt.Errorf("loading kubeconfig: %w", err)}
Expand Down Expand Up @@ -135,7 +139,7 @@ func resolveClusterTarget(ctx context.Context, p *ui.Printer, opts cluster.Kubec
// back, so a mutating command cannot have touched anything by the time it is told
// this is the wrong cluster.
if mutates {
if err := guardActiveClientCluster(ctx, p, t); err != nil {
if err := guardActiveClientCluster(ctx, p, t, ackTarget); err != nil {
return nil, err
}
}
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/clustertarget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func withClusterSeams(t *testing.T, cs kubernetes.Interface) {
func TestResolveClusterTarget_NoClient_InstallerMessageExit4(t *testing.T) {
withClusterSeams(t, fake.NewSimpleClientset()) // empty cluster
_, err := resolveClusterTarget(context.Background(), nil,
cluster.KubeconfigOptions{}, activeClientBinding{}, true, true, false)
cluster.KubeconfigOptions{}, activeClientBinding{}, true, true, false, false)
if err == nil {
t.Fatal("expected an error when the cluster hosts no client")
}
Expand All @@ -68,7 +68,7 @@ func TestResolveClusterTarget_NoClient_InstallerMessageExit4(t *testing.T) {
func TestResolveClusterTarget_MultipleClients_PickOneExit4(t *testing.T) {
withClusterSeams(t, fake.NewSimpleClientset(jmDep("alpha"), jmDep("beta")))
_, err := resolveClusterTarget(context.Background(), nil,
cluster.KubeconfigOptions{}, activeClientBinding{}, true, true, false)
cluster.KubeconfigOptions{}, activeClientBinding{}, true, true, false, false)
if err == nil {
t.Fatal("expected an error when multiple clients are present")
}
Expand Down Expand Up @@ -338,7 +338,7 @@ func TestExplain_BindingMiss_NamesTheLocalClientWithoutRetargeting(t *testing.T)

binding := activeClientBinding{applied: true, name: "gpu-box-01", namespace: "stale-ns"}
target, err := resolveClusterTarget(context.Background(), nil,
cluster.KubeconfigOptions{Namespace: "stale-ns"}, binding, false, false, false)
cluster.KubeconfigOptions{Namespace: "stale-ns"}, binding, false, false, false, false)
if err == nil {
t.Fatal("a binding miss must still fail — this changes the message, not the target")
}
Expand Down
8 changes: 7 additions & 1 deletion internal/cli/data_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type runDataDeleteArgs struct {
// --output-json mode. Same contract as data list / data ingest.
OutputJSON bool
JSONOut io.Writer
// AckTarget carries --i-know-the-target: proceed even when the target
// cluster's identity can't be verified with tracebloc (backend#2983).
AckTarget bool
}

// newDataDeleteCmd implements `tracebloc data delete <table>` — the
Expand All @@ -50,6 +53,7 @@ func newDataDeleteCmd() *cobra.Command {
dryRun bool
yes bool
outputJSON bool
iKnowTarget bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -106,6 +110,7 @@ docs/json-output.md for the shape and the stability promise.`,
Prompter: pr,
OutputJSON: outputJSON,
JSONOut: jsonOut,
AckTarget: iKnowTarget,
})
},
}
Expand All @@ -118,6 +123,7 @@ docs/json-output.md for the shape and the stability promise.`,
"skip the confirmation prompt (required when not on a terminal)")
cmd.Flags().BoolVar(&outputJSON, "output-json", false,
"emit the delete result as JSON on stdout (human output → stderr; never prompts — pass --yes to delete, or --dry-run)")
addKnowTargetFlag(cmd, &iKnowTarget)

return cmd
}
Expand Down Expand Up @@ -174,7 +180,7 @@ undone — re-ingesting the data is the only way back.`)
// leadRedirect=false: the warning paragraph above is already this command's
// opening output, so the multi-client redirect note stays inline — no
// mid-output blank between the warning and the note (§380).
target, err := resolveClusterTargetFn(ctx, a.Printer, opts, binding, true, false, true)
target, err := resolveClusterTargetFn(ctx, a.Printer, opts, binding, true, false, true, a.AckTarget)
if err != nil {
return binding.explain(ctx, err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/data_delete_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestRunDataDelete_Execute(t *testing.T) {
resolveClusterTargetFn, listDatasetsFn, teardownFn = origRCT, origList, origTD
})

resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _ bool) (*clusterTarget, error) {
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _, _ bool) (*clusterTarget, error) {
return &clusterTarget{
Resolved: &cluster.ResolvedConfig{Context: "ctx", Namespace: "tracebloc"},
Clientset: fake.NewSimpleClientset(),
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/data_delete_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestRunDataDelete_OutputJSON(t *testing.T) {
resolveClusterTargetFn, listDatasetsFn, teardownFn = origRCT, origList, origTD
})

resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _ bool) (*clusterTarget, error) {
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _, _ bool) (*clusterTarget, error) {
return &clusterTarget{
Resolved: &cluster.ResolvedConfig{Context: "ctx", Namespace: "tracebloc"},
Clientset: fake.NewSimpleClientset(),
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/data_ingest_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func connectIngestTarget(ctx context.Context, a *runDataIngestArgs) (target *clu
// leadRedirect=false: the "Connecting…" line above is already this command's
// opening output, so the multi-client redirect note stays inline — no
// mid-output blank between "Connecting…" and the note (§380).
target, err = resolveClusterTarget(ctx, a.Printer, opts, binding, true, false, true)
target, err = resolveClusterTarget(ctx, a.Printer, opts, binding, true, false, true, a.AckTarget)
if err != nil {
return nil, "", false, binding.explain(ctx, err)
}
Expand Down
16 changes: 12 additions & 4 deletions internal/cli/data_ingest_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ func newDataIngestCmd() *cobra.Command {
numberOfKeypoints int

// Operations flags.
dryRun bool
overwrite bool
noInput bool
outputJSON bool
dryRun bool
overwrite bool
noInput bool
outputJSON bool
iKnowTarget bool

// Stage Pod image override. Defaults to the digest-pinned
// alpine that ships with the CLI; air-gapped customers
Expand Down Expand Up @@ -246,6 +247,7 @@ Exit codes:
ChangedFlags: changedFlags,
OutputJSON: outputJSON,
JSONOut: jsonOut,
AckTarget: iKnowTarget,
})
},
}
Expand Down Expand Up @@ -300,6 +302,7 @@ Exit codes:
"disable interactive prompts; fail on missing required values (for CI/scripts)")
cmd.Flags().BoolVar(&outputJSON, "output-json", false,
"emit a machine-readable JSON result on stdout (human output → stderr; implies --no-input)")
addKnowTargetFlag(cmd, &iKnowTarget)
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.")
Expand Down Expand Up @@ -375,4 +378,9 @@ type runDataIngestArgs struct {
Detach bool
IdempotencyKey string
ImageDigest string

// AckTarget carries --i-know-the-target: proceed even when the target
// cluster's identity can't be verified with tracebloc (backend#2983). It
// threads to the mutating-target guard via resolveClusterTarget.
AckTarget bool
}
2 changes: 1 addition & 1 deletion internal/cli/data_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func runDataList(ctx context.Context, a runDataListArgs) (err error) {
binding := bindActiveClientNamespace(&opts)
// leadRedirect=true: data list prints nothing before resolving, so the
// multi-client redirect note is the opening line and self-leads its blank.
target, err := resolveClusterTarget(ctx, p, opts, binding, false, true, false)
target, err := resolveClusterTarget(ctx, p, opts, binding, false, true, false, false)
if err != nil {
return binding.explain(ctx, err)
}
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const (
kubeconfigFlagUsage = "path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config)"
contextFlagUsage = "name of the kubeconfig context to use (default: kubeconfig's current-context)"
namespaceFlagUsage = "namespace where your tracebloc client is installed"

// knowTargetFlag is the shared escape hatch for the mutating-command target
// guard (backend#2983): proceed even when the cluster's identity can't be
// verified with tracebloc. Named as a const so the flag string is defined once
// and the guard's message can refer to it without drift.
knowTargetFlag = "i-know-the-target"
knowTargetFlagUsage = "proceed even if the target cluster's identity can't be verified with tracebloc " +
"(overrides the safety check — only when you are certain which cluster you are on)"
)

// addKubeconfigFlags registers the shared --kubeconfig/--context pair on cmd,
Expand All @@ -29,3 +37,11 @@ func addKubeconfigFlags(cmd *cobra.Command, kubeconfig, context *string, kubecon
func addNamespaceFlag(cmd *cobra.Command, namespace *string, usage string) {
cmd.Flags().StringVarP(namespace, "namespace", "n", "", usage)
}

// addKnowTargetFlag registers the shared --i-know-the-target escape hatch on a
// mutating command (backend#2983). Every command that passes mutates=true to
// resolveClusterTarget should offer it, so an operator whose cluster can't be
// verified with tracebloc always has a named way through rather than a dead end.
func addKnowTargetFlag(cmd *cobra.Command, ack *bool) {
cmd.Flags().BoolVar(ack, knowTargetFlag, false, knowTargetFlagUsage)
}
2 changes: 1 addition & 1 deletion internal/cli/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func runResourcesShow(ctx context.Context, p *ui.Printer, opts cluster.Kubeconfi
binding := bindActiveClientNamespace(&opts)
// leadRedirect=true: this resolve is the command's first output, so the
// multi-client redirect note self-leads its one leading blank (§380).
target, err := resolveClusterTargetFn(ctx, p, opts, binding, false, true, false)
target, err := resolveClusterTargetFn(ctx, p, opts, binding, false, true, false, false)
if err != nil {
return binding.explain(ctx, err)
}
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/resources_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ type setReq struct {
max bool // the `max` positional: give a run the whole machine (− overhead)
yes bool
dryRun bool
// ackTarget carries --i-know-the-target: proceed even when the target
// cluster's identity can't be verified with tracebloc (backend#2983).
ackTarget bool
}

// newResourcesSetCmd wires `tracebloc resources set` — raising how much of this
Expand Down Expand Up @@ -116,6 +119,7 @@ Exit codes:
"skip the confirmation prompt (for automation)")
setCmd.Flags().BoolVar(&req.dryRun, "dry-run", false,
"show exactly what would change and apply nothing")
addKnowTargetFlag(setCmd, &req.ackTarget)

setCmd.Flags().StringVar(&kubeconfigPath, "kubeconfig", "",
"path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config)")
Expand Down Expand Up @@ -144,7 +148,7 @@ func runResourcesSet(ctx context.Context, p *ui.Printer, pr prompter, opts clust
// single-client path there's no note and the confirm/dry-run self-lead
// supplies the only leading blank — so `set` keeps NO pre-resolve Newline()
// and never regresses the #375 double-blank (§380).
target, err := resolveClusterTarget(ctx, p, opts, binding, false, true, true)
target, err := resolveClusterTarget(ctx, p, opts, binding, false, true, true, req.ackTarget)
if err != nil {
return binding.explain(ctx, err)
}
Expand Down
11 changes: 9 additions & 2 deletions internal/cli/resources_set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1197,9 +1197,16 @@ func TestSet_MultiClientRedirectOpensWithSingleBlank(t *testing.T) {
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) // no active-client binding → scan allowed
fakeHelm(t)
// Ready node for the fit-check, plus a chart-labeled jobs-manager in a
// NON-default namespace so the scan retargets there.
cs := fake.NewClientset(resNode("n1", "8", "32Gi"), jmDep("lukas-01"))
// NON-default namespace so the scan retargets there, and a kube-system UID so
// the mutating-target guard can read the cluster identity.
cs := fake.NewClientset(resNode("n1", "8", "32Gi"), jmDep("lukas-01"), kubeSystem("MULTI-UID"))
withClusterSeams(t, cs)
// The recorded anchor matches the live cluster, so the guard proceeds (this test
// is about the redirect blank, not the guard); the API is offline so it does not
// second-guess. Setting only the cluster anchor leaves the namespace binding
// unset, so allowScan stays true and the redirect still fires.
withAnchor(t, "MULTI-UID")
withAccountClients(t, nil, errors.New("offline (test): no backend"))

var buf bytes.Buffer
// No --context/--namespace so allowScan stays true; a flag-driven change
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func TestShow_OpensWithSingleBlank(t *testing.T) {
orig := resolveClusterTargetFn
t.Cleanup(func() { resolveClusterTargetFn = orig })
cs := csWith("8", "32Gi", map[string]string{"RESOURCE_LIMITS": "cpu=4,memory=16Gi"})
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _ bool) (*clusterTarget, error) {
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _, _ bool) (*clusterTarget, error) {
return resTarget(cs), nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/seal.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ func (m sealModel) failedCount() int {
// way the data commands do (exit 3 unreachable, exit 4 no client — with the
// §7.3 active-client binding and its "runs on another machine" rewrite), list
// the chart's test hooks, run each one, render, and exit by the verdict.
func runSealCheck(ctx context.Context, p *ui.Printer, opts cluster.KubeconfigOptions, timeout time.Duration) error {
func runSealCheck(ctx context.Context, p *ui.Printer, opts cluster.KubeconfigOptions, timeout time.Duration, ackTarget bool) error {
binding := bindActiveClientNamespace(&opts)
// leadRedirect=true: seal prints nothing before resolving, so the
// multi-client redirect note is the opening line and self-leads its blank.
target, err := resolveClusterTargetFn(ctx, p, opts, binding, false, true, true)
target, err := resolveClusterTargetFn(ctx, p, opts, binding, false, true, true, ackTarget)
if err != nil {
return binding.explain(ctx, err)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/seal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
func stubSealTarget(t *testing.T) {
t.Helper()
orig := resolveClusterTargetFn
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _ bool) (*clusterTarget, error) {
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _, _ bool) (*clusterTarget, error) {
return &clusterTarget{
Resolved: &cluster.ResolvedConfig{Context: "resolved-ctx", Namespace: "acme"},
Release: &cluster.ParentRelease{ReleaseName: "acme"},
Expand Down Expand Up @@ -79,7 +79,7 @@ func runSeal(t *testing.T, timeout time.Duration) (string, error) {
var out bytes.Buffer
err := runSealCheck(context.Background(), ui.New(&out), cluster.KubeconfigOptions{
Path: "/tmp/kc", Context: "kind-acme",
}, timeout)
}, timeout, false)
return out.String(), err
}

Expand Down Expand Up @@ -312,7 +312,7 @@ func TestSeal_HookListError_NoVerdict(t *testing.T) {
// §7.3 binding-miss rewrite path returns exit 4) — the seal check adds nothing.
func TestSeal_ResolveErrorPropagates(t *testing.T) {
orig := resolveClusterTargetFn
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _ bool) (*clusterTarget, error) {
resolveClusterTargetFn = func(_ context.Context, _ *ui.Printer, _ cluster.KubeconfigOptions, _ activeClientBinding, _, _, _, _ bool) (*clusterTarget, error) {
return nil, &exitError{code: exitNoWorkspace, err: errors.New("no tracebloc client found in namespace \"acme\"")}
}
t.Cleanup(func() { resolveClusterTargetFn = orig })
Expand All @@ -338,7 +338,7 @@ func TestSeal_CancelledDuringListing_QuietExit(t *testing.T) {
t.Cleanup(func() { listTestHooksFn = origList })

var out bytes.Buffer
err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0)
err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0, false)
if got := ExitCodeFromError(err); got != exitInterrupted {
t.Fatalf("exit code = %d, want %d (%v)", got, exitInterrupted, err)
}
Expand Down Expand Up @@ -369,7 +369,7 @@ func TestSeal_CancelledMidSuite_NoVerdict(t *testing.T) {
t.Cleanup(func() { listTestHooksFn, runHelmTestFn = origList, origRun })

var out bytes.Buffer
err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0)
err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0, false)
if got := ExitCodeFromError(err); got != exitInterrupted {
t.Fatalf("exit code = %d, want %d (%v)", got, exitInterrupted, err)
}
Expand Down Expand Up @@ -414,7 +414,7 @@ func TestSeal_CtrlCExit130BeforeCtxFlips_NoFalseUnsealed(t *testing.T) {
t.Cleanup(func() { listTestHooksFn, runHelmTestFn = origList, origRun })

var out bytes.Buffer
err := runSealCheck(context.Background(), ui.New(&out), cluster.KubeconfigOptions{}, 0)
err := runSealCheck(context.Background(), ui.New(&out), cluster.KubeconfigOptions{}, 0, false)
if got := ExitCodeFromError(err); got != exitInterrupted {
t.Fatalf("exit code = %d, want %d — a Ctrl-C (helm exit 130) must be a quiet interrupt, not a verdict: %v", got, exitInterrupted, err)
}
Expand Down
Loading
Loading