diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f9f38703..bb9f5867 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -118,9 +118,9 @@ produces that code. | `0` | Success — includes `--dry-run` completing, a guided run you cancelled cleanly, and `doctor` passing with warnings only | all commands | `exitOK` | | `1` | Generic failure with no more specific bucket (also any error without an explicit code) | `login`, `client …`, `delete`, mistyped commands | `exitFailure` | | `2` | Your input didn't validate: schema validation failed (spec synthesized from flags, or your YAML), an unsupported/unknown `--task`, a task-scoped flag applied to the wrong task, an invalid dataset name, or a resource size that doesn't fit the machine | `data ingest`, `data validate`, `data delete`, `resources set` | `exitBadInput` | -| `2` | One or more checks failed | `doctor` | `exitChecksFailed` | -| `3` | Local environment problem: kubeconfig couldn't be loaded, the dataset path is missing or unreadable, the local layout is wrong, a YAML file didn't parse, or a prompt was needed but the run is non-interactive (`--no-input` / `--output-json` / no TTY) | `data ingest`, `data validate`, `data list`, `data delete`, `doctor`, `resources`, `resources set` | `exitLocalEnv` | -| `4` | Cluster reachable but no tracebloc client found in the namespace — or its shared storage / dataset list is missing, so the target can't be confirmed | `data ingest`, `data list`, `data delete`, `cluster info`, `resources`, `resources set` | `exitNoWorkspace` | +| `2` | One or more checks failed — for `client status --seal`: the environment is unsealed (a conformance check failed), or unknown (the chart ships no conformance checks, so the seal couldn't be verified) | `doctor`, `client status --seal` | `exitChecksFailed` | +| `3` | Local environment problem: kubeconfig couldn't be loaded, the dataset path is missing or unreadable, the local layout is wrong, a YAML file didn't parse, or a prompt was needed but the run is non-interactive (`--no-input` / `--output-json` / no TTY) | `data ingest`, `data validate`, `data list`, `data delete`, `doctor`, `resources`, `resources set`, `client status --seal` | `exitLocalEnv` | +| `4` | Cluster reachable but no tracebloc client found in the namespace — or its shared storage / dataset list is missing, so the target can't be confirmed | `data ingest`, `data list`, `data delete`, `cluster info`, `resources`, `resources set`, `client status --seal` | `exitNoWorkspace` | | `5` | Auth: the ingestor SA token couldn't be obtained, or jobs-manager rejected it (401/403) | `data ingest`, `cluster info` | `exitAuth` | | `5` | No dataset by that name on this client (nothing to delete) | `data delete` | `exitNoSuchDataset` | | `6` | Destination table already exists — re-run with `--overwrite` to replace it, or pick a different `--name` | `data ingest` | `exitTableExists` | diff --git a/internal/cli/client.go b/internal/cli/client.go index 72d936f2..7875cae7 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -12,7 +12,6 @@ import ( "path/filepath" "strconv" "strings" - "time" "github.com/spf13/cobra" @@ -758,184 +757,6 @@ func runClientList(ctx context.Context, p *ui.Printer) error { return nil } -func newClientStatusCmd() *cobra.Command { - var wait bool - var timeout time.Duration - cmd := &cobra.Command{ - Use: "status", - Short: "Show whether tracebloc can see this machine's client (online)", - Long: `Report tracebloc's view of this machine's active client — online, offline, -or pending. With --wait, poll until tracebloc reports it online (exit 0) or the -timeout elapses (non-zero), to confirm the client connected after setup.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - // --timeout only governs the --wait poll; accepting it alone would be a - // silent no-op, so reject it rather than mislead. - if cmd.Flags().Changed("timeout") && !wait { - return &exitError{code: exitFailure, err: errors.New("--timeout has no effect without --wait")} - } - return runClientStatus(cmd.Context(), printerFor(cmd), wait, timeout) - }, - } - cmd.Flags().BoolVar(&wait, "wait", false, "poll until tracebloc reports this client online") - cmd.Flags().DurationVar(&timeout, "timeout", 120*time.Second, "with --wait, give up after this long") - return cmd -} - -// clientStatusPollInterval is how often --wait re-checks the backend. A const, -// not a seam: tests inject through pollAfter (which ignores the duration and -// fires instantly), so the value never needs overriding. -const clientStatusPollInterval = 3 * time.Second - -func runClientStatus(ctx context.Context, p *ui.Printer, wait bool, timeout time.Duration) error { - client, cfg, err := authedClient() - if err != nil { - return &exitError{code: exitFailure, err: err} - } - active := cfg.Current().ActiveClientID - if active == "" { - return &exitError{code: exitFailure, err: errors.New( - "no active client on this machine — run `tracebloc client create` (or re-run the installer) first")} - } - - // One-shot: report the current state and exit 0 (informational). - if !wait { - st, found, lerr := lookupClientStatus(ctx, client, active) - if lerr != nil { - return &exitError{code: exitFailure, err: lerr} - } - if !found { - return &exitError{code: exitFailure, err: fmt.Errorf( - "active client %s isn't in your account — run `tracebloc client create` "+ - "(or re-run the installer) to provision this machine", active)} - } - p.Section("Client status") - p.Field("state", clientStateLabel(st)) - return nil - } - - // --wait: poll the same source `client list` renders until online or timeout. - // The backend-reported status is the honest Online signal (RFC-0001 §8.5); a - // local rollout check can't tell whether tracebloc can actually see the client. - sp := p.Spinner("Waiting for tracebloc to confirm…", "") - defer sp.Stop() // leak-proof net for every return; the online path Stops explicitly before its ✔ - deadline := time.Now().Add(timeout) - var lastErr error // most recent transient (retryable) error, for the timeout message - lastState := -1 // most recent successfully-read status; -1 = none read yet - var ue *api.UpgradeRequiredError - var apiErr *api.APIError - for { - st, found, lerr := lookupClientStatus(ctx, client, active) - switch { - case errors.As(lerr, &ue): - // 426 (CLI too old) won't recover by waiting — surface the upgrade signal. - return &exitError{code: exitFailure, err: lerr} - case errors.As(lerr, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden): - // A revoked/expired/forbidden token (401/403) won't recover by waiting — - // the client itself may be online. Fail fast, point at sign-in. Note 429 - // and 5xx stay transient (below): those DO recover on retry. - return &exitError{code: exitFailure, err: errors.New( - "tracebloc rejected your credentials while waiting — run `tracebloc login`, then retry")} - case lerr != nil: - lastErr = lerr // transient (5xx / 429 / network) — keep waiting, remember why - case !found: - // The active client isn't in the account (deleted / wrong account) — no - // amount of waiting surfaces it. Fail fast, matching the one-shot path. - return &exitError{code: exitFailure, err: fmt.Errorf( - "active client %s isn't in your account — run `tracebloc client create` "+ - "(or re-run the installer) to provision this machine", active)} - case st == clientStatusOnline: - sp.Stop() - p.Successf("tracebloc can see this client.") - return nil - default: - // A good poll (present, not online yet) supersedes any earlier transient - // error, so a later timeout reports the real state, not a stale failure. - lastErr, lastState = nil, st - } - - // --timeout caps how long we WAIT: stop once the budget is spent, and clamp - // the sleep to what remains so total runtime doesn't overshoot by a poll - // cycle. A poll begun within budget is still honored (online → ✔ above). - remaining := time.Until(deadline) - if remaining <= 0 { - switch { - case lastErr != nil: - return &exitError{code: exitFailure, err: fmt.Errorf( - "timed out after %s waiting for tracebloc to report this client online; "+ - "the last status check failed: %v", timeout, lastErr)} - case lastState >= 0: - return &exitError{code: exitFailure, err: fmt.Errorf( - "timed out after %s waiting for tracebloc to report this client online (last state: %s). "+ - "Run `tracebloc doctor` to diagnose, or re-run the installer.", timeout, clientStateLabel(lastState))} - default: - return &exitError{code: exitFailure, err: fmt.Errorf( - "timed out after %s before tracebloc could confirm this client — retry, "+ - "or run `tracebloc doctor`.", timeout)} - } - } - wait := clientStatusPollInterval - if wait > remaining { - wait = remaining - } - select { - case <-ctx.Done(): - return &exitError{code: exitInterrupted} // Ctrl-C: exit quietly (no "Error: context canceled") - case <-pollAfter(wait): - } - } -} - -// lookupClientStatus fetches the active client directly and returns its backend -// status code. found=false means no such client (deleted, or signed into the -// wrong account). A lookup error is returned verbatim so --wait can treat it as -// transient and retry. Fetches the single client by id (GET /edge-device/{id}/) -// rather than listing the whole account — the home-screen heartbeat runs this -// under a ~1.2s budget, and paging every client blew it (cli#338). -func lookupClientStatus(ctx context.Context, client *api.Client, active string) (status int, found bool, err error) { - id, err := strconv.Atoi(active) - if err != nil { - // A non-numeric active id can never match a backend client, so report it - // as not-found — exactly what the old ListClients+match path did — rather - // than a permanent error. A --wait loop fail-fasts on a missing client but - // treats errors as transient, so returning an error here would make it - // poll a permanent parse failure to the timeout (Bugbot: poll/retry loops - // must fail-fast on non-transient errors). - return 0, false, nil - } - c, err := client.GetClient(ctx, id) - if err != nil { - return 0, false, err - } - if c == nil { - return 0, false, nil // 404 — no such client - } - return c.Status, true, nil -} - -// EdgeDevice.status codes mirrored from the backend (metaApi User.py). -const ( - clientStatusOffline = 0 - clientStatusOnline = 1 - clientStatusPending = 2 -) - -// clientStateLabel maps the backend status code to a TTY/CI-safe word. Plain -// text (not an emoji glyph) on purpose — flag/emoji glyphs mojibake in CI logs -// and Windows consoles (RFC-0001 §12 watch-item). -func clientStateLabel(status int) string { - switch status { - case clientStatusOnline: - return "online" - case clientStatusOffline: - return "offline" - case clientStatusPending: - return "pending" - default: - return "unknown" - } -} - // setActiveClient points this env's profile at c, caching its namespace and // display name alongside the id so the data commands can bind to the active // client's cluster (§7.3) without a backend round-trip. Callers Save() after. diff --git a/internal/cli/client_status.go b/internal/cli/client_status.go new file mode 100644 index 00000000..4f1d5547 --- /dev/null +++ b/internal/cli/client_status.go @@ -0,0 +1,241 @@ +// `tracebloc client status` — tracebloc's view of this machine's client +// (online / offline / pending, with --wait to poll), plus the --seal mode that +// verifies the environment's protections via the chart's conformance checks +// (seal.go). Split out of client.go, which sits at its file-budget ceiling. + +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/tracebloc/cli/internal/api" + "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/ui" +) + +func newClientStatusCmd() *cobra.Command { + var wait, seal bool + var timeout time.Duration + var kubeconfigPath, contextOverride, nsOverride string + cmd := &cobra.Command{ + Use: "status", + Short: "Show whether tracebloc can see this machine's client (online)", + Long: `Report tracebloc's view of this machine's active client — online, offline, +or pending. With --wait, poll until tracebloc reports it online (exit 0) or the +timeout elapses (non-zero), to confirm the client connected after setup. + +With --seal, verify the environment's protections instead: run the chart's +conformance checks (its helm tests — the seal check, RFC-0003) against this +machine's secure environment and report the verdict with per-check detail: + + sealed every conformance check passed + unsealed a check failed or couldn't run — a protection is not enforced + unknown the chart ships no conformance checks, so nothing was verified + +Only a fully-passed suite exits 0; an environment that can't be verified is +never claimed sealed. + +Exit codes with --seal: + 0 sealed — every conformance check passed + 2 unsealed (a check failed), or unknown (the chart has no checks) + 3 kubeconfig could not be loaded / cluster unreachable + 4 cluster reachable but no tracebloc client found there`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // --wait watches the backend's view; --seal interrogates the cluster. + // Two different questions — refuse the ambiguous combination. + if wait && seal { + return &exitError{code: exitFailure, err: errors.New("--wait and --seal are separate modes — run them one at a time")} + } + // --timeout governs the --wait poll and the per-check budget under + // --seal; accepting it alone would be a silent no-op, so reject it + // rather than mislead. Same for the cluster-targeting flags, which + // only the seal check (a cluster-side operation) consumes. + 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"} { + 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) + } + return runClientStatus(cmd.Context(), printerFor(cmd), wait, timeout) + }, + } + cmd.Flags().BoolVar(&wait, "wait", false, "poll until tracebloc reports this client online") + cmd.Flags().DurationVar(&timeout, "timeout", 120*time.Second, + "with --wait, give up after this long; with --seal, the time budget per check") + cmd.Flags().BoolVar(&seal, "seal", false, + "verify this environment's protections: run the chart's conformance checks and report sealed / unsealed") + addKubeconfigFlags(cmd, &kubeconfigPath, &contextOverride, + "with --seal: "+kubeconfigFlagUsage, + "with --seal: "+contextFlagUsage) + addNamespaceFlag(cmd, &nsOverride, "with --seal: "+namespaceFlagUsage) + return cmd +} + +// clientStatusPollInterval is how often --wait re-checks the backend. A const, +// not a seam: tests inject through pollAfter (which ignores the duration and +// fires instantly), so the value never needs overriding. +const clientStatusPollInterval = 3 * time.Second + +func runClientStatus(ctx context.Context, p *ui.Printer, wait bool, timeout time.Duration) error { + client, cfg, err := authedClient() + if err != nil { + return &exitError{code: exitFailure, err: err} + } + active := cfg.Current().ActiveClientID + if active == "" { + return &exitError{code: exitFailure, err: errors.New( + "no active client on this machine — run `tracebloc client create` (or re-run the installer) first")} + } + + // One-shot: report the current state and exit 0 (informational). + if !wait { + st, found, lerr := lookupClientStatus(ctx, client, active) + if lerr != nil { + return &exitError{code: exitFailure, err: lerr} + } + if !found { + return &exitError{code: exitFailure, err: fmt.Errorf( + "active client %s isn't in your account — run `tracebloc client create` "+ + "(or re-run the installer) to provision this machine", active)} + } + p.Section("Client status") + p.Field("state", clientStateLabel(st)) + return nil + } + + // --wait: poll the same source `client list` renders until online or timeout. + // The backend-reported status is the honest Online signal (RFC-0001 §8.5); a + // local rollout check can't tell whether tracebloc can actually see the client. + sp := p.Spinner("Waiting for tracebloc to confirm…", "") + defer sp.Stop() // leak-proof net for every return; the online path Stops explicitly before its ✔ + deadline := time.Now().Add(timeout) + var lastErr error // most recent transient (retryable) error, for the timeout message + lastState := -1 // most recent successfully-read status; -1 = none read yet + var ue *api.UpgradeRequiredError + var apiErr *api.APIError + for { + st, found, lerr := lookupClientStatus(ctx, client, active) + switch { + case errors.As(lerr, &ue): + // 426 (CLI too old) won't recover by waiting — surface the upgrade signal. + return &exitError{code: exitFailure, err: lerr} + case errors.As(lerr, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden): + // A revoked/expired/forbidden token (401/403) won't recover by waiting — + // the client itself may be online. Fail fast, point at sign-in. Note 429 + // and 5xx stay transient (below): those DO recover on retry. + return &exitError{code: exitFailure, err: errors.New( + "tracebloc rejected your credentials while waiting — run `tracebloc login`, then retry")} + case lerr != nil: + lastErr = lerr // transient (5xx / 429 / network) — keep waiting, remember why + case !found: + // The active client isn't in the account (deleted / wrong account) — no + // amount of waiting surfaces it. Fail fast, matching the one-shot path. + return &exitError{code: exitFailure, err: fmt.Errorf( + "active client %s isn't in your account — run `tracebloc client create` "+ + "(or re-run the installer) to provision this machine", active)} + case st == clientStatusOnline: + sp.Stop() + p.Successf("tracebloc can see this client.") + return nil + default: + // A good poll (present, not online yet) supersedes any earlier transient + // error, so a later timeout reports the real state, not a stale failure. + lastErr, lastState = nil, st + } + + // --timeout caps how long we WAIT: stop once the budget is spent, and clamp + // the sleep to what remains so total runtime doesn't overshoot by a poll + // cycle. A poll begun within budget is still honored (online → ✔ above). + remaining := time.Until(deadline) + if remaining <= 0 { + switch { + case lastErr != nil: + return &exitError{code: exitFailure, err: fmt.Errorf( + "timed out after %s waiting for tracebloc to report this client online; "+ + "the last status check failed: %v", timeout, lastErr)} + case lastState >= 0: + return &exitError{code: exitFailure, err: fmt.Errorf( + "timed out after %s waiting for tracebloc to report this client online (last state: %s). "+ + "Run `tracebloc doctor` to diagnose, or re-run the installer.", timeout, clientStateLabel(lastState))} + default: + return &exitError{code: exitFailure, err: fmt.Errorf( + "timed out after %s before tracebloc could confirm this client — retry, "+ + "or run `tracebloc doctor`.", timeout)} + } + } + wait := clientStatusPollInterval + if wait > remaining { + wait = remaining + } + select { + case <-ctx.Done(): + return &exitError{code: exitInterrupted} // Ctrl-C: exit quietly (no "Error: context canceled") + case <-pollAfter(wait): + } + } +} + +// lookupClientStatus fetches the active client directly and returns its backend +// status code. found=false means no such client (deleted, or signed into the +// wrong account). A lookup error is returned verbatim so --wait can treat it as +// transient and retry. Fetches the single client by id (GET /edge-device/{id}/) +// rather than listing the whole account — the home-screen heartbeat runs this +// under a ~1.2s budget, and paging every client blew it (cli#338). +func lookupClientStatus(ctx context.Context, client *api.Client, active string) (status int, found bool, err error) { + id, err := strconv.Atoi(active) + if err != nil { + // A non-numeric active id can never match a backend client, so report it + // as not-found — exactly what the old ListClients+match path did — rather + // than a permanent error. A --wait loop fail-fasts on a missing client but + // treats errors as transient, so returning an error here would make it + // poll a permanent parse failure to the timeout (Bugbot: poll/retry loops + // must fail-fast on non-transient errors). + return 0, false, nil + } + c, err := client.GetClient(ctx, id) + if err != nil { + return 0, false, err + } + if c == nil { + return 0, false, nil // 404 — no such client + } + return c.Status, true, nil +} + +// EdgeDevice.status codes mirrored from the backend (metaApi User.py). +const ( + clientStatusOffline = 0 + clientStatusOnline = 1 + clientStatusPending = 2 +) + +// clientStateLabel maps the backend status code to a TTY/CI-safe word. Plain +// text (not an emoji glyph) on purpose — flag/emoji glyphs mojibake in CI logs +// and Windows consoles (RFC-0001 §12 watch-item). +func clientStateLabel(status int) string { + switch status { + case clientStatusOnline: + return "online" + case clientStatusOffline: + return "offline" + case clientStatusPending: + return "pending" + default: + return "unknown" + } +} diff --git a/internal/cli/copy_catalog_test.go b/internal/cli/copy_catalog_test.go index 2433de55..9701bb5c 100644 --- a/internal/cli/copy_catalog_test.go +++ b/internal/cli/copy_catalog_test.go @@ -325,11 +325,29 @@ func TestCopyCatalog(t *testing.T) { ) // ── 08 client ────────────────────────────────────────────────────────────────── + // The seal check (client status --seal) renders through the pure + // renderSealResult, so all three verdicts are pinned as stable screens: the + // live run streams the same header/check/verdict pieces (plus a per-check + // spinner line, catalogued in the backstop). + sealSealed := sealModel{envName: "lukas-macbook", checks: []sealCheck{ + {name: "egress-enforcement", passed: true}, + {name: "backend-reachability", passed: true}, + }} + sealUnsealed := sealModel{envName: "lukas-macbook", checks: []sealCheck{ + {name: "egress-enforcement", passed: false, + detail: "job failed: BackoffLimitExceeded", + hint: "see why: kubectl logs -n lukas-macbook job/lukas-macbook-egress-enforcement-check"}, + {name: "backend-reachability", passed: true}, + }} + sealUnknown := sealModel{envName: "lukas-macbook"} clientFile := doc( "tb client — register / list / inspect environments", - "What you see under `tb client`. `tb client create` shows a review (below) before\nit registers a new secure environment. `tb client list` / `tb client status` read\nlive backend state, so they aren't stable screens — their strings are in\nzz-all-strings.golden.", + "What you see under `tb client`. `tb client create` shows a review (below) before\nit registers a new secure environment. `tb client status --seal` verifies the\nenvironment's protections by running the chart's conformance checks (the seal\ncheck) — all three verdicts are shown: sealed, unsealed (with the per-check\nfailure + fix hint), and unknown (a chart with no checks is honestly NOT called\nsealed). `tb client list` / plain `tb client status` read live backend state, so\nthey aren't stable screens — their strings are in zz-all-strings.golden.", []run{ {"tb client create # review, before you confirm", rndr(func(p *ui.Printer) { renderClientReview(p, "lukas-macbook", "lukas-macbook", "DE", "a1b2c3d4") })}, + {"tb client status --seal # sealed — every conformance check passed", rndr(func(p *ui.Printer) { renderSealResult(p, sealSealed) })}, + {"tb client status --seal # unsealed — a protection is not enforced", rndr(func(p *ui.Printer) { renderSealResult(p, sealUnsealed) })}, + {"tb client status --seal # unknown — this chart ships no conformance checks", rndr(func(p *ui.Printer) { renderSealResult(p, sealUnknown) })}, }, []run{ {"tracebloc client --help", help("client")}, @@ -410,6 +428,11 @@ func TestCopyCatalog(t *testing.T) { }, "02-data-list.golden": {"tracebloc data list --help"}, "05-doctor.golden": {"Connected to tracebloc", "Everything looks good"}, + "08-client.golden": { // the seal check's three verdicts (cli#393) + "Sealed — all 2 conformance checks passed", + "Unsealed — 1 of 2 conformance checks failed", + "Seal unknown — this chart ships no conformance checks", + }, } for name, needles := range mustRender { got := files[name] @@ -521,6 +544,10 @@ func harvestMessages(t *testing.T) []string { "PromptStep": true, "WarnLine": true, "CrossLine": true, "CheckLine": true, "Step": true, "Action": true, "Stat": true, "Field": true, "MenuRow": true, "Banner": true, "Command": true, + // Spinner — live-wait lines ("Waiting for tracebloc to confirm…", + // "Checking …") print as a static line on non-TTY runs, so their + // copy is user-facing too. + "Spinner": true, // prompter seam (survey) — question labels + help text for every guided // flow (ingest, client create, delete), incl. flows not driven as a screen. "Input": true, "Select": true, "Confirm": true, diff --git a/internal/cli/seal.go b/internal/cli/seal.go new file mode 100644 index 00000000..df9cacde --- /dev/null +++ b/internal/cli/seal.go @@ -0,0 +1,311 @@ +// The seal check — `tracebloc client status --seal` (cli#393, RFC-0003 §8.2 +// D12). Runs the chart's conformance checks (its helm-test hooks; see +// internal/helm/seal.go for the chart contract) against this machine's secure +// environment and reports an honest verdict: +// +// sealed — every conformance check passed (exit 0) +// unsealed — a check failed or couldn't run: a protection is not enforced (exit 2) +// unknown — the chart ships no conformance checks, so nothing was verified (exit 2) +// +// The honest-output principle (the same one delete's closing summary follows): +// never print a success state that wasn't verified. "Unknown" is explicitly NOT +// sealed — an environment that cannot demonstrate a guarantee is never claimed +// to hold it — and only a fully-passed suite exits 0, so scripts can gate on it. + +package cli + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/helm" + "github.com/tracebloc/cli/internal/ui" +) + +// Seams over the helm seal-check calls, so tests drive every verdict path +// without helm or a cluster — the same fn-var pattern as listDatasetsFn / +// resolveClusterTargetFn. +var ( + listTestHooksFn = helm.ListTestHooks + runHelmTestFn = helm.RunTest +) + +// sealCheck is one conformance check's outcome, ready to render. +type sealCheck struct { + name string // display name (chart's seal-check-name label, else the de-prefixed hook name) + passed bool + detail string // one-line failure detail ("" when passed) + hint string // one-line remediation pointer ("" when passed) +} + +// sealModel is everything renderSealResult needs — a pure value, so the copy +// catalog renders the sealed / unsealed / unknown screens byte-exact. +type sealModel struct { + envName string // the secure environment being verified + fallback bool // chart marks no seal-check suite; ran ALL of its helm tests instead + checks []sealCheck // empty = the chart ships no conformance checks (verdict: unknown) +} + +// sealed reports the verdict: true only when there were checks and every one +// passed. No checks at all is NOT sealed (it's unknown — nothing was verified). +func (m sealModel) sealed() bool { + if len(m.checks) == 0 { + return false + } + return m.failedCount() == 0 +} + +func (m sealModel) failedCount() int { + n := 0 + for _, c := range m.checks { + if !c.passed { + n++ + } + } + return n +} + +// runSealCheck drives the seal verdict: resolve the cluster + release the same +// 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 { + binding := bindActiveClientNamespace(&opts) + target, err := resolveClusterTargetFn(ctx, p, opts, binding, false) + if err != nil { + return binding.explain(err) + } + tt := helm.TestTarget{ + Release: target.Release.ReleaseName, + Namespace: target.Resolved.Namespace, + Kubeconfig: opts.Path, + // The RESOLVED context, not the raw --context flag: with the flag + // omitted the raw value is empty and helm would fall back to its own + // ambient resolution (including $HELM_KUBECONTEXT), which can point at + // a different cluster than the one discovery just used. Same pinning + // `resources set` does (Bugbot). + KubeContext: target.Resolved.Context, + } + + hooks, herr := listTestHooksFn(ctx, tt) + if herr != nil { + // Ctrl-C while enumerating is a cancelled run, not an enumeration + // failure — exit quietly, matching the per-check loop below (Bugbot). + if ctx.Err() != nil { + return &exitError{code: exitInterrupted} + } + // Can't even enumerate the checks — that's an error, not a verdict: + // printing "unsealed" (or worse, "unknown") off a helm failure would + // misstate what we know about the environment. + return &exitError{code: exitFailure, err: fmt.Errorf( + "couldn't read the chart's conformance checks: %w", herr)} + } + suite, aux, fallback := sealSuite(hooks) + model := sealModel{envName: sealEnvName(target, binding), fallback: fallback} + + if len(suite) == 0 { + renderSealResult(p, model) // header + the unknown verdict + return &exitError{code: exitChecksFailed, err: nil} // rendered above — exit silent + } + + renderSealHeader(p, model) + for _, h := range suite { + name := sealCheckName(h, tt.Release) + sp := p.Spinner(fmt.Sprintf("Checking %s…", name), "") + // The filter carries the check plus the aux plumbing hooks (see + // helm.RunTest) — but never the other checks, so this check's verdict + // stays its own. + out, terr := runHelmTestFn(ctx, tt, append([]string{h.Name}, aux...), timeout) + sp.Stop() + // Ctrl-C (or a parent deadline) mid-suite is a cancelled run, not a + // verdict: every remaining check would "fail" on the dead context and + // render a fake Unsealed. Exit quietly, the way `status --wait` does. + if ctx.Err() != nil { + return &exitError{code: exitInterrupted} + } + check := sealCheck{name: name, passed: terr == nil} + if terr != nil { + check.detail = helmFailureDetail(out, terr) + check.hint = sealFailureHint(h, tt.Namespace) + } + renderSealCheckLine(p, check) + model.checks = append(model.checks, check) + } + + renderSealVerdict(p, model) + if !model.sealed() { + return &exitError{code: exitChecksFailed, err: nil} // rendered above — exit silent + } + return nil +} + +// sealSuite splits the release's test hooks into the checks to run and the +// aux plumbing to carry along on every run: +// +// - suite: the RUNNABLE hooks (Jobs/Pods) the chart labels as the seal-check +// suite; when the chart labels none (an older chart), ALL of its runnable +// helm tests, reported as a fallback so the output says which contract ran. +// - aux: the non-runnable test hooks (a check's ServiceAccount/RBAC, created +// at negative hook-weight). Never checks themselves — an SA can't "pass" — +// but helm's --filter would exclude them from a per-check run and strand +// the check without its plumbing, so every RunTest lists them too. +func sealSuite(hooks []helm.TestHook) (suite []helm.TestHook, aux []string, fallback bool) { + var runnable []helm.TestHook + for _, h := range hooks { + if h.Runnable() { + runnable = append(runnable, h) + } else { + aux = append(aux, h.Name) + } + } + for _, h := range runnable { + if h.SealCheck { + suite = append(suite, h) + } + } + if len(suite) > 0 { + return suite, aux, false + } + return runnable, aux, len(runnable) > 0 +} + +// sealEnvName names the environment under test: the active client's display +// name when the §7.3 binding chose the target, else the namespace actually +// resolved (explicit --namespace/--context, or the kubeconfig default). +func sealEnvName(target *clusterTarget, binding activeClientBinding) string { + if binding.applied && binding.name != "" { + return binding.name + } + return target.Resolved.Namespace +} + +// sealCheckName is a check's display name: the chart's seal-check-name label +// when present (the stable per-check identifier, e.g. "egress-enforcement"), +// else the hook name with the release prefix trimmed (the chart's hooks are +// named "-"). +func sealCheckName(h helm.TestHook, release string) string { + if h.SealName != "" { + return h.SealName + } + return strings.TrimPrefix(h.Name, release+"-") +} + +// sealFailureHint is the one-line remediation pointer under a failed check: +// the chart's seal-hint annotation when present, else the kubectl command that +// shows the check's own output (the probes print their diagnosis). +func sealFailureHint(h helm.TestHook, namespace string) string { + if h.SealHint != "" { + return h.SealHint + } + ref := h.Name + if strings.EqualFold(h.Kind, "Job") { + ref = "job/" + h.Name + } + return fmt.Sprintf("see why: kubectl logs -n %s %s", namespace, ref) +} + +// helmFailureDetail distills helm's combined output + error into the one-line +// reason a check failed. helm reports hook failures as a bullet list under an +// "Error:" line ("* job failed: BackoffLimitExceeded", "* timed out waiting +// for the condition"); prefer the first bullet (the specific reason), then the +// Error: line, then the raw error's first line — never empty for a failure. +func helmFailureDetail(out string, err error) string { + if err == nil { + return "" + } + var errLine, bullet string + for _, line := range strings.Split(out, "\n") { + l := strings.TrimSpace(line) + switch { + case bullet == "" && strings.HasPrefix(l, "* "): + bullet = strings.TrimSpace(strings.TrimPrefix(l, "* ")) + case errLine == "" && strings.HasPrefix(l, "Error:"): + errLine = strings.TrimSpace(strings.TrimPrefix(l, "Error:")) + } + } + detail := bullet + if detail == "" { + detail = errLine + } + if detail == "" { + detail, _, _ = strings.Cut(err.Error(), "\n") + } + return sealTrimDetail(detail) +} + +// sealTrimDetail caps a failure detail to one readable line; the hint under it +// points at the full output. +func sealTrimDetail(s string) string { + const max = 140 + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} + +// ── rendering ──────────────────────────────────────────────────────────────── +// Split header / per-check line / verdict so the live path streams each check's +// result as it lands while the copy catalog renders the identical composed +// screen (renderSealResult). + +func renderSealHeader(p *ui.Printer, m sealModel) { + p.Section(fmt.Sprintf("Seal check — secure environment %q", m.envName)) + if m.fallback { + p.Infof("This chart doesn't mark a seal-check suite yet — running all of its checks instead.") + } + p.Newline() +} + +func renderSealCheckLine(p *ui.Printer, c sealCheck) { + if c.passed { + p.CheckLine("%s", c.name) + return + } + p.CrossLine("%s — %s", c.name, c.detail) + if c.hint != "" { + p.Hintf(" %s", c.hint) + } +} + +func renderSealVerdict(p *ui.Printer, m sealModel) { + if len(m.checks) == 0 { + // No conformance checks shipped — HONESTLY unknown. Never word this as + // sealed: nothing was verified. + p.Warnf("Seal unknown — this chart ships no conformance checks, so this environment's protections can't be verified. Not claiming sealed.") + p.Hintf("Upgrade your secure environment to a chart with the seal-check suite, then re-run `tracebloc client status --seal`.") + return + } + p.Newline() + // The default chart can ship a single conformance check (the enforcement + // probe only renders once the egress lockdown is enabled), so the singular + // wording is a common case, not an edge. + single := len(m.checks) == 1 + switch failed := m.failedCount(); { + case failed > 0 && single: + p.Errorf("Unsealed — the conformance check failed. This environment's protections are not enforced.") + case failed > 0: + p.Errorf("Unsealed — %d of %d conformance checks failed. This environment's protections are not all enforced.", failed, len(m.checks)) + case single: + p.Successf("Sealed — the conformance check passed. This environment's protections are enforced.") + default: + p.Successf("Sealed — all %d conformance checks passed. This environment's protections are enforced.", len(m.checks)) + } + if m.failedCount() > 0 { + p.Hintf("Fix the failing checks above, then re-run `tracebloc client status --seal` to confirm the seal.") + } +} + +// renderSealResult renders a complete seal screen from a model — what a full +// run prints, composed of the same pieces the live path streams. The copy +// catalog drives this for the sealed / unsealed / unknown states. +func renderSealResult(p *ui.Printer, m sealModel) { + renderSealHeader(p, m) + for _, c := range m.checks { + renderSealCheckLine(p, c) + } + renderSealVerdict(p, m) +} diff --git a/internal/cli/seal_test.go b/internal/cli/seal_test.go new file mode 100644 index 00000000..308dbed8 --- /dev/null +++ b/internal/cli/seal_test.go @@ -0,0 +1,448 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/helm" + "github.com/tracebloc/cli/internal/ui" +) + +// stubSealTarget fakes cluster resolution for the seal check (no kubeconfig, +// no apiserver): the release "acme" in namespace "acme" — the same fake-target +// pattern the data-delete tests use. The resolved context is deliberately +// distinct from any --context flag a test passes, so asserts can prove helm is +// pinned to the RESOLVED context, never the raw flag (Bugbot). +func stubSealTarget(t *testing.T) { + t.Helper() + orig := resolveClusterTargetFn + 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"}, + }, nil + } + t.Cleanup(func() { resolveClusterTargetFn = orig }) +} + +// stubSealHelm fakes the two helm seams: the hook listing and the per-check +// run. The run fake keys the scripted outcome on the CHECK hook (names[0]) +// and records every invocation's full filter list for aux-plumbing asserts. +type stubSealHelm struct { + hooks []helm.TestHook + listErr error + listGot helm.TestTarget + runs []string // names[0] of each run — the check under test + filters [][]string // the full names list of each run (check + aux) + runGot helm.TestTarget + runOut map[string]string + runErr map[string]error + timeouts []time.Duration +} + +func (s *stubSealHelm) install(t *testing.T) { + t.Helper() + origList, origRun := listTestHooksFn, runHelmTestFn + listTestHooksFn = func(_ context.Context, tt helm.TestTarget) ([]helm.TestHook, error) { + s.listGot = tt + return s.hooks, s.listErr + } + runHelmTestFn = func(_ context.Context, tt helm.TestTarget, names []string, timeout time.Duration) (string, error) { + s.runGot = tt + s.runs = append(s.runs, names[0]) + s.filters = append(s.filters, names) + s.timeouts = append(s.timeouts, timeout) + return s.runOut[names[0]], s.runErr[names[0]] + } + t.Cleanup(func() { listTestHooksFn, runHelmTestFn = origList, origRun }) +} + +// sealHooks is the chart's suite as docs/SEAL-CHECK.md labels it: conformance +// Jobs carrying tracebloc.io/seal-check=true + the seal-check-name identifier +// (one also carrying the CLI's optional hint annotation). +func sealHooks() []helm.TestHook { + return []helm.TestHook{ + {Kind: "Job", Name: "acme-egress-enforcement-check", SealCheck: true, SealName: "egress-enforcement", + SealHint: "ensure the CNI enforces egress NetworkPolicy, then re-run"}, + {Kind: "Job", Name: "acme-egress-reachability-check", SealCheck: true, SealName: "backend-reachability"}, + } +} + +func runSeal(t *testing.T, timeout time.Duration) (string, error) { + t.Helper() + var out bytes.Buffer + err := runSealCheck(context.Background(), ui.New(&out), cluster.KubeconfigOptions{ + Path: "/tmp/kc", Context: "kind-acme", + }, timeout) + return out.String(), err +} + +// Every check passes → Sealed, exit 0, per-check ✓ lines, and helm was pointed +// at the resolved release/namespace/kubeconfig — never the ambient context. +func TestSeal_AllPass_Sealed(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{hooks: sealHooks()} + s.install(t) + + out, err := runSeal(t, 90*time.Second) + if err != nil { + t.Fatalf("sealed run must exit 0, got: %v", err) + } + for _, want := range []string{ + `Seal check — secure environment "acme"`, + "✓ egress-enforcement", + "✓ backend-reachability", + "Sealed — all 2 conformance checks passed", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if len(s.runs) != 2 || s.runs[0] != "acme-egress-enforcement-check" || s.runs[1] != "acme-egress-reachability-check" { + t.Errorf("helm test runs = %v", s.runs) + } + // KubeContext is the RESOLVED context — never the raw --context flag + // ("kind-acme" here), which would be empty when omitted and let helm fall + // back to its own ambient resolution ($HELM_KUBECONTEXT included). + wantTT := helm.TestTarget{Release: "acme", Namespace: "acme", Kubeconfig: "/tmp/kc", KubeContext: "resolved-ctx"} + if s.listGot != wantTT || s.runGot != wantTT { + t.Errorf("helm targets = list %+v run %+v, want %+v", s.listGot, s.runGot, wantTT) + } + if s.timeouts[0] != 90*time.Second { + t.Errorf("per-check timeout = %v, want 90s", s.timeouts[0]) + } +} + +// One check fails → Unsealed (exit 2, silent — the verdict was already +// rendered), the OTHER check still runs (no first-failure abort), the failure +// line carries helm's distilled reason, and the hint is the chart's seal-hint. +func TestSeal_OneFails_Unsealed_AllChecksStillRun(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{ + hooks: sealHooks(), + runOut: map[string]string{ + "acme-egress-enforcement-check": "NAME: acme\nError: 1 error occurred:\n\t* job failed: BackoffLimitExceeded\n", + }, + runErr: map[string]error{"acme-egress-enforcement-check": errors.New("exit status 1")}, + } + s.install(t) + + out, err := runSeal(t, 0) + if got := ExitCodeFromError(err); got != exitChecksFailed { + t.Fatalf("exit code = %d, want %d", got, exitChecksFailed) + } + if !IsSilentError(err) { + t.Fatalf("verdict already rendered — the exit error must be silent, got: %v", err) + } + for _, want := range []string{ + "✗ egress-enforcement — job failed: BackoffLimitExceeded", + "ensure the CNI enforces egress NetworkPolicy, then re-run", + "✓ backend-reachability", // the suite kept going past the failure + "Unsealed — 1 of 2 conformance checks failed", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "Sealed — all") { + t.Errorf("a failed suite must never print the sealed verdict:\n%s", out) + } + if len(s.runs) != 2 { + t.Errorf("both checks must run despite the first failing, ran: %v", s.runs) + } +} + +// A failing check with no chart hint falls back to the kubectl-logs pointer +// (job/ for a Job hook), so the failure is still actionable. +func TestSeal_FailureHintFallsBackToKubectlLogs(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{ + hooks: []helm.TestHook{{Kind: "Job", Name: "acme-egress-reachability-check", SealCheck: true}}, + runErr: map[string]error{"acme-egress-reachability-check": errors.New("exit status 1")}, + } + s.install(t) + + out, _ := runSeal(t, 0) + if want := "see why: kubectl logs -n acme job/acme-egress-reachability-check"; !strings.Contains(out, want) { + t.Errorf("output missing the kubectl-logs hint %q:\n%s", want, out) + } +} + +// No runnable checks → honestly UNKNOWN: exit 2 (a script must not read +// "couldn't verify" as sealed), the word "sealed" only in the disclaimer. +// Both shapes: a chart with no test hooks at all, and one whose only test +// hooks are non-runnable plumbing (an SA alone verifies nothing). +func TestSeal_NoRunnableChecks_Unknown(t *testing.T) { + for name, hooks := range map[string][]helm.TestHook{ + "no hooks": nil, + "plumbing only": {{Kind: "ServiceAccount", Name: "acme-storage-assertions-sa"}}, + } { + t.Run(name, func(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{hooks: hooks} + s.install(t) + + out, err := runSeal(t, 0) + if got := ExitCodeFromError(err); got != exitChecksFailed { + t.Fatalf("exit code = %d, want %d", got, exitChecksFailed) + } + if !IsSilentError(err) { + t.Fatalf("unknown verdict is rendered, not errored — got: %v", err) + } + if !strings.Contains(out, "Seal unknown — this chart ships no conformance checks") { + t.Errorf("output missing the unknown verdict:\n%s", out) + } + if strings.Contains(out, "Sealed — all") { + t.Errorf("an unverifiable environment must never be claimed sealed:\n%s", out) + } + if len(s.runs) != 0 { + t.Errorf("nothing to run, but helm test ran: %v", s.runs) + } + }) + } +} + +// An older chart with test hooks but no seal labels → run ALL of its tests and +// say so (the fallback note), rather than claiming unknown while checks exist. +func TestSeal_UnlabeledHooks_FallbackRunsAll(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{hooks: []helm.TestHook{ + {Kind: "Job", Name: "acme-egress-enforcement-check"}, + {Kind: "Job", Name: "acme-egress-reachability-check"}, + }} + s.install(t) + + out, err := runSeal(t, 0) + if err != nil { + t.Fatalf("passing fallback suite must exit 0, got: %v", err) + } + if !strings.Contains(out, "doesn't mark a seal-check suite yet — running all of its checks") { + t.Errorf("output missing the fallback note:\n%s", out) + } + if len(s.runs) != 2 { + t.Errorf("fallback must run every test hook, ran: %v", s.runs) + } +} + +// When the chart labels a seal suite, ONLY the labelled checks run — unlabeled +// helper tests aren't part of the conformance verdict — and no fallback note. +func TestSeal_LabeledSubsetOnly(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{hooks: []helm.TestHook{ + {Kind: "Job", Name: "acme-egress-enforcement-check", SealCheck: true}, + {Kind: "Job", Name: "acme-smoke-helper"}, + }} + s.install(t) + + out, err := runSeal(t, 0) + if err != nil { + t.Fatalf("want exit 0, got: %v", err) + } + if len(s.runs) != 1 || s.runs[0] != "acme-egress-enforcement-check" { + t.Errorf("only the labelled check should run, ran: %v", s.runs) + } + if strings.Contains(out, "running all of its checks") { + t.Errorf("fallback note must not print when the chart labels a suite:\n%s", out) + } + if !strings.Contains(out, "Sealed — the conformance check passed") { + t.Errorf("verdict must count only the seal suite (singular wording for one check):\n%s", out) + } +} + +// Non-runnable test hooks — the storage check's ServiceAccount/RBAC plumbing +// at negative hook-weight — are never checks (no line, no verdict weight), but +// they must ride along in EVERY per-check filter: helm's --filter excludes +// unlisted test hooks, and running a check without its plumbing strands it and +// would report a false Unsealed. +func TestSeal_AuxPlumbingCarriedNotCounted(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{hooks: append(sealHooks(), + helm.TestHook{Kind: "ServiceAccount", Name: "acme-storage-assertions-sa"}, + helm.TestHook{Kind: "Role", Name: "acme-storage-assertions-role"}, + )} + s.install(t) + + out, err := runSeal(t, 0) + if err != nil { + t.Fatalf("want exit 0, got: %v", err) + } + if !strings.Contains(out, "Sealed — all 2 conformance checks passed") { + t.Errorf("aux hooks must not count as checks:\n%s", out) + } + if len(s.filters) != 2 { + t.Fatalf("want 2 check runs, got %v", s.filters) + } + for i, f := range s.filters { + want := []string{s.runs[i], "acme-storage-assertions-sa", "acme-storage-assertions-role"} + if strings.Join(f, ",") != strings.Join(want, ",") { + t.Errorf("run %d filter = %v, want %v (check first, then the aux plumbing)", i, f, want) + } + } +} + +// Hook enumeration failing is an ERROR (exit 1 with the cause), never a +// verdict: we don't know the environment's state, so we say neither sealed, +// unsealed, nor unknown. +func TestSeal_HookListError_NoVerdict(t *testing.T) { + stubSealTarget(t) + s := &stubSealHelm{listErr: errors.New("helm get hooks acme: exit status 1\nKubernetes cluster unreachable")} + s.install(t) + + out, err := runSeal(t, 0) + if err == nil || !strings.Contains(err.Error(), "couldn't read the chart's conformance checks") { + t.Fatalf("want the enumeration failure surfaced, got: %v", err) + } + if got := ExitCodeFromError(err); got != exitFailure { + t.Errorf("exit code = %d, want %d", got, exitFailure) + } + for _, verdict := range []string{"Sealed", "Unsealed", "Seal unknown"} { + if strings.Contains(out, verdict) { + t.Errorf("no verdict may render when the checks couldn't be read; got %q in:\n%s", verdict, out) + } + } +} + +// A cluster-resolution failure propagates with its own exit contract (here the +// §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) { + return nil, &exitError{code: exitNoWorkspace, err: errors.New("no tracebloc client found in namespace \"acme\"")} + } + t.Cleanup(func() { resolveClusterTargetFn = orig }) + s := &stubSealHelm{} + s.install(t) + + _, err := runSeal(t, 0) + if got := ExitCodeFromError(err); got != exitNoWorkspace { + t.Fatalf("exit code = %d, want %d (%v)", got, exitNoWorkspace, err) + } +} + +// Ctrl-C while the hooks are being enumerated exits quietly (130), not as an +// enumeration failure — same quiet-exit contract as mid-suite below (Bugbot). +func TestSeal_CancelledDuringListing_QuietExit(t *testing.T) { + stubSealTarget(t) + ctx, cancel := context.WithCancel(context.Background()) + origList := listTestHooksFn + listTestHooksFn = func(_ context.Context, _ helm.TestTarget) ([]helm.TestHook, error) { + cancel() // the user hits Ctrl-C while `helm get hooks` runs + return nil, errors.New("context canceled") + } + t.Cleanup(func() { listTestHooksFn = origList }) + + var out bytes.Buffer + err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0) + if got := ExitCodeFromError(err); got != exitInterrupted { + t.Fatalf("exit code = %d, want %d (%v)", got, exitInterrupted, err) + } + if !IsSilentError(err) { + t.Fatalf("Ctrl-C must exit quietly, got: %v", err) + } + if out.Len() != 0 { + t.Errorf("nothing may render on a cancelled enumeration, got:\n%s", out.String()) + } +} + +// Ctrl-C mid-suite exits quietly (130) with NO verdict: the remaining checks +// didn't fail — they never ran — and a fake Unsealed would misstate the +// environment. +func TestSeal_CancelledMidSuite_NoVerdict(t *testing.T) { + stubSealTarget(t) + ctx, cancel := context.WithCancel(context.Background()) + origList, origRun := listTestHooksFn, runHelmTestFn + listTestHooksFn = func(_ context.Context, _ helm.TestTarget) ([]helm.TestHook, error) { + return sealHooks(), nil + } + ran := 0 + runHelmTestFn = func(_ context.Context, _ helm.TestTarget, _ []string, _ time.Duration) (string, error) { + ran++ + cancel() // the user hits Ctrl-C while the first check runs + return "", errors.New("context canceled") + } + t.Cleanup(func() { listTestHooksFn, runHelmTestFn = origList, origRun }) + + var out bytes.Buffer + err := runSealCheck(ctx, ui.New(&out), cluster.KubeconfigOptions{}, 0) + if got := ExitCodeFromError(err); got != exitInterrupted { + t.Fatalf("exit code = %d, want %d (%v)", got, exitInterrupted, err) + } + if !IsSilentError(err) { + t.Fatalf("Ctrl-C must exit quietly, got: %v", err) + } + if ran != 1 { + t.Errorf("no further checks may run after cancellation, ran %d", ran) + } + for _, verdict := range []string{"Sealed", "Unsealed", "Seal unknown"} { + if strings.Contains(out.String(), verdict) { + t.Errorf("no verdict may render on a cancelled run; got %q in:\n%s", verdict, out.String()) + } + } +} + +// helmFailureDetail distills helm's combined output into the one-line reason: +// the specific hook bullet first, then the Error: line, then the raw error — +// and never an empty string for a failure. +func TestHelmFailureDetail(t *testing.T) { + cases := []struct { + name, out string + err error + want string + }{ + {"bullet wins", "Error: 1 error occurred:\n\t* job failed: BackoffLimitExceeded\n", errors.New("exit status 1"), "job failed: BackoffLimitExceeded"}, + {"error line when no bullet", "Error: timed out waiting for the condition\n", errors.New("exit status 1"), "timed out waiting for the condition"}, + {"raw error fallback", "", errors.New("context deadline exceeded\nmore"), "context deadline exceeded"}, + {"nil error is not a failure", "whatever", nil, ""}, + } + for _, c := range cases { + if got := helmFailureDetail(c.out, c.err); got != c.want { + t.Errorf("%s: helmFailureDetail = %q, want %q", c.name, got, c.want) + } + } + if got := helmFailureDetail(strings.Repeat("* x", 1)+strings.Repeat("y", 300), errors.New("x")); len([]rune(got)) > 140 { + t.Errorf("detail not capped to one readable line: %d runes", len([]rune(got))) + } +} + +// Cobra-level flag guards: the modes are mutually exclusive, and flags that +// only one mode consumes are rejected without it instead of silently ignored. +func TestSeal_FlagGuards(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + cases := []struct { + args []string + want string + }{ + {[]string{"client", "status", "--wait", "--seal"}, "--wait and --seal are separate modes"}, + {[]string{"client", "status", "--timeout", "5s"}, "--timeout has no effect without --wait or --seal"}, + {[]string{"client", "status", "--namespace", "acme"}, "--namespace has no effect without --seal"}, + {[]string{"client", "status", "--kubeconfig", "/tmp/kc"}, "--kubeconfig has no effect without --seal"}, + {[]string{"client", "status", "--context", "kind"}, "--context has no effect without --seal"}, + } + for _, c := range cases { + _, err := runCmd(t, c.args...) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%v: err = %v, want %q", c.args, err, c.want) + } + } +} + +// `client status --seal --timeout` is a valid pair (the per-check budget) and +// must reach the seal path, not the --timeout guard. +func TestSeal_TimeoutWithSealAccepted(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + stubSealTarget(t) + s := &stubSealHelm{hooks: sealHooks()} + s.install(t) + + if _, err := runCmd(t, "client", "status", "--seal", "--timeout", "45s"); err != nil { + t.Fatalf("--seal --timeout: %v", err) + } + if len(s.timeouts) == 0 || s.timeouts[0] != 45*time.Second { + t.Errorf("per-check timeouts = %v, want 45s", s.timeouts) + } +} diff --git a/internal/cli/testdata/golden/08-client.golden b/internal/cli/testdata/golden/08-client.golden index 6ac292bf..8a94809e 100644 --- a/internal/cli/testdata/golden/08-client.golden +++ b/internal/cli/testdata/golden/08-client.golden @@ -1,9 +1,12 @@ tb client — register / list / inspect environments ================================================== What you see under `tb client`. `tb client create` shows a review (below) before -it registers a new secure environment. `tb client list` / `tb client status` read -live backend state, so they aren't stable screens — their strings are in -zz-all-strings.golden. +it registers a new secure environment. `tb client status --seal` verifies the +environment's protections by running the chart's conformance checks (the seal +check) — all three verdicts are shown: sealed, unsealed (with the per-check +failure + fix hint), and unknown (a chart with no checks is honestly NOT called +sealed). `tb client list` / plain `tb client status` read live backend state, so +they aren't stable screens — their strings are in zz-all-strings.golden. $ tb client create # review, before you confirm @@ -13,6 +16,33 @@ $ tb client create # review, before you confirm location: DE cluster: a1b2c3d4 (anchors this client — re-runs adopt it) +$ tb client status --seal # sealed — every conformance check passed + + Seal check — secure environment "lukas-macbook" + + ✓ egress-enforcement + ✓ backend-reachability + + ✔ Sealed — all 2 conformance checks passed. This environment's protections are enforced. + +$ tb client status --seal # unsealed — a protection is not enforced + + Seal check — secure environment "lukas-macbook" + + ✗ egress-enforcement — job failed: BackoffLimitExceeded + see why: kubectl logs -n lukas-macbook job/lukas-macbook-egress-enforcement-check + ✓ backend-reachability + + ✖ Unsealed — 1 of 2 conformance checks failed. This environment's protections are not all enforced. + Fix the failing checks above, then re-run `tracebloc client status --seal` to confirm the seal. + +$ tb client status --seal # unknown — this chart ships no conformance checks + + Seal check — secure environment "lukas-macbook" + + ⚠ Seal unknown — this chart ships no conformance checks, so this environment's protections can't be verified. Not claiming sealed. + Upgrade your secure environment to a chart with the seal-check suite, then re-run `tracebloc client status --seal`. + ------------------------------------------------------------ --help @@ -78,13 +108,34 @@ Report tracebloc's view of this machine's active client — online, offline, or pending. With --wait, poll until tracebloc reports it online (exit 0) or the timeout elapses (non-zero), to confirm the client connected after setup. +With --seal, verify the environment's protections instead: run the chart's +conformance checks (its helm tests — the seal check, RFC-0003) against this +machine's secure environment and report the verdict with per-check detail: + + sealed every conformance check passed + unsealed a check failed or couldn't run — a protection is not enforced + unknown the chart ships no conformance checks, so nothing was verified + +Only a fully-passed suite exits 0; an environment that can't be verified is +never claimed sealed. + +Exit codes with --seal: + 0 sealed — every conformance check passed + 2 unsealed (a check failed), or unknown (the chart has no checks) + 3 kubeconfig could not be loaded / cluster unreachable + 4 cluster reachable but no tracebloc client found there + Usage: tracebloc client status [flags] Flags: - -h, --help help for status - --timeout duration with --wait, give up after this long (default 2m0s) - --wait poll until tracebloc reports this client online + --context string with --seal: name of the kubeconfig context to use (default: kubeconfig's current-context) + -h, --help help for status + --kubeconfig string with --seal: path to the kubeconfig file (default: $KUBECONFIG, then ~/.kube/config) + -n, --namespace string with --seal: namespace where your tracebloc client is installed + --seal verify this environment's protections: run the chart's conformance checks and report sealed / unsealed + --timeout duration with --wait, give up after this long; with --seal, the time budget per check (default 2m0s) + --wait poll until tracebloc reports this client online Global Flags: --plain disable color and decorative output (also honors $NO_COLOR) diff --git a/internal/cli/testdata/golden/zz-all-strings.golden b/internal/cli/testdata/golden/zz-all-strings.golden index 50841a51..2303300d 100644 --- a/internal/cli/testdata/golden/zz-all-strings.golden +++ b/internal/cli/testdata/golden/zz-all-strings.golden @@ -57,6 +57,7 @@ screen. %s/%d are runtime placeholders. "%s · running, but tracebloc hasn't heard from it — run %s" "%s · starting up, not ready yet — run %s" "%s ×%d" +"%s — %s" "%s, … and %d more" "%s/%s" "%s: %w" @@ -74,13 +75,15 @@ screen. %s/%d are runtime placeholders. ", %s=%s" "- %s, age %s%s" "-%02d" +"--%s has no effect without --seal" "--label-column doesn't apply to task %q — it trains on the text itself, with no label column" "--number-of-keypoints is keypoint_detection only; it doesn't apply to task %q" "--overwrite can't be combined with --idempotency-key: a reused key makes the cluster replay the previous run instead of ingesting the new data — after --overwrite's removal that would report success while loading nothing. Drop one of the two (a fresh per-run key is the default)." "--schema is empty; expected col:TYPE,col:TYPE,..." "--schema is tabular/time-series tasks only; it doesn't apply to task %q" "--time-column is time_to_event_prediction only; it doesn't apply to task %q" -"--timeout has no effect without --wait" +"--timeout has no effect without --wait or --seal" +"--wait and --seal are separate modes — run them one at a time" "0:%d" "3 GiB" "A dataset named %q already exists — replace it?" @@ -106,12 +109,14 @@ screen. %s/%d are runtime placeholders. "Check on it later with: kubectl logs -f -n %s job/%s" "Check your data" "Check your network / HTTP(S)_PROXY, then run `%s doctor` again." +"Checking %s…" "Client install" "Client status" "Clients in your account" "Cluster teardown reported: %v" "Column types" "Connected to tracebloc" +"Connecting to your secure environment to submit the run" "Connecting to your secure environment…" "Copy into your secure environment" "Copying %s" @@ -130,6 +135,8 @@ screen. %s/%d are runtime placeholders. "Couldn't verify your session with the backend (%v)." "Couldn't write the support bundle: %v" "Credential written to %s (mode 0600, not shown). This machine is set to enroll as client %d." +"Ctrl-C to cancel" +"Ctrl-C to stop watching — the run keeps going on the cluster" "DB failures" "DROP TABLE IF EXISTS `%s`.`%s`" "Datasets in %s (0)" @@ -154,6 +161,7 @@ screen. %s/%d are runtime placeholders. "Ensure your kubeconfig user can list nodes." "Enter" "Everything looks good — you're ready to run training." +"Fix the failing checks above, then re-run `tracebloc client status --seal` to confirm the seal." "Follow it later with: kubectl logs -f -n %s job/%s" "For help: https://docs.tracebloc.io/create-use-case/prepare-dataset" "Found labels.csv and a %s folder — this looks like text data." @@ -227,6 +235,7 @@ screen. %s/%d are runtime placeholders. "Provision this client?" "Provisioned client %q (namespace %s)." "Provisioning didn't complete. Re-running is safe — on the same cluster it adopts the existing client instead of minting a duplicate (idempotent):" +"Reading your files" "Reading your files locally first — nothing has touched your secure environment yet — so a layout or settings problem shows up right away." "Ready for `tracebloc data ingest`." "Ready to run training" @@ -234,6 +243,7 @@ screen. %s/%d are runtime placeholders. "Ready to run training — couldn't check free compute (run with --verbose)" "Ready to run training — couldn't check your workloads (run with --verbose)" "Reclaimed tracebloc's downloaded images." +"Reclaiming the temporary copy" "Recreate it as a docker-registry secret (kubectl create secret docker-registry)." "Recreate the registry secret; its .dockerconfigjson isn't valid JSON." "Regression targets are continuous. 'bucket' groups them into ranges before they leave the cluster; 'passthrough' keeps raw values." @@ -250,6 +260,10 @@ screen. %s/%d are runtime placeholders. "Run '%s --help' for the available commands." "SELECT '%s',%s,COUNT(*),%s,%s FROM `%s`.`%s`" "SELECT table_name FROM information_schema.tables WHERE table_schema='%s' ORDER BY table_name" +"Seal check — secure environment %q" +"Seal unknown — this chart ships no conformance checks, so this environment's protections can't be verified. Not claiming sealed." +"Sealed — all %d conformance checks passed. This environment's protections are enforced." +"Sealed — the conformance check passed. This environment's protections are enforced." "Secure environment %q" "Set one up: %s" "Sign in to tracebloc" @@ -267,6 +281,7 @@ screen. %s/%d are runtime placeholders. "Stopped following after 1 hour — the ingestion is still running and will finish on its own." "Stopped watching — the ingestion keeps running on your secure environment." "Submitted — tracebloc is validating your data and loading it into the table." +"Submitting the run" "Submitting the run — with --detach it keeps running on your secure environment after this command returns; the reconnect command is shown below." "Submitting the run, then following along as tracebloc validates your data and loads it into the table — progress streams below." "System · %d" @@ -282,6 +297,7 @@ screen. %s/%d are runtime placeholders. "The size your images already are, as WxH — tracebloc checks every image matches and never resizes. Press Enter to read it from your first image. e.g. 224x224" "The tracebloc CLI (your local data & config are kept — --keep-data)" "This CLI is out of date — update it: %s" +"This chart doesn't mark a seal-check suite yet — running all of its checks instead." "This cluster is already registered as client %q (namespace %s) — adopted it." "This drops the table and removes the files listed above — there's no undo. Pass --yes next time to skip this prompt." "This follows the run for up to an hour; a longer run keeps going on its own (or start it with --detach and check back later)." @@ -298,11 +314,17 @@ screen. %s/%d are runtime placeholders. "Try again shortly; if it persists, email support@tracebloc.io with `%s doctor --diagnose`." "Type %q to offboard this machine" "Uninstalled tracebloc." +"Unsealed — %d of %d conformance checks failed. This environment's protections are not all enforced." +"Unsealed — the conformance check failed. This environment's protections are not enforced." "Updating tracebloc — re-running the installer (verifies signatures, then updates the CLI and your secure environment)." +"Upgrade your secure environment to a chart with the seal-check suite, then re-run `tracebloc client status --seal`." "Use the GPU for training runs?" "VARCHAR(%d)" "Validate and load" "Verify the requests-proxy is wired: kubectl set env deploy/-jobs-manager --list | grep PROXY" +"Waiting for the ingestion to start (scheduling + pulling the image)" +"Waiting for tracebloc to confirm…" +"Waiting for your browser…" "We couldn't tell from the layout — tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/." "We infer each column's type from your CSV. Press Enter to accept, or type overrides like age:INT,price:FLOAT." "Welcome to your secure environment for AI, %s 👋" @@ -361,6 +383,7 @@ screen. %s/%d are runtime placeholders. "couldn't read RESOURCE_REQUESTS from jobs-manager — skipping node-fit" "couldn't read capacity: %v" "couldn't read jobs-manager to resolve image pull secrets — skipping" +"couldn't read the chart's conformance checks: %w" "couldn't read this machine's capacity: %w" "cpu=%s, memory=%s" "crash-looping: %v" @@ -531,6 +554,7 @@ screen. %s/%d are runtime placeholders. "schema entry %q must be col:TYPE (e.g. age:INT,price:FLOAT)" "secret %q has an empty or malformed %s" "secret %q is type %q, not %s" +"see why: kubectl logs -n %s %s" "sent to API" "server" "service %s/%s has no selector — can't resolve to a Pod for port-forwarding" diff --git a/internal/helm/seal.go b/internal/helm/seal.go new file mode 100644 index 00000000..9bca444c --- /dev/null +++ b/internal/helm/seal.go @@ -0,0 +1,190 @@ +// Seal-check plumbing — the helm side of `tracebloc client status --seal` +// (cli#393, RFC-0003 §8.2 D12). +// +// THE CONTRACT WITH THE CHART (client repo docs/SEAL-CHECK.md, backend#1184). +// The chart's conformance checks are ordinary `helm test` hook Jobs — +// egress-enforcement, backend-reachability, storage-assertions — each +// carrying the SealCheckLabel membership marker and the SealNameLabel +// per-check identifier; the machine contract is labels + Job exit status. +// Everything degrades gracefully against an older chart: no labelled hooks → +// the CLI falls back to ALL of the chart's runnable helm tests; no test hooks +// at all → the CLI reports the seal as unverifiable (never silently sealed — +// the chart's own design stance). +// +// Discovery reads `helm get hooks` (the release's stored hook manifests), so +// the names fed to `helm test --filter name=…` come from the same release +// store the test action reads — they can't drift apart. +// +// (The package comment lives in upgrade.go.) + +package helm + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// The label keys are the chart's enumeration contract (client repo +// docs/SEAL-CHECK.md): both are public API on every runnable check Job. The +// hint annotation is a CLI-side optional extension — labels can't carry +// free-form sentences — that the chart can adopt per check; absent, the CLI +// falls back to a kubectl-logs pointer. +const ( + // SealCheckLabel marks a helm-test hook as part of the chart's seal-check + // (conformance) suite: `tracebloc.io/seal-check: "true"`. + SealCheckLabel = "tracebloc.io/seal-check" + // SealNameLabel carries the stable per-check identifier + // (`tracebloc.io/seal-check-name`, e.g. "egress-enforcement"). + SealNameLabel = "tracebloc.io/seal-check-name" + // SealHintAnnotation optionally carries a one-line remediation hint the CLI + // shows when that check fails. + SealHintAnnotation = "tracebloc.io/seal-hint" +) + +// TestTarget identifies the release the helm-test commands act on. Kubeconfig +// and KubeContext pin helm to the SAME cluster the CLI resolved — never the +// ambient current-context — mirroring nodeboot.UninstallChart; both are +// appended only when non-empty. +type TestTarget struct { + Release string // helm release name (from cluster discovery) + Namespace string // release namespace + Kubeconfig string // kubeconfig path (empty = ambient $KUBECONFIG/~/.kube/config) + KubeContext string // kubeconfig context (empty = current-context) +} + +// kubeFlags renders the optional cluster-pinning flags helm accepts on every +// subcommand this package drives. +func (t TestTarget) kubeFlags() []string { + var f []string + if t.Kubeconfig != "" { + f = append(f, "--kubeconfig", t.Kubeconfig) + } + if t.KubeContext != "" { + f = append(f, "--kube-context", t.KubeContext) + } + return f +} + +// TestHook describes one helm-test hook rendered in the release's chart. +type TestHook struct { + Kind string // manifest kind (Job / Pod / a check's SA-RBAC plumbing) + Name string // hook resource name; what `helm test --filter name=…` matches + SealCheck bool // labelled tracebloc.io/seal-check="true" (the consolidated suite) + SealName string // tracebloc.io/seal-check-name label ("" when absent) + SealHint string // tracebloc.io/seal-hint annotation ("" when absent) +} + +// Runnable reports whether the hook is a check that executes and completes — +// a Job or a bare Pod, the kinds `helm test` waits on. Other test-event hooks +// (a check's ServiceAccount/Role plumbing, created at negative hook-weight) +// are applied but never "pass", so they are not checks; per the chart +// contract only runnable checks carry the seal labels. +func (h TestHook) Runnable() bool { + return strings.EqualFold(h.Kind, "Job") || strings.EqualFold(h.Kind, "Pod") +} + +// ListTestHooks enumerates the release's helm-test hooks from its stored hook +// manifests (`helm get hooks`). Only hooks whose `helm.sh/hook` annotation +// declares the test event are returned; install/upgrade/delete hooks are not +// conformance checks. A release with no hooks yields an empty slice, nil error. +func ListTestHooks(ctx context.Context, t TestTarget) ([]TestHook, error) { + args := append([]string{"get", "hooks", t.Release, "--namespace", t.Namespace}, t.kubeFlags()...) + out, err := Runner(ctx, "helm", args...) + if err != nil { + // Wrap with helm's own output (e.g. "release: not found", cluster + // unreachable) — that text is the actionable part, not the exit status. + return nil, fmt.Errorf("helm get hooks %s: %w\n%s", t.Release, err, strings.TrimSpace(out)) + } + return parseTestHooks(out) +} + +// hookManifest is the minimal slice of a rendered hook manifest the seal check +// reads. Everything else in the document is ignored. +type hookManifest struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + Labels map[string]string `yaml:"labels"` + Annotations map[string]string `yaml:"annotations"` + } `yaml:"metadata"` +} + +// parseTestHooks decodes the multi-document YAML stream `helm get hooks` +// prints and keeps the test hooks. A document that fails to decode is a hard +// error, not a skip: a manifest we can't parse could be a conformance check, +// and silently dropping it from the verdict would overstate the seal. +func parseTestHooks(manifests string) ([]TestHook, error) { + dec := yaml.NewDecoder(strings.NewReader(manifests)) + var hooks []TestHook + for { + var m hookManifest + err := dec.Decode(&m) + if errors.Is(err, io.EOF) { + return hooks, nil + } + if err != nil { + return nil, fmt.Errorf("parsing the release's hook manifests: %w", err) + } + // Empty documents (comment-only separators) decode to a zero value. + if m.Metadata.Name == "" || !isTestHook(m.Metadata.Annotations["helm.sh/hook"]) { + continue + } + hooks = append(hooks, TestHook{ + Kind: m.Kind, + Name: m.Metadata.Name, + SealCheck: m.Metadata.Labels[SealCheckLabel] == "true", + SealName: m.Metadata.Labels[SealNameLabel], + SealHint: m.Metadata.Annotations[SealHintAnnotation], + }) + } +} + +// isTestHook reports whether a `helm.sh/hook` annotation value declares the +// test event. The value is a comma-separated event list; "test-success" is the +// legacy helm-2 spelling helm 3 still runs as a test. +func isTestHook(annotation string) bool { + for _, event := range strings.Split(annotation, ",") { + switch strings.TrimSpace(event) { + case "test", "test-success": + return true + } + } + return false +} + +// RunTest runs ONE of the release's checks (`helm test --filter +// name=a,name=b`) and returns helm's combined output plus its raw error. The +// caller derives the per-check verdict from the error (exit code is the +// pass/fail contract) and the failure detail from the output — so neither is +// wrapped or trimmed here. +// +// names is the check hook PLUS the release's non-runnable test hooks (the +// SA/RBAC plumbing a check may depend on, created at negative hook-weight). +// helm's --filter excludes every unlisted test hook from the run — plumbing +// included — so filtering to the check alone would strand e.g. the +// storage-assertions Job without its ServiceAccount and report a false +// failure. Non-runnable hooks are applied instantly (helm only waits on +// Jobs/Pods), so carrying them costs nothing. +// +// One invocation per check, not one `helm test` for the whole suite, is +// deliberate: helm stops a suite at the first failure, and the seal check must +// report EVERY check's state (the partially-degraded picture), not just the +// first break. +func RunTest(ctx context.Context, t TestTarget, names []string, timeout time.Duration) (string, error) { + filters := make([]string, len(names)) + for i, n := range names { + filters[i] = "name=" + n + } + args := []string{"test", t.Release, "--namespace", t.Namespace, "--filter", strings.Join(filters, ",")} + if timeout > 0 { + args = append(args, "--timeout", timeout.String()) + } + args = append(args, t.kubeFlags()...) + return Runner(ctx, "helm", args...) +} diff --git a/internal/helm/seal_test.go b/internal/helm/seal_test.go new file mode 100644 index 00000000..e65b09ca --- /dev/null +++ b/internal/helm/seal_test.go @@ -0,0 +1,210 @@ +package helm + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// sealRunner swaps Runner for a scripted fake and records every invocation, so +// the seal-check plumbing is exercised without helm or a cluster — the same +// double upgrade_test.go uses. +type sealRunner struct { + calls [][]string + out string + err error +} + +func (f *sealRunner) install(t *testing.T) { + t.Helper() + orig := Runner + Runner = func(_ context.Context, name string, args ...string) (string, error) { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.out, f.err + } + t.Cleanup(func() { Runner = orig }) +} + +// hooksYAML mirrors what `helm get hooks` prints for the client chart +// (docs/SEAL-CHECK.md contract): two conformance Jobs carrying the seal +// labels (one also with the CLI's optional hint annotation), a non-runnable +// aux test hook (the storage check's ServiceAccount, negative hook-weight), a +// non-test hook that must be filtered out, and a legacy `test-success` Pod +// that must still count as a test. +const hooksYAML = `--- +# Source: client/templates/egress-enforcement-check.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: acme-egress-enforcement-check + namespace: acme + labels: + app.kubernetes.io/instance: acme + tracebloc.io/seal-check: "true" + tracebloc.io/seal-check-name: egress-enforcement + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded + tracebloc.io/seal-hint: ensure the CNI enforces egress NetworkPolicy, then re-run +spec: + backoffLimit: 0 +--- +# Source: client/templates/egress-reachability-check.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: acme-egress-reachability-check + namespace: acme + labels: + app.kubernetes.io/instance: acme + tracebloc.io/seal-check: "true" + tracebloc.io/seal-check-name: backend-reachability + annotations: + "helm.sh/hook": test +spec: + backoffLimit: 0 +--- +# Source: client/templates/storage-assertions-check.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: acme-storage-assertions-check + annotations: + "helm.sh/hook": test + "helm.sh/hook-weight": "-6" +--- +# Source: client/templates/some-migration.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: acme-pre-upgrade-migrate + annotations: + "helm.sh/hook": pre-upgrade +--- +apiVersion: v1 +kind: Pod +metadata: + name: acme-legacy-probe + annotations: + "helm.sh/hook": "test-success, something-else" +` + +func TestListTestHooks_ParsesAndFilters(t *testing.T) { + f := &sealRunner{out: hooksYAML} + f.install(t) + + hooks, err := ListTestHooks(context.Background(), TestTarget{ + Release: "acme", Namespace: "acme", + Kubeconfig: "/tmp/kc", KubeContext: "kind-acme", + }) + if err != nil { + t.Fatalf("ListTestHooks: %v", err) + } + + // argv: get hooks on the right release/namespace, pinned to the resolved + // cluster — never the ambient context. + want := []string{"helm", "get", "hooks", "acme", "--namespace", "acme", + "--kubeconfig", "/tmp/kc", "--kube-context", "kind-acme"} + if len(f.calls) != 1 || strings.Join(f.calls[0], " ") != strings.Join(want, " ") { + t.Fatalf("helm argv = %v, want %v", f.calls, want) + } + + if len(hooks) != 4 { + t.Fatalf("got %d hooks (%+v), want 4 (the pre-upgrade hook filtered out)", len(hooks), hooks) + } + enf := hooks[0] + if enf.Name != "acme-egress-enforcement-check" || enf.Kind != "Job" || !enf.SealCheck || !enf.Runnable() { + t.Errorf("enforcement hook parsed wrong: %+v", enf) + } + if enf.SealName != "egress-enforcement" || + enf.SealHint != "ensure the CNI enforces egress NetworkPolicy, then re-run" { + t.Errorf("seal name label / hint annotation parsed wrong: %+v", enf) + } + reach := hooks[1] + if reach.SealName != "backend-reachability" || !reach.SealCheck || reach.SealHint != "" { + t.Errorf("reachability hook parsed wrong: %+v", reach) + } + sa := hooks[2] + if sa.Kind != "ServiceAccount" || sa.SealCheck || sa.Runnable() { + t.Errorf("aux ServiceAccount hook parsed wrong (must be non-runnable, unlabelled): %+v", sa) + } + legacy := hooks[3] + if legacy.Name != "acme-legacy-probe" || legacy.Kind != "Pod" || !legacy.Runnable() { + t.Errorf("legacy test-success hook parsed wrong: %+v", legacy) + } +} + +// A release with no hooks prints nothing — that's an empty suite, not an error +// (the CLI turns it into the honest "unknown" verdict). +func TestListTestHooks_NoHooks(t *testing.T) { + f := &sealRunner{out: ""} + f.install(t) + hooks, err := ListTestHooks(context.Background(), TestTarget{Release: "acme", Namespace: "acme"}) + if err != nil || len(hooks) != 0 { + t.Fatalf("got %v, %v — want empty, nil", hooks, err) + } +} + +// A helm failure surfaces helm's own output (the actionable part), wrapped. +func TestListTestHooks_HelmError(t *testing.T) { + f := &sealRunner{out: "Error: release: not found\n", err: errors.New("exit status 1")} + f.install(t) + _, err := ListTestHooks(context.Background(), TestTarget{Release: "ghost", Namespace: "ghost"}) + if err == nil || !strings.Contains(err.Error(), "release: not found") { + t.Fatalf("want the helm output in the error, got: %v", err) + } +} + +// A manifest that doesn't parse must fail closed — dropping it could silently +// remove a conformance check from the verdict. +func TestListTestHooks_BadYAMLFailsClosed(t *testing.T) { + f := &sealRunner{out: "kind: Job\nmetadata: {name: x\n"} + f.install(t) + _, err := ListTestHooks(context.Background(), TestTarget{Release: "acme", Namespace: "acme"}) + if err == nil || !strings.Contains(err.Error(), "parsing the release's hook manifests") { + t.Fatalf("want a parse failure, got: %v", err) + } +} + +func TestRunTest_Argv(t *testing.T) { + f := &sealRunner{out: "NAME: acme\n"} + f.install(t) + // One check plus an aux plumbing hook: both must land in ONE --filter + // (comma-OR), so helm creates the check's dependencies but runs no other check. + out, err := RunTest(context.Background(), TestTarget{ + Release: "acme", Namespace: "acme", Kubeconfig: "/tmp/kc", KubeContext: "kind-acme", + }, []string{"acme-storage-assertions-check", "acme-storage-assertions-sa"}, 2*time.Minute) + if err != nil || out != "NAME: acme\n" { + t.Fatalf("RunTest = %q, %v", out, err) + } + want := []string{"helm", "test", "acme", "--namespace", "acme", + "--filter", "name=acme-storage-assertions-check,name=acme-storage-assertions-sa", + "--timeout", "2m0s", + "--kubeconfig", "/tmp/kc", "--kube-context", "kind-acme"} + if len(f.calls) != 1 || strings.Join(f.calls[0], " ") != strings.Join(want, " ") { + t.Fatalf("helm argv = %v, want %v", f.calls, want) + } +} + +// The raw error + output pass through unwrapped: the caller owns the per-check +// verdict (exit code) and the failure-detail extraction, and needs both intact. +func TestRunTest_PassesThroughFailure(t *testing.T) { + f := &sealRunner{ + out: "Error: 1 error occurred:\n\t* job failed: BackoffLimitExceeded\n", + err: errors.New("exit status 1"), + } + f.install(t) + out, err := RunTest(context.Background(), TestTarget{Release: "acme", Namespace: "acme"}, []string{"acme-x"}, 0) + if err == nil || err.Error() != "exit status 1" { + t.Fatalf("want the raw runner error, got: %v", err) + } + if !strings.Contains(out, "BackoffLimitExceeded") { + t.Fatalf("want the raw combined output, got: %q", out) + } + // timeout == 0 → no --timeout flag (helm's own default stands). + if got := strings.Join(f.calls[0], " "); strings.Contains(got, "--timeout") { + t.Fatalf("timeout 0 must not add --timeout: %v", got) + } +}