This is the full developer documentation for k8s-lookout # Using k8s-lookout from an AI agent > The agent-optimized entry point — what k8s-lookout is, how to install the CLI, deploy the sentinel, wire MCP, and verify each step, in copy-runnable form. This page is written for an AI agent that has been asked to install, deploy, demo, or troubleshoot with `k8s-lookout`. It is deliberately dense. Every command is copy-runnable; every verification step names what success looks like. The same content in one fetchable file: [`llms.txt`](https://go-steer.github.io/k8s-lookout/llms.txt) (index) · [`llms-full.txt`](https://go-steer.github.io/k8s-lookout/llms-full.txt) (entire docs). ## What you are working with [Section titled “What you are working with”](#what-you-are-working-with) One Go binary, `lookout`, with three surfaces: * **CLI** — `lookout `: one-shot diagnostic reads against the current kubeconfig context. No deployment required. * **MCP server** — `lookout mcp`: every read command exposed 1:1 as MCP tools (stdio, or streamable HTTP — loopback by default). * **Sentinel** — `lookout watch`: a resident in-cluster daemon that detects developing trouble (stalled rollouts, saturation forecasts, expiring certs, node flaps) and opens incident sessions on a sink. Safety facts you can rely on and repeat to the user: `lookout` holds **read-only** cluster credentials and never mutates cluster state; every output surface passes one sanitizer (Secret values render as names and sizes, credential-shaped strings are redacted), enforced by CI tests that plant fake credentials. ## Route by task [Section titled “Route by task”](#route-by-task) | The user wants | Do | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | “Is anything wrong with my cluster?” right now | [Install the CLI](#install-the-cli), run `lookout health` and `lookout triage delta -A` | | Standing monitoring that opens incidents | [Deploy the sentinel](#deploy-the-sentinel) | | Their agent runtime to call these checks as tools | [Serve MCP](#serve-mcp) | | A demo / evaluation on a disposable cluster | [Tutorial: see both halves work](/k8s-lookout/getting-started/tutorial/) — kind cluster, staged failures, real incidents | | Deep flag/output detail | [Reference](/k8s-lookout/reference/) — generated from the same metadata as `--help`, so `lookout --help` is equally authoritative offline | ## Install the CLI [Section titled “Install the CLI”](#install-the-cli) Prebuilt binaries (v0.13.0+; linux/darwin amd64+arm64, windows amd64; `lookout-gke_*` assets = GKE provider compiled in): ```sh gh release download -R go-steer/k8s-lookout -p 'lookout_*_linux_amd64.tar.gz' tar -xzf lookout_*_linux_amd64.tar.gz && sudo install lookout /usr/local/bin/ ``` Or with Go 1.26+: `go install github.com/go-steer/k8s-lookout/cmd/lookout@latest`. Without either, run from the container image (entrypoint override is required — the image’s default entrypoint is the sentinel): ```sh docker run --rm --entrypoint /lookout \ -v "$HOME/.kube:/kube:ro" -e KUBECONFIG=/kube/config \ ghcr.io/go-steer/lookout:latest health ``` ## Read the output correctly [Section titled “Read the output correctly”](#read-the-output-correctly) The output contract, identical on CLI and MCP: * One finding per line: logfmt by default, `--format=json` for JSON-per-line. Healthy resources emit **nothing**. * Every invocation ends with a mandatory summary line — `scanned=16 findings=18 elapsed=537ms` — so `findings=0` plus a summary line means genuinely healthy, while a missing summary line means the invocation itself broke. * Exit 0: payload on stdout. Exit 1: runtime failure, diagnostics on stderr only. Exit 2: usage error. * Missing capability is explicit, never an error: provider-gated commands on a cluster without a cloud provider emit `kind=cloud.unavailable` and exit 0. Branch on the marker, not the exit code. ## Deploy the sentinel [Section titled “Deploy the sentinel”](#deploy-the-sentinel) Decisions to make (ask the user if unknown): 1. **Sink** — where do incidents go? A [core-agent](https://github.com/go-steer/core-agent) daemon is the default (`--daemon-url` + bearer token). Any HTTP receiver works via `--sink=webhook --sink-url=… --sink-token-env=…` ([the two-endpoint contract](/k8s-lookout/getting-started/integrations/)). For evaluation without either, the [tutorial](/k8s-lookout/getting-started/tutorial/) deploys a one-page capture stub. 2. **Image flavor** — `ghcr.io/go-steer/lookout:latest` runs anywhere (zero GCP SDKs); `:latest-gke` adds the GKE/GCP provider, required only for the `quota` source and `cloud`/`state wi`/`perf probe` commands. 3. **RBAC scope** — the shipped manifests create a read-only ClusterRole (applying them needs cluster-level RBAC rights). The only Secret-value read is the expiry source’s `list` (TLS `notAfter` parsing); scope it with `--expiry-namespaces` or delete the rule. Then the install is three commands (no clone): ```sh kubectl create namespace agent-triage kubectl -n agent-triage create secret generic lookout-watch-token \ --from-literal=token="$WATCHER_TOKEN" kubectl apply -k "github.com/go-steer/k8s-lookout/deploy?ref=main" ``` Before walking away, edit the Deployment’s `args:` for the environment (`--cluster-name`, `--daemon-url` or the webhook sink flags) — `kubectl -n agent-triage edit deploy/lookout-watch` — and verify: ```sh kubectl -n agent-triage rollout status deploy/lookout-watch kubectl -n agent-triage logs deploy/lookout-watch | head -30 ``` Read that startup log, do not skip it. `--sources=auto` (the default) probes each source’s grants and prints one line per decision, ending in `sources: auto resolved → …`. A skipped source is one loud line naming the missing grant — that is expected degradation, not failure. Two misses you should recognize and not “fix” by widening RBAC: * `saturation: disabled (metrics.k8s.io unavailable)` — the cluster has no metrics-server; install one or accept no saturation forecasts. * On GKE Autopilot, the platform denies `nodes/proxy` to every principal — the PVC dimension of saturation degrades loudly and nothing you grant will change it. A sentinel that cannot watch Events at all refuses to start; that is misdeployment, not degradation. Full source-by-source table: [Troubleshooting](/k8s-lookout/operations/troubleshooting/). ## Serve MCP [Section titled “Serve MCP”](#serve-mcp) For an agent runtime that speaks MCP instead of shelling out, register a stdio server — command `lookout`, args `["mcp"]`: ```json { "mcpServers": { "k8s-lookout": { "command": "lookout", "args": ["mcp"] } } } ``` Tool names mirror the commands (`k8s_cluster_health`, `k8s_triage_delta`, `k8s_triage_workload`, …) with schemas generated from the CLI flags; results carry the exact CLI payload including the summary line. The HTTP transport (`--listen`) refuses non-loopback binds by default; serving off-host takes an explicit opt-in plus a bearer token and an access log. Full tool table and the off-host recipe: [MCP setup](/k8s-lookout/getting-started/mcp/). ## Prove it works [Section titled “Prove it works”](#prove-it-works) Cheapest end-to-end check on any cluster, no failures staged: ```sh lookout health # every category reports; ends with scanned=… ``` To showcase the sentinel detecting, enriching, and resolving real incidents on a disposable kind cluster, run the [tutorial](/k8s-lookout/getting-started/tutorial/) — it stages failures with inject/verify/revert scripts and shows what to expect on each surface. When something looks wrong: the sentinel’s own log records every fire/dedup/route decision; [Observing lookout](/k8s-lookout/operations/observability/) maps each startup line, and [Troubleshooting](/k8s-lookout/operations/troubleshooting/) distinguishes loud-but-expected degradations from real faults. # Getting started > Install lookout, run the first read-path commands against a kubeconfig, deploy the sentinel, connect it to core-agent, and wire up MCP. This section is for anyone starting from zero: you have a kubeconfig, maybe an AI agent, and you have never run `lookout`. By the end you will have the binary installed, real diagnostic output from a cluster you already have access to, and — if you take the later steps — a sentinel deployed in-cluster, opening incident sessions for your agent. The first useful command needs nothing deployed at all. Remember the shape: one binary, `lookout`, used three ways — the **CLI** (one-shot diagnostic commands), the **MCP server** (`lookout mcp`, the same commands as MCP tools), and the **sentinel** (`lookout watch`, the optional in-cluster watcher). The path in is incremental — each step works without the next: 1. [Install](/k8s-lookout/getting-started/install/) — get the `lookout` binary on your workstation, and know which container image flavor a cluster deployment needs. 2. [First reads](/k8s-lookout/getting-started/first-run/) — the CLI against your current kubeconfig, nothing deployed. Start with `lookout scan`: no target, no flags, and it names what is broken. 3. [Tutorial](/k8s-lookout/getting-started/tutorial/) — a \~20-minute end-to-end run on a disposable kind cluster: stage real failures, watch the sentinel open and close incidents. 4. [Deploy the sentinel](/k8s-lookout/getting-started/deploy/) — one `kubectl apply -k` from the shipped manifests, what each manifest is, the RBAC tiers, and the flags that matter. 5. [Connect to core-agent](/k8s-lookout/getting-started/connect-core-agent/) — the daemon contract: sessions, injects, per-incident vs shared routing. 6. [MCP setup](/k8s-lookout/getting-started/mcp/) — every read command as an MCP tool, for agent runtimes that cannot shell out. 7. [Integrations](/k8s-lookout/getting-started/integrations/) — beyond core-agent: the read path from any MCP client or shell-capable agent, and the watch path into any webhook receiver. The two commands worth running first take no arguments and need nothing deployed — point them at a cluster you have never seen: ```sh lookout scan # what is broken right now lookout audit # what has no safety net, while it is still healthy ``` [What lookout detects](/k8s-lookout/detect/) is the coverage map for both, and for the sentinel: one page per mode, listing everything each one looks for. The [Reference](/k8s-lookout/reference/) section is generated from the same declarations that produce `--help` — when this section links a flag or a command, the reference page is the authoritative surface. # Connect to core-agent > The daemon contract — sessions and injects, per-incident vs shared routing, tokens and asserted callers, and what an inject looks like on the wire. The sentinel speaks to a [core-agent](https://github.com/go-steer/core-agent) daemon over its pre-existing HTTP API — `POST /sessions` to open an incident session, `POST /sessions//inject` to deliver signals into it. Nothing new is required on the daemon side. ## Wiring [Section titled “Wiring”](#wiring) * **`--daemon-url`** — base URL of the daemon, no trailing slash. The shipped manifest points at the local Service: `http://core-agent.agent-triage.svc.cluster.local:7777`. For a sentinel in a remote cluster, override to `https://:7777` — one daemon may serve many sentinels. * **`--token-env`** — the name of the environment variable holding the bearer token (the shipped manifest sources `WATCHER_TOKEN` from the `lookout-watch-token` Secret). Every request carries it as `Authorization`. * **`--cluster-name`** — stamped into every payload, so a daemon serving several clusters can tell the streams apart. ## Per-incident vs shared [Section titled “Per-incident vs shared”](#per-incident-vs-shared) * **`--mode=per-incident`** (default) — the sentinel creates a session per `(uid, reason)` incident. Requires **`--owner`**: the value sent as `X-Asserted-Caller` on `POST /sessions`, which must match a proxy identity in the daemon’s `users.json` — the daemon attributes every sentinel-opened session to that owner. Severity routing is per-incident-mode machinery: critical signals open their own enriched sessions, warnings batch into the [watchboard](/k8s-lookout/operations/watchboard/), info-class signals are stored only. * **`--mode=shared`** — all injects, every severity, go to one pre-existing session named by **`--target-session`**. The watchboard is disabled; nothing is created. The right shape when an existing agent session should receive everything. ## What an inject looks like [Section titled “What an inject looks like”](#what-an-inject-looks-like) Captured on the wire during a validation drill (a stub daemon logging every request): ```plaintext REQ POST /sessions Authorization: X-Asserted-Caller: sre-oncall@example.com REQ POST /sessions/stub-sess-0005/inject Authorization: X-Asserted-Caller: sre-oncall@example.com BODY: {"message":"{\"kind\":\"k8s-event\",\"reason\":\"BackOff\",\"namespace\":\"default\",\"kind_of_object\":\"Pod\",\"name\":\"crashloop-demo\",\"container\":\"spec.containers{crasher}\",\"uid\":\"7503ea47-d147-4342-92b2-743a1d88cd4b\",\"message\":\"Back-off restarting failed container crasher in pod crashloop-demo_default(7503ea47-d147-4342-92b2-743a1d88cd4b)\",\"count\":1,\"first_seen\":\"2026-07-24T17:12:00Z\",\"last_seen\":\"2026-07-24T17:12:00Z\",\"cluster\":\"local\",\"context\":{\"node\":\"kl-m0-control-plane\"}}"} ``` The payload is a structured signal, not prose: `kind`, the object coordinates, a cross-cluster-stable `fingerprint` (on all source-namespaced kinds), and per-kind fields. The `k8s-event` / `k8s-event-followup` pair above is byte-frozen from the predecessor; everything the sentinel can inject — `storm`, `resolved`, `watchboard.digest`, `saturation.forecast` with its ETA attachment, and the rest — is cataloged in the generated [signal-kind reference](/k8s-lookout/reference/signal-kinds/). Beyond the initial inject, sessions receive followups without any agent polling: dedup-window repeats, `kind=storm.member` attachments, and — the closed loop — `kind=resolved` when the sentinel observes the symptom clear and hold stable for `--recovery-stable-for` (with `kind=resolved.reverted` if it comes back). ## Trying it without a daemon [Section titled “Trying it without a daemon”](#trying-it-without-a-daemon) * **`--dry-run`** watches the cluster for real — informers, sources, filter/dedup/routing all run, so it needs cluster access like a normal run — but prints inject payloads to stdout instead of calling any daemon or sink. Point it at a kubeconfig (or run it in-cluster), break something, and watch the payloads appear. * The capture stub [`dev/drills/stub-daemon.py`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/stub-daemon.py) implements the two endpoints and logs every request body — deployed behind a Service named `core-agent:7777`, it is the wire-level evidence capture every validation drill uses. # Deploy the sentinel > One kubectl apply -k, no clone — what each manifest is, the RBAC tiers, namespace-tier caveats, and a walkthrough of the flags that matter. The sentinel — `lookout watch`, the resident per-cluster watcher — deploys from the shipped manifests, no clone needed. Three commands, assuming a core-agent daemon is answering at the in-cluster `--daemon-url` (wiring the daemon is the [next page](/k8s-lookout/getting-started/connect-core-agent/); `$WATCHER_TOKEN` is its bearer token): ```sh kubectl create namespace agent-triage kubectl -n agent-triage create secret generic lookout-watch-token \ --from-literal=token="$WATCHER_TOKEN" kubectl apply -k "github.com/go-steer/k8s-lookout/deploy?ref=v0.26.0" ``` Pin `?ref=` to the release you are deploying — each tag’s manifest pins its matching image. The first two commands exist because the manifests reference the namespace and Secret but deliberately do not create them. From a clone, `kubectl apply -k deploy/` applies the same set (use `-k`, not `-f` — the directory carries the kustomization). ## What each manifest is [Section titled “What each manifest is”](#what-each-manifest-is) | Manifest | What it is | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `11-serviceaccount-watcher.yaml` | The sentinel’s ServiceAccount (`lookout-watch`). Bound to no GCP IAM role: the sentinel talks only to the local API server and the daemon. | | `12-clusterrole-watcher.yaml` | The minimum-necessary, **read-only** ClusterRole. No patch/update/delete on anything — the sentinel observes; mutations happen through the core-agent daemon’s own permission gate. Each rule is annotated with the source that needs it; rules for disabled sources are harmless. | | `13-clusterrolebinding-watcher.yaml` | Binds the ServiceAccount to the ClusterRole. | | `14-role-watcher-capacity.yaml` | A `kube-system`-namespaced Role for the capacity source’s one extra read: `get` on the `cluster-autoscaler-status` ConfigMap, pinned by `resourceNames` rather than widening the ClusterRole. Only the capacity source needs it — under `--sources=auto` its absence skips the source loudly; with capacity named explicitly it is fatal. | | `15-rolebinding-watcher-capacity.yaml` | Binds the ServiceAccount to the capacity Role. | | `16-networkpolicy-watcher.yaml` | Default-deny ingress for the sentinel pod: only same-namespace scrapers reach `/metrics` + `/healthz` on `:9090`, since that surface is an incident-topology map a co-tenant should not scrape. Admit off-namespace monitoring (e.g. a `gmp-system` namespace) by uncommenting the `namespaceSelector` block. **Inert without an enforcing CNI** — if your CNI does not enforce NetworkPolicy the manifest is a no-op, and the Secret-read RBAC tradeoff in `deploy/12` still applies. Egress is left unrestricted (the API server, daemon, and cloud API addresses are environment-specific). | | `17-service-watcher.yaml` | A ClusterIP Service, `lookout-watch-metrics`, publishing the metrics port. Until #288 `deploy/` shipped no Service at all and `16`’s NetworkPolicy assumed a scraper reaching the pod IP directly — which works for a hand-rolled scrape config and for nothing else. Prometheus-operator users additionally apply `deploy/prometheus-operator/`, which is a separate `-k` target because `ServiceMonitor` is a CRD the base bundle must not require. | | `51-deployment-watcher.yaml` | The sentinel Deployment: one replica, distroless image, nonroot, a `/healthz` liveness probe and a `/readyz` readiness probe on the metrics port, and the shipped `args:`. `strategy: Recreate`, because a rolling update of a single-replica watcher briefly runs two sentinels double-emitting every signal — a few seconds of downtime is the better trade. A separate Deployment from the daemon (not a sidecar) so the two scale and restart independently; for another cluster, copy it and change `--cluster-name` + `--daemon-url`. | One deliberate tradeoff to know about: the expiry source’s `secrets` rule is the sentinel’s only read of Secret values (`tls.crt` to parse `notAfter`; the token JWT for its `exp` claim), and it is `list` only — no watch, no get, no informer cache of secret material. Scope it with `--expiry-namespaces`, or remove the rule entirely if the expiry source stays disabled. ### Narrowing the role — partial bundles, not errors [Section titled “Narrowing the role — partial bundles, not errors”](#narrowing-the-role--partial-bundles-not-errors) `list` on `secrets` returns the full value of every Secret at the API level, so some operators would rather the sentinel’s ServiceAccount never hold that grant — even given the masking guarantees. As of #192 you can drop any resource from your copy of `deploy/12` and the bundle and enrichment paths **degrade to a documented partial** instead of failing: a per-resource `Forbidden` (or `NotFound`, e.g. a CRD that is not installed) is caught, the resource is left out of the topology pass, and the bundle’s `bundle.target` head carries a `skipped=` note naming exactly what was dropped (`skipped=secrets` when you withhold the secrets grant). Nothing that reads a skipped resource errors; the rest of the bundle is unaffected and, without the secrets grant, provably secret-free at the source rather than only at the sanitizer. This is opt-in tolerance: the strict RBAC probe (`--sources`, above) and `state edges`/`triage` still fail loudly on a missing grant, so a narrowed role is a deliberate choice a bundle documents, never a silent hole in a signal source. You do not have to edit the role to try this. Two flags select, per run, which lists the pass reads — accepting `all` (default), a comma-separated allowlist (`pods,deployments`), or subtractions (`all,-secrets`): * **`--enrich-lists`** (on `lookout watch`) narrows the scoped-list enrichment fallback; **`--enrich-lists-preflight`** SelfSubjectAccessReviews each selected resource first and drops the denied ones proactively (fewer 403s in the watcher log), falling back to the reactive `Forbidden`-skip when SSAR itself is not permitted. * **`--lists`** / **`--lists-preflight`** are the same knobs on the one-shot `lookout bundle` command. The [`lookout bundle` reference](/k8s-lookout/reference/bundle/) documents both, and the `skipped=` field, in full. ## Or install the chart [Section titled “Or install the chart”](#or-install-the-chart) The same deployment ships as a Helm chart, published to the same registry as the images and signed with the same keyless identity: ```sh helm install lookout-watch oci://ghcr.io/go-steer/charts/lookout \ --version 0.26.0 \ --namespace agent-triage --create-namespace \ --set-string 'args[0]=--daemon-url=http://core-agent.agent-triage.svc.cluster.local:7777' \ --set-string 'args[4]=--cluster-name=prod-us-east1' ``` The chart version tracks the release, minus the `v` — chart `0.26.0` deploys `v0.26.0`. There is no compatibility matrix to consult because there is only one version line. (v0.22.0 is the first release that publishes a chart; for anything earlier, `helm install lookout-watch deploy/chart` from a clone is the only route.) Verify it the same way you verify an image: ```sh cosign verify ghcr.io/go-steer/charts/lookout:0.26.0 \ --certificate-identity-regexp '^https://github.com/go-steer/k8s-lookout' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` The namespace and the token Secret are the same two prerequisites as above — the chart does not create either. The Secret in particular is deliberate: Helm stores the rendered release manifest in a Secret of its own, so a token passed as a chart value ends up readable by anyone who can read the release. The chart is **not** a second description of the deployment. Its defaults are the values in `deploy/*.yaml`, and a CI job renders both and diffs them: ```plaintext helm template lookout-watch deploy/chart -n agent-triage == kustomize build deploy/ ``` They must match resource for resource and field for field, modulo the three provenance labels Helm stamps on everything it renders. So the manifest table above remains the one place to read about what a rule or a flag is for, and a chart that quietly keeps deploying last quarter’s RBAC is a build failure rather than a discovery you make during an incident. Run it yourself with `dev/tools/verify-helm-parity`. What the chart adds over `kubectl apply -k` is the toggles: RBAC tiers (`rbac.create`, `rbac.capacity`), a PVC for the occurrence store (`persistence.enabled`), the prometheus-operator ServiceMonitor (`serviceMonitor.enabled`, which is the `deploy/prometheus-operator/` add-on as a flag), the `-gke` image flavor (`image.flavor`), and extra NetworkPolicy ingress peers for an off-namespace scraper. Everything is documented in [`deploy/chart/README.md`](https://github.com/go-steer/k8s-lookout/blob/main/deploy/chart/README.md) and in the comments in `values.yaml`. One value behaves unlike the rest: `args` is a flat list you replace wholesale, not a map of individual flags. The flags interact — `--mode` with `--dedup-window`, `--storm` with `--store` — and a chart that let you override one in isolation would happily render a combination nobody has ever run. ## Deployment tiers [Section titled “Deployment tiers”](#deployment-tiers) | Tier | Unit | Mechanism | | --------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Namespace | `lookout watch` under a `Role` | `--namespace`/`--exclude-namespace`. Cluster-scoped sources cannot run: `--sources=auto` (the default) skips each with a loud line naming the missing grant, while an explicit list fails loudly at startup — never a silently empty watch either way. The topology graph builds a namespace-local subgraph. | | Cluster | one sentinel per cluster (canonical) | One informer cache, one topology index, one credential boundary, one failure domain. One daemon may serve many sentinels. | | Project | quota source only | One instance per GCP project, regardless of cluster count. Needs the `-gke` image. | | Fleet | the fleet layer, not `lookout` | Sentinel-per-cluster fan-in; a fleet-level consumer joins signals on `fingerprint` + `cluster`/`zone`/`project`. | ### Namespace-tier caveats — failures are loud [Section titled “Namespace-tier caveats — failures are loud”](#namespace-tier-caveats--failures-are-loud) A namespace-scoped (Role-only) deployment cannot satisfy sources that watch cluster-scoped objects (`object-state`’s nodes, `capacity`, PDB checks, the `--storm` graph informers). Under the default `--sources=auto`/`--storm=auto`, those resolve OFF — each with one startup line naming the missing grant — and the sentinel runs with what the Role supports (`k8s-events` at minimum; events access itself is non-negotiable). Name a source explicitly and the same probe refuses to start instead, naming exactly what is missing: ```plaintext source "object-state" requires permission to "list nodes cluster-wide" (scope: Cluster) and this ServiceAccount does not have it; grant it or disable the source — refusing to run a silently empty watch ``` The same discipline covers scoped grants: with `--expiry-namespaces` set, the probe verifies exactly the scoped namespaces. See [Troubleshooting](/k8s-lookout/operations/troubleshooting/) for the source-by-source requirements table. ## The flags that matter [Section titled “The flags that matter”](#the-flags-that-matter) The shipped `args:` in `51-deployment-watcher.yaml` carry the wiring (`--daemon-url`, `--token-env`, `--mode=per-incident`, `--owner`, `--cluster-name`, `--dedup-window=5m`, `--in-cluster`, `--metrics-addr=:9090`, `--log-level=info`) plus `--storm=on` and a `--store` on an emptyDir volume; sources ride the binary’s `--sources=auto` default, so the manifest enables everything its RBAC supports and runs unchanged on platforms that deny a grant to every principal. The capability flags: * **`--sources`** — which signal sources run. The default is `auto`: probe every portable source’s needs at startup — RBAC per source, metrics.k8s.io presence for `saturation` — and enable what the deployment supports, skipping misses with one loud line each (`k8s-events` must pass; a sentinel that cannot watch events fails to start). An explicit list is the strict mode: a named source’s missing REQUIRED grant is fatal (optional dimensions, like `saturation`’s `nodes/proxy` PVC read, degrade loudly instead — platform policies such as GKE Autopilot’s Warden deny that one to every principal). The shipped `args:` ride the auto default so the manifest runs unchanged on such platforms; pin the list explicitly for strict fail-fast semantics. `quota`, `notifications` and `token-burn` are never auto-enabled. What each source watches for, with example triggers and extra needs, is [What the sentinel watches](/k8s-lookout/detect/sentinel/). * **`--storm`** — storm correlation: incidents sharing a blast-radius key (nearest common topology ancestor) within `--storm-window` form one `kind=storm` session instead of dozens of per-pod pages. Takes `auto` (the default: the graph informers’ pods/nodes/replicasets list+watch grants present resolve on; a miss resolves off with a loud line), `on` (a missing grant is a fatal startup error), or `off` — `true`/`false` are accepted as aliases, but the old bare `--storm` bool syntax now errors; write `--storm=on`. Independent of `object-state`: the graph feed runs its own informers. * **`--store`** — the sentinel-local SQLite occurrence store: every emitted signal with its routing outcome, graph snapshots + change log (which unlock `--at` post-mortem queries), and triage-status records. Put it on the same volume as `--dedup-persist`. Bounded by `--store-ttl` (default 30 days) and `--store-max-mb` (default 512). See [The occurrence store](/k8s-lookout/operations/store/). * **`--enrich`** — which severities get a pre-warmed bundle attached to their session’s initial inject (`critical` by default; `warning` extends it; `off` disables). `--enrich-cap`, `--enrich-log-lines`, `--enrich-timeout` bound the work; failures become `enrichment_error` trailers, never crashes. `--enrich-lists` (and `--enrich-lists-preflight`) narrow which cluster resources the scoped-list fallback reads — see [Narrowing the role](#narrowing-the-role--partial-bundles-not-errors). * **Severity and watchboard knobs** — `--severity kind=level` (repeatable) overrides the per-kind default routing; warning-class signals batch into the shared watchboard session (`--watchboard-batch`, `--watchboard-flush`, `--watchboard-rotate`). See [The watchboard](/k8s-lookout/operations/watchboard/). * **`--dedup-persist`** and **`--recovery-stable-for`** — persist dedup bindings across restarts (recovery tracking resumes instead of re-firing), and how long a cleared symptom must stay clear before `kind=resolved` is injected (default 5m). The generated [`lookout watch` reference](/k8s-lookout/reference/watch/) is the full table of all 57 flags, derived from the live flag surface. A battle-tested full-capability flag set — the exact args a live validation drill appended to the shipped set (drill-tuned values marked): ```plaintext --sources=k8s-events,object-state,rollout,saturation,degradation,expiry --storm=on --enrich=critical --store=/data/lookout.db --graph-snapshot-interval=1m # drill value; default 5m --recovery-stable-for=60s # drill value; default 5m ``` After `kubectl apply`, read the startup log before walking away: every armed stage announces itself (`store: enabled …`, `graph history: enabled …`, `storm: topology graph ready …`), and every problem is a named, loud line — see [Observing `lookout`](/k8s-lookout/operations/observability/). # First reads > The read-path against your current kubeconfig — lookout scan, lookout audit, lookout health and lookout triage delta, with real captured output. Every read command works against your current kubeconfig context. Nothing is deployed, nothing is mutated — the read-path only ever gets, lists, and watches. ## Start here: `lookout scan` [Section titled “Start here: lookout scan”](#start-here-lookout-scan) If you know something is wrong but not what, this is the first call. It runs every target-free incident check in one invocation — broken workloads, dead admission webhooks, stuck volumes and PVCs, rejected Gateway routes, config drift — then drills into the dependency edges of whatever it flagged. No target, no flags, nothing deployed: ```sh lookout scan ``` ```console kind=pod.imagepull severity=critical namespace=lookout-demo kind_of_object=Pod name=api-f599769c4-swfxx reason=ErrImagePull message="failed to pull and unpack image \"ghcr.io/go-steer/lookout-examples-nosuch:v0\"…" fingerprint=sha256:e95154c7… check="triage delta" container=api image=ghcr.io/go-steer/lookout-examples-nosuch:v0 kind=pod.restarts severity=warning namespace=lookout-demo kind_of_object=Pod name=worker-7cd49696bf-9sbf5 reason=ExcessiveRestarts fingerprint=sha256:e094a6ee… check="triage delta" container=worker restarts=6 kind=workload.rollout severity=critical namespace=lookout-demo kind_of_object=Deployment name=worker reason=RolloutIncomplete fingerprint=sha256:f954cac0… check="triage delta" desired=1 ready=0 updated=1 available=0 … kind=crd.unavailable severity=info reason=APIGroupNotServed message="Gateway API is not installed: the gateway.networking.k8s.io/v1 API group is not served by this cluster…" check="state gateway" api_group=gateway.networking.k8s.io/v1 kind=cloud.unavailable severity=info reason=CapabilityUnavailable message="state wi needs the provider workload-identity capability: no cloud provider configured" check="state wi" capability=workload-identity provider=none kind=edge.missing_ref severity=critical namespace=lookout-demo kind_of_object=ConfigMap name=missing-config reason=FailedMount message="configmap missing-config not found (volume config)" check="state edges" workload=Deployment/lookout-demo/mounter volume=config pods=1 scanned=330 findings=9 elapsed=442ms unavailable="state gateway,state wi" detection=none detection_reason=no-majority-manager candidate=kubectl-client-side-apply share=45% checks=7 skipped=audit,cloud,perf drilldown=3 ``` (Real output against a kind cluster with three faults staged, abridged.) One call, and four things are visible that no single command would have given you: * **Stage 1 named the incidents** — a bad image tag, a container restarting, the rollouts those two are holding up. Every finding is stamped `check=`, which is also the command to run for the detail behind it, so the output is both a worklist and a set of next moves. * **Stage 2 drilled in.** `drilldown=3` means three flagged workloads had their dependency edges verified, which is where `edge.missing_ref` came from: nothing in stage 1 knew *why* `mounter` could not start, and the answer is a ConfigMap that does not exist. * **What could not run said so.** No Gateway API CRDs and no cloud provider, both reported as `info` findings and rolled up into `unavailable=` — an empty scan means “nothing is wrong”, never “nothing ran”. * **`skipped=audit,cloud,perf`** names the groups that are off by default, so they stay discoverable while off. [What `lookout scan` finds](/k8s-lookout/detect/scan/) lists every check it runs and every kind it can emit, grouped by stage. `scan` reports **incidents**: things broken now, which clear themselves when fixed. The posture sweep is one flag away — `lookout scan --include=audit`, or on its own: ```sh lookout audit workloads -A ``` ```console kind=audit.no_pdb severity=warning namespace=lookout-demo kind_of_object=Deployment name=web reason=NoPodDisruptionBudget message="2 replicas and no PodDisruptionBudget selecting them: the eviction API will let a drain or upgrade take all 2 at once" fingerprint=sha256:7fe356f9… replicas=2 namespace_pdbs=1 kind=audit.single_replica severity=warning namespace=lookout-demo kind_of_object=Deployment name=worker reason=SingleReplica message="spec.replicas=1: a node drain, upgrade, or eviction takes the workload fully down, and no PodDisruptionBudget can prevent that" fingerprint=sha256:8a0b879a… replicas=1 kind=audit.no_spread severity=info namespace=lookout-demo kind_of_object=Deployment name=api reason=NoTopologySpread message="2 replicas with no topologySpreadConstraints and no pod anti-affinity: nothing in the spec stops the scheduler putting them all on one node…" fingerprint=sha256:994489a9… replicas=2 … scanned=8 findings=19 elapsed=281ms pdbs=1 hpas=0 nodes=3 workloads=6/0/2/0 ``` That answers a different question — *what has no safety net while it is still healthy*. Note what it did **not** say: none of these workloads is unhealthy, and none of these findings will clear on its own. `web` has two replicas and no budget protecting them — `namespace_pdbs=1` says the namespace has a PDB, just not one selecting `web`, which is the mistake worth catching. [What `lookout audit` checks](/k8s-lookout/detect/audit/) is its coverage map. The rest of this page is the individual commands behind those two. ## “Any issues with this cluster?” [Section titled ““Any issues with this cluster?””](#any-issues-with-this-cluster) ```sh lookout health ``` ```console kind=health.category severity=info reason=Unavailable message="requires cloud provider metrics; no cloud provider configured" category=control-plane status=unavailable kind=health.category severity=info category=nodes status=healthy kind=health.category severity=warning category=crashloops status=degraded total=8 top="pod.restarts agent-sandbox-system/agent-sandbox-controller-7c69875fcc-n7xms; pod.restarts kube-system/coredns-7d764666f9-g82j9; …" kind=health.category severity=info category=pending status=healthy kind=health.category severity=info category=rollouts status=healthy … kind=pod.restarts severity=warning namespace=kube-system kind_of_object=Pod name=coredns-7d764666f9-g82j9 reason=ExcessiveRestarts fingerprint=sha256:e094a6ee… category=crashloops container=coredns restarts=62 scanned=16 findings=18 elapsed=537ms ``` (Real output against a kind cluster, abridged.) Three things to notice, because they are the output contract everything else follows: * **Every category answers.** Ten categories each report `healthy | degraded | unavailable` — healthy resources are omitted, but a category is never silently absent. `control-plane` honestly reports `unavailable` here: its metric packs need a cloud provider, and none is configured. Absent capability is always explicit, never an error. * **The summary line is mandatory.** `scanned=16 findings=18 elapsed=537ms` closes every invocation, so “cluster healthy” (`findings=0`) is distinguishable from “wrong flag / broken tool”. * **Findings are one record per line** (logfmt by default, `--format=json` for JSON), each with a stable `fingerprint` for cross-referencing. ## Everything abnormal, in one pass [Section titled “Everything abnormal, in one pass”](#everything-abnormal-in-one-pass) `lookout triage delta` is `scan`’s first and broadest stage, and useful on its own: broken workloads, aged Pending pods, node pressure, gridlocked PDBs, degraded kube-system add-ons, quotas at their limits — one pass, only the abnormal: ```sh lookout triage delta -A ``` ```console kind=pod.imagepull severity=critical namespace=shop kind_of_object=Pod name=checkout-5898857498-vw894 reason=ImagePullBackOff … container=checkout image=busybox:1.36-nonexistent-m1 kind=workload.rollout severity=warning namespace=shop kind_of_object=Deployment name=checkout reason=RolloutIncomplete desired=2 ready=2 updated=1 available=2 scanned=20 findings=2 elapsed=148ms ``` (Real output from a live validation drill — a kind cluster seeded with a deliberately broken Deployment.) The healthy Deployment, kube-system, and the node emitted nothing; `scanned=20` proves they were examined. From a delta finding, the usual next move is one correlated snapshot of the broken workload — sanitized spec, abnormal objects, broken dependency edges, blast radius, distilled logs, in a single payload: ```sh lookout bundle --workload=Deployment/shop/checkout ``` ## The output contract, in brief [Section titled “The output contract, in brief”](#the-output-contract-in-brief) * **Exit 0:** pure payload on stdout — no banners, no progress — terminated by the summary line. Diagnostics go to stderr only, so a captured stream never corrupts an agent’s context window. * **Exit 1** is a runtime failure (structured diagnostics on stderr); **exit 2** is a usage error. * **Common flags** on every command: `--namespace` / `-A`, `--workload=//`, `--since`, `--format=logfmt|json`, `--timeout=10s`. * **Sanitized always:** secret values, credential-shaped strings, and system metadata are masked or stripped from every output surface. Provider-gated commands (`cloud …`, `state wi`, `perf probe`) on a cluster without a cloud provider answer explicitly and exit 0: ```console kind=cloud.unavailable severity=info reason=CapabilityUnavailable message="cloud quota needs the provider quota capability: no cloud provider configured" capability=quota provider=none scanned=0 findings=1 elapsed=0s unavailable="no cloud provider configured" ``` The [Reference](/k8s-lookout/reference/) section documents every command’s flags, output fields, and examples — generated from the same metadata that produces `--help`. # Install > Get the lookout binary — go install for the workstation, container images (default and -gke) for cluster deployments — plus cosign verification and the image-swap compatibility contract. One binary covers all three surfaces — the CLI, the MCP server (`lookout mcp`), and the sentinel (`lookout watch`). Installing means getting that binary where you need it: * **On a workstation** — for the CLI and the MCP server — download a prebuilt binary from the [latest release](https://github.com/go-steer/k8s-lookout/releases/latest) (v0.13.0 and later; Linux and macOS on amd64/arm64, Windows on amd64 — Windows archives are `.zip`): ```sh gh release download -R go-steer/k8s-lookout -p 'lookout_*_linux_amd64.tar.gz' tar -xzf lookout_*_linux_amd64.tar.gz && sudo install lookout /usr/local/bin/ ``` The `lookout-gke_*` assets are the same binary with the GKE/GCP provider compiled in (see the flavor guide below). Or build from source with Go 1.26+: ```sh go install github.com/go-steer/k8s-lookout/cmd/lookout@latest ``` Either way that is the whole install; `lookout health` against your current kubeconfig works immediately ([First reads](/k8s-lookout/getting-started/first-run/) is the next page). * **In a cluster** — for the sentinel — use the container images below; [Deploy the sentinel](/k8s-lookout/getting-started/deploy/) applies the shipped manifests with one `kubectl apply -k`, no clone needed. ## Container images [Section titled “Container images”](#container-images) Images are published at `ghcr.io/go-steer/lookout` — multi-arch (amd64 + arm64), distroless static, running as nonroot, Sigstore-signed: ```sh docker pull ghcr.io/go-steer/lookout:latest # default: GCP-free, runs on any conformant cluster docker pull ghcr.io/go-steer/lookout:latest-gke # same binary + GKE/GCP provider (-tags allproviders) ``` Which flavor you need: * **Default (`:latest`, `:vX.Y.Z`)** — links zero GCP SDKs, by design (a conformance test in CI keeps it that way). Most of the suite is pure `client-go`: the entire `triage` group, `state edges|webhooks|volumes`, `stab drift|drain`, `bundle`, `health`, `net probe`, and the sentinel sources `k8s-events`, `object-state`, `rollout`, `saturation`, `degradation`, `expiry`, and `token-burn`. Provider-gated commands in this image never break or lie — they emit an explicit `cloud.unavailable` finding and exit 0 (see [Troubleshooting](/k8s-lookout/operations/troubleshooting/)). * **`-gke` (`:latest-gke`, `:vX.Y.Z-gke`)** — the same binary compiled with the GKE/GCP cloud provider. Required for the `cloud` command group, `state wi`, the `perf probe` metric packs, the `quota` source, and the capacity source’s GKE scale-decision sub-source. Same flags, same signing; only the compiled-in cloud provider differs. Project-tier deployments (the one sentinel per GCP project that enables the `quota` source) must pin this flavor — `--sources=…,quota` in the default image refuses at startup, loudly and correctly. ### Verify signatures [Section titled “Verify signatures”](#verify-signatures) ```sh cosign verify ghcr.io/go-steer/lookout:vX.Y.Z \ --certificate-identity-regexp '^https://github.com/go-steer/k8s-lookout' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` `cosign verify` works identically against the `-gke` tags. ### Read the bill of materials [Section titled “Read the bill of materials”](#read-the-bill-of-materials) Every image carries an SPDX SBOM attestation per platform, signed keyless with the same identity as the signature — so the “who built this” and “what is in it” questions verify through one flow and one trust root: ```sh cosign verify-attestation ghcr.io/go-steer/lookout:vX.Y.Z \ --type spdxjson \ --certificate-identity-regexp '^https://github.com/go-steer/k8s-lookout' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ | jq -r '.payload | @base64d | fromjson | .predicate' > sbom.spdx.json ``` Two attestations come back, one per platform (`linux/amd64`, `linux/arm64`) — a multi-arch index scanned without a platform silently describes whichever child matched the scanner’s host. This is also the checkable form of the [GCP-free guarantee](/k8s-lookout/concepts/portability/): the default flavor’s SBOM contains no cloud SDK, and the `-gke` flavor’s does. Release binaries are covered by a keyless-signed checksums file attached to each release: ```sh cosign verify-blob lookout_vX.Y.Z_SHA256SUMS \ --bundle lookout_vX.Y.Z_SHA256SUMS.sigstore.json \ --certificate-identity-regexp '^https://github.com/go-steer/k8s-lookout' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com sha256sum -c lookout_vX.Y.Z_SHA256SUMS --ignore-missing ``` **GKE Autopilot:** both flavors run on Autopilot, with one platform limitation — Warden denies `nodes/proxy` to every principal, so the saturation source’s PVC dimension is disabled there (CPU/memory forecasting still works). The sentinel reports this at startup; see [Concepts → Portability](/k8s-lookout/concepts/portability/#gke-autopilot). ## Building with a cloud provider [Section titled “Building with a cloud provider”](#building-with-a-cloud-provider) `go install` builds the GCP-free default. For a provider-enabled binary, build with tags — `-tags gke` for the GKE provider alone, `-tags allproviders` for everything (what the `-gke` image ships): ```sh go build -tags allproviders ./cmd/lookout ``` ## Entrypoint [Section titled “Entrypoint”](#entrypoint) The image’s entrypoint is `["/lookout", "watch"]`, so a Deployment’s bare `args:` splice in behind `watch` (no explicit `command:` needed). The sentinel’s core flag surface is pinned by CI contract tests, so an existing deployment can upgrade the image with zero config change. To run a read-path command from the image (rather than the sentinel), override the entrypoint — e.g. `--entrypoint /lookout` with `docker run`, or `command: ["/lookout"]` in a pod spec. On a workstation the natural path is the plain binary: every read command works against your current kubeconfig context, which is the next page. # Integrations > Beyond core-agent — the read path from any MCP client or shell-capable agent, skills portability, and receiving the watch path anywhere over the webhook sink. core-agent is the first-class runtime and the default everywhere, but neither data path is welded to it. The read path terminates at MCP, a CLI, and plain-markdown skills — surfaces any agent runtime already speaks. The watch path terminates at a [two-verb sink contract](https://github.com/go-steer/k8s-lookout/blob/main/docs/agent-sink-design.md) whose webhook implementation any HTTP endpoint can receive. ## Consuming the read path [Section titled “Consuming the read path”](#consuming-the-read-path) ### Any MCP client [Section titled “Any MCP client”](#any-mcp-client) `lookout mcp` is a standard MCP server (stdio, or loopback streamable HTTP) — nothing about it is core-agent-specific. The config shape for a generic MCP client: ```json { "mcpServers": { "k8s-lookout": { "command": "lookout", "args": ["mcp"] } } } ``` Claude Code users, same thing from the CLI: ```sh claude mcp add k8s-lookout -- lookout mcp ``` The server reads the current kubeconfig context (the ServiceAccount when in-cluster), and every tool result is the CLI’s exact sanitized payload, summary line included. Transports, the non-loopback refusal and how to lift it, and the full tool↔command table are on the [MCP setup page](/k8s-lookout/getting-started/mcp/). ### Shell-capable agents [Section titled “Shell-capable agents”](#shell-capable-agents) Any agent with a shell tool needs no integration at all — the CLI contract is designed for capture into a context window: token-dense logfmt (or `--format=json`) on stdout, always terminated by the `scanned=/findings=/elapsed=` summary line; diagnostics on stderr only, so a captured stream never mixes streams; everything through the sanitizer. `lookout --help` teaches the surface in one read, and the [Reference](/k8s-lookout/reference/) section is generated from the same declarations. ### Skills travel too [Section titled “Skills travel too”](#skills-travel-too) The workflow skills in [`skills/`](https://github.com/go-steer/k8s-lookout/tree/main/skills) are plain markdown with a `SKILL.md` + `references/` layout and no runtime-specific hooks — they install into any runtime that loads that shape (Claude Code’s skills directory, `$HOME/.agents/skills/` conventions, or a framework’s own prompt-library mechanism) by copying: ```sh cp -r skills/k8s-triage skills/cluster-health skills/gitops-drift \ skills/k8s-capacity skills/playbooks "$HOME/.agents/skills/" ``` Skills version with tool flags and output formats, so reinstall the `skills/` matching the `lookout` tag you deploy. `playbooks/` is shared reference material the skills link to — keep it alongside. ## Receiving the watch path anywhere [Section titled “Receiving the watch path anywhere”](#receiving-the-watch-path-anywhere) The sentinel’s delivery side is a `Sink`: the core-agent daemon client is the default (`--sink=core-agent`, wire-identical across every release), and `--sink=webhook` delivers the same [schema-v1 signal payloads](/k8s-lookout/reference/signal-kinds/) to any HTTP receiver: ```sh lookout watch \ --sink=webhook \ --sink-url=https://receiver.example.com/lookout \ --sink-token-env=SINK_TOKEN \ --cluster-name=prod-east ``` The contract is two endpoints, mirroring the two verbs the watch-path needs from any runtime — open an incident context, append to it: | Verb | Request | Response | | ------ | --------------------------------------------------- | ------------------------ | | Open | `POST /incidents`, body = one signal payload | 2xx, `{"id":""}` | | Append | `POST /incidents//events`, same body shape | 2xx | Every request carries `Authorization: Bearer ` from `--sink-token-env`, and the body is the schema-v1 payload JSON itself — what the core-agent sink wraps in its inject envelope, here unwrapped. The exchange, curl-able against your own receiver (both payloads below are real ones captured on the wire during validation drills, abridged): ```sh curl -s -X POST "https://receiver.example.com/lookout/incidents" \ -H "Authorization: Bearer $SINK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"k8s-event","reason":"BackOff","namespace":"default","kind_of_object":"Pod","name":"crashloop-demo","container":"spec.containers{crasher}","uid":"7503ea47-d147-4342-92b2-743a1d88cd4b","message":"Back-off restarting failed container crasher in pod crashloop-demo_default(7503ea47-d147-4342-92b2-743a1d88cd4b)","count":1,"first_seen":"2026-07-24T17:12:00Z","last_seen":"2026-07-24T17:12:00Z","cluster":"local","context":{"node":"kl-m0-control-plane"}}' ``` ```json {"id":"inc-0001"} ``` Later, the sentinel observes the symptom clear and holds stable — the closed-loop outcome record appends to the same context: ```sh curl -s -X POST "https://receiver.example.com/lookout/incidents/inc-0001/events" \ -H "Authorization: Bearer $SINK_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"resolved","reason":"CrashLoopBackOff","namespace":"fixlab","kind_of_object":"Pod","name":"payment-869d9b5594-d6gtm","uid":"6b6d28f1-b811-49dd-86a7-9bb0c3c8468a","fingerprint":"sha256:e2957792a0b3ad9e29db2051dbc69ff01dfe3a52da8dbb6d1331aa44fe946f8b","cluster":"kl-m2","first_seen":"2026-07-25T00:54:07Z","resolved_at":"2026-07-25T00:56:53Z","cleared_after":"1m36s","observed_stable_for":"1m10s","resolution":"recovered","context":{}}' ``` Followups, storm records, and watchboard digests arrive the same way — same endpoint, different `kind`. What a receiver should know: * **You may be stateless.** Ignore the ids you hand out if you like — every payload carries its own identity (`fingerprint`, object coordinates, `kind`), and `lookout` sequences opens before appends. Correlating payloads into per-incident threads is your opportunity, not your obligation. * **A reference receiver already exists.** [`dev/drills/stub-daemon.py`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/stub-daemon.py) is the capture receiver every validation drill uses — a page of stdlib Python implementing the open/append pattern (in the default sink’s core-agent shape) and logging each body as one greppable line. Start there. * **Delivery failures behave exactly as with core-agent**: logged, counted in `inject_errors_total`, re-fired through the dedup retry cooldown. The sink adds no retry semantics of its own. * **`token-burn` needs core-agent.** That source reads the daemon’s usage API; under `--sink=webhook` it idles with a loud startup message naming the source and the reason — never a silent empty watch. core-agent remains the first-class default: enriched sessions an agent wakes up inside of, the [closed loop](/k8s-lookout/concepts/closed-loop/), and the usage-driven `token.burn` source all assume a runtime on the other end — the webhook sink is the door for everyone else, not a replacement. Wiring the daemon is [Connect to core-agent](/k8s-lookout/getting-started/connect-core-agent/); the settled design (and what is deliberately out of scope) is [`docs/agent-sink-design.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/agent-sink-design.md). # MCP setup > lookout mcp — every read command as an MCP tool over stdio or loopback HTTP, and why the HTTP transport is loopback-only. Every read-path command is also exposed 1:1 as an MCP tool: ```sh lookout mcp # stdio: JSON-RPC on stdin/stdout lookout mcp --listen=127.0.0.1:8383 # streamable HTTP on a loopback address ``` This exists because of a hard-learned constraint: distroless images kill `bash + curl`. A distroless core-agent daemon has no shell to run `lookout triage delta` in — MCP is how it calls the same checks natively. The server uses the same kube client bootstrap as the CLI (kubeconfig outside a pod, the ServiceAccount inside one), and tool results carry the exact payload the CLI prints: logfmt findings terminated by the `scanned=/findings=/elapsed=` summary line, passed through the same sanitizer. ## Transports [Section titled “Transports”](#transports) * **stdio (default)** — the transport a daemon uses when it spawns `lookout mcp` as a child process. Diagnostics go to stderr only. * **`--listen=`** — streamable HTTP, for the same-pod case where the daemon and `lookout` run as separate containers sharing the pod’s network namespace. **Non-loopback binds are refused by default**: ```plaintext --listen="0.0.0.0:8383": refusing to bind a non-loopback address. To serve off-host, pass --allow-non-loopback together with --auth-token-file= and --access-log=; without all three lookout mcp is loopback-only (§4.3) ``` A `lookout` reachable off-host hands its cluster read access to the network, so it is not something to open by accident. It is, however, something you can open on purpose — see below. ## Serving off-host [Section titled “Serving off-host”](#serving-off-host) The one deployment shape the loopback rule blocks is the useful one: the MCP server on one host, the agent somewhere else. That is permitted, behind three flags that must all be present: ```sh lookout mcp \ --listen=0.0.0.0:8383 \ --allow-non-loopback \ --auth-token-file=/etc/lookout/mcp-token \ --access-log=/var/log/lookout/mcp-access.log ``` Three rather than one, because each guards a different mistake: * **`--allow-non-loopback`** — a token supplied for a localhost bind must not silently change which interface gets opened. * **`--auth-token-file`** — a bind flag must not open an unauthenticated cluster-read API. * **`--access-log`** — on loopback the log is a debugging convenience; off-host it is the only evidence that exists of who called what. The token is a single shared bearer token, compared in constant time against every request’s `Authorization: Bearer ` header; anything else gets a bare `401` that says nothing about what is behind it. The file may have a trailing newline, must be one line, and must be at least 16 characters — generate one with `head -c 32 /dev/urandom | base64`. Permissions on the file are not checked, because the obvious way to supply it in-cluster is a Secret volume and those mount `0644`. Startup says what it did, on stderr: ```plaintext lookout mcp: serving MCP over HTTP on 0.0.0.0:8383 — REACHABLE OFF-HOST. lookout mcp: bearer-token authentication is REQUIRED; every call is recorded to /var/log/lookout/mcp-access.log. ``` **What this is not.** There is no authorization: every caller presenting the token gets the full advertised tool surface. Narrow it with [`--profile`](#profiles-dont-advertise-what-the-agent-will-never-call) if a caller should not reach everything. mTLS is out of scope — it is the right answer for a production deployment and a much larger piece of work (cert distribution, rotation, a CA story). ## Wiring into a core-agent daemon [Section titled “Wiring into a core-agent daemon”](#wiring-into-a-core-agent-daemon) Register `lookout mcp` in the daemon’s MCP server configuration as a stdio server (spawned command: `lookout`, args: `["mcp"]`), or — when the daemon image cannot exec at all — run `lookout` as a second container in the daemon’s pod with `--listen=127.0.0.1:` and register the HTTP endpoint. In-cluster, the pod’s ServiceAccount needs read RBAC covering the checks you expect agents to call; the sentinel’s shipped ClusterRole (`deploy/12-clusterrole-watcher.yaml`) is a working superset for the common ones. ## Profiles: don’t advertise what the agent will never call [Section titled “Profiles: don’t advertise what the agent will never call”](#profiles-dont-advertise-what-the-agent-will-never-call) Every advertised tool is paid for on **every** model call, whether or not it is ever invoked. The full surface is over 130 KB of JSON schema — roughly 35k tokens per turn — and the cost is not only tokens: an agent choosing among thirty similar-sounding tools chooses worse than one choosing among seven. So the surface is selectable. The default is unchanged — every command, for every client that asks for nothing — and the saving is opt-in: ```sh lookout mcp --profile=triage # the incident surface lookout mcp --profile=audit # the posture surface lookout mcp --tools=all,-k8s_perf_probe # everything but one tool lookout mcp --profile=triage --tools=-k8s_triage_logs lookout mcp --list-tools # what a selection costs, per tool ``` `--profile` and `--tools` are one left-to-right selection, `--profile` first, in the same `all,-x` syntax as `bundle --lists`: `all` (or `full`) adds every tool, a profile name adds its members, a tool name adds one, and a `-` prefix removes. A selection that resolves to zero tools is a usage error — a server with an empty tool list is indistinguishable from a missing one. `lookout mcp --help` prints the profiles with their sizes; each command’s Reference page names the profiles it belongs to. Membership is declared on the command itself, so a new check joins a profile in the same edit that creates it. ## The access log [Section titled “The access log”](#the-access-log) `lookout mcp` is silent by default: it writes nothing but protocol frames, so when an agent’s tool call misbehaves there is no record it happened at all. `--access-log` fixes that with one logfmt line per call: ```sh lookout mcp --access-log=/var/log/lookout/mcp-access.log ``` ```plaintext ts=2026-08-18T14:03:21Z tool=k8s_scan exit=0 dur=1.204s bytes=4096 ts=2026-08-18T14:03:29Z tool=k8s_triage_logs exit=1 dur=312ms bytes=118 ts=2026-08-18T14:03:33Z tool=k8s_triage_logs exit=2 dur=0s bytes=64 ``` `exit` is the §4.2 code the tool call mapped from — `0` a payload, `1` a tool error the model can see, `2` a rejected argument. Calls the schema layer rejects before the command ever runs are logged too; those are exactly the ones worth noticing. The file is created if absent, **appended** if present (a supervisor restart must not erase the evidence from the run that caused it), and created mode `0600` — the tool names alone say which clusters an operator has been reading. If the path cannot be opened, `lookout mcp` exits 2 rather than serving without a log. It is optional on loopback and **mandatory** for an off-host bind. What a line deliberately does *not* carry is the arguments or the response body. The [sanitizer](/k8s-lookout/concepts/sanitization/) guarantees no secret value reaches an output surface; a log that copied payloads would be a second place that guarantee has to hold, audited by nobody. Tool, outcome, and size answer the operational questions — what was called, did it work, what did it cost — without becoming a second data path. ## The tools [Section titled “The tools”](#the-tools) Tool names are the commands’ MCP names — `triage delta` → `k8s_triage_delta`, `bundle` → `k8s_triage_workload` — with input schemas mirrored from the CLI flags (plus a `target` property where a command takes a positional argument). Two conveniences for clients that guess: on every other tool `target` is accepted as a synonym for `workload`, and an argument name the tool does not know is rejected with the nearest one it does — `unknown argument "form" for tool k8s_scan; did you mean "format"? (accepts: …)`. Only the canonical names appear in the schemas. The current surface: | Tool | Command | | ------------------------ | -------------------------------------------------------------- | | `k8s_scan` | [`scan`](/k8s-lookout/reference/scan/) | | `k8s_cluster_health` | [`health`](/k8s-lookout/reference/health/) | | `k8s_triage_workload` | [`bundle`](/k8s-lookout/reference/bundle/) | | `k8s_list_resources` | [`triage list`](/k8s-lookout/reference/triage-list/) | | `k8s_triage_delta` | [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `k8s_triage_logs` | [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `k8s_event_timeline` | [`triage events`](/k8s-lookout/reference/triage-events/) | | `k8s_resource_top` | [`triage top`](/k8s-lookout/reference/triage-top/) | | `k8s_blast_radius` | [`triage radius`](/k8s-lookout/reference/triage-radius/) | | `k8s_recent_changes` | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `k8s_resource_spec` | [`triage spec`](/k8s-lookout/reference/triage-spec/) | | `k8s_triage_status` | [`triage status`](/k8s-lookout/reference/triage-status/) | | `k8s_findings_diff` | [`findings diff`](/k8s-lookout/reference/findings-diff/) | | `k8s_findings_ack` | [`findings ack`](/k8s-lookout/reference/findings-ack/) | | `k8s_state_edges` | [`state edges`](/k8s-lookout/reference/state-edges/) | | `k8s_admission_webhooks` | [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `k8s_workload_identity` | [`state wi`](/k8s-lookout/reference/state-wi/) | | `k8s_volume_conflicts` | [`state volumes`](/k8s-lookout/reference/state-volumes/) | | `k8s_storage_binding` | [`state storage`](/k8s-lookout/reference/state-storage/) | | `k8s_gateway_routes` | [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `k8s_gitops_drift` | [`stab drift`](/k8s-lookout/reference/stab-drift/) | | `k8s_drain_blockers` | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `k8s_perf_probe` | [`perf probe`](/k8s-lookout/reference/perf-probe/) | | `k8s_cloud_stockout` | [`cloud stockout`](/k8s-lookout/reference/cloud-stockout/) | | `k8s_cloud_orphans` | [`cloud orphans`](/k8s-lookout/reference/cloud-orphans/) | | `k8s_cloud_ipspace` | [`cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/) | | `k8s_cloud_quota` | [`cloud quota`](/k8s-lookout/reference/cloud-quota/) | | `k8s_net_probe` | [`net probe`](/k8s-lookout/reference/net-probe/) | | `k8s_audit_workloads` | [`audit workloads`](/k8s-lookout/reference/audit-workloads/) | | `k8s_audit_hardening` | [`audit hardening`](/k8s-lookout/reference/audit-hardening/) | | `k8s_audit_netpol` | [`audit netpol`](/k8s-lookout/reference/audit-netpol/) | | `k8s_audit_cluster` | [`audit cluster`](/k8s-lookout/reference/audit-cluster/) | | `k8s_audit_upgrades` | [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/) | | `k8s_audit_exemptions` | [`audit exemptions`](/k8s-lookout/reference/audit-exemptions/) | Commands added later become tools with no extra wiring — the list the server serves, its schemas and its descriptions are generated from the same command metadata as `--help` and this site’s Reference section, so `tools/list` is always the authoritative surface. The table above is a hand-maintained copy of it, held to the registry by a test. Tool descriptions are written as micro-skills (“when to reach for this”), which is most of what an agent needs; the workflow-level decision tree ships as skills in [`skills/`](https://github.com/go-steer/k8s-lookout/tree/main/skills). The loop is smoke-tested end to end in a live drill: `initialize` → `tools/list`, then `tools/call k8s_cluster_health` returned `isError:false` with the identical scorecard payload the CLI prints, summary line included. # Tutorial: catch a bad deploy in the act > A ~20-minute walkthrough on a disposable kind cluster — the sentinel wired to a capture stub, a staged crashloop, a user-invisible bad deploy, and the closed loop from detection to verified resolution. This walkthrough stands up everything on a disposable local cluster and stages two real failures, so you can watch both halves of `lookout` work: the sentinel catching trouble and opening incidents, and the read-path CLI investigating them. Every command is copy-runnable — by you or by an AI agent you hand this page to — and every output block below is real output captured from live runs of these exact commands, never invented (abridged where marked). **Takes:** \~20 minutes. **Needs:** `docker`, `kind`, `kubectl`, and Go 1.26+. **Safety:** the cluster is disposable, and every script refuses to run unless your current kubectl context is the tutorial cluster (`kind-lookout-examples`). ## What you will stand up [Section titled “What you will stand up”](#what-you-will-stand-up) * A 3-node kind cluster with metrics-server (`examples/kind/up`). * The **sentinel** (`lookout watch`), deployed from the same shipped manifests a production install uses, tuned to drill-speed windows. * A **capture stub** standing in for a core-agent daemon: a one-page Python server behind a Service named `core-agent:7777` that logs every session-create and inject it receives — so you can read the exact wire traffic a real agent daemon would get. * A small demo app to break: `web`, `api` (with a PDB), `worker`, and a `vantage` pod for in-cluster HTTP checks. ## 1. Stand it up [Section titled “1. Stand it up”](#1-stand-it-up) ```sh go install github.com/go-steer/k8s-lookout/cmd/lookout@latest git clone https://github.com/go-steer/k8s-lookout && cd k8s-lookout examples/kind/up # cluster + metrics-server examples/sentinel/up # RBAC + sentinel + capture stub kubectl apply -f examples/workloads/ # the demo app ``` `examples/sentinel/up` ends by printing the sentinel’s startup log. Read it — every armed stage announces itself, and every degradation is a named line, not silence (abridged): ```console 2026/07/31 12:18:37 recovery: tracking enabled (stable-for=1m0s, tick=15s) 2026/07/31 12:18:37 storm: correlation enabled (window=1m0s, min=3) 2026/07/31 12:18:37 lookout watch: starting on cluster "lookout-examples" → daemon http://core-agent.agent-triage.svc.cluster.local:7777 (mode=per-incident, owner=lookout-examples@local) 2026/07/31 12:18:37 capacity: provider scale-decision sub-source disabled: unavailable reason="no cloud provider configured" — Events + status-ConfigMap sub-sources still fire on scaleup failures, without the structured why 2026/07/31 12:18:37 storm: topology graph ready (88 nodes, 115 edges) — blast-radius correlation armed ``` Then take the baseline. Healthy resources print nothing; every category answers; the summary line is mandatory: ```sh lookout health ``` ```console kind=health.category severity=info reason=Unavailable message="requires cloud provider metrics; no cloud provider configured" category=control-plane status=unavailable kind=health.category severity=info category=nodes status=healthy kind=health.category severity=info category=crashloops status=healthy kind=health.category severity=info category=pending status=healthy kind=health.category severity=info category=rollouts status=healthy kind=health.category severity=info category=storage status=healthy kind=health.category severity=info category=addons status=healthy kind=health.category severity=info category=quota status=healthy kind=health.category severity=info category=certs status=healthy kind=health.category severity=info category=webhooks status=healthy scanned=35 findings=10 elapsed=87ms ``` In a second terminal, follow the wire — this is what a real agent daemon would be receiving from here on: ```sh kubectl -n agent-triage logs deploy/stub-daemon -f ``` ## 2. Break something simple: a crashloop [Section titled “2. Break something simple: a crashloop”](#2-break-something-simple-a-crashloop) ```sh examples/scenarios/crashloop/inject # worker's command → exit(1) after 2s ``` Within a minute or two, the stub log shows the sentinel opening a per-incident session and injecting the signal — **with a pre-warmed evidence bundle already attached** (sanitized spec, blast radius, distilled logs), so an agent’s first tool calls are pre-answered (abridged): ```console SESSION-CREATE sid=stub-sess-0004 caller=… token=present INJECT sid=stub-sess-0004 kind=k8s-event token=present body={"message":"{\"kind\":\"k8s-event\",\"reason\":\"BackOff\",\"namespace\":\"lookout-demo\",\"kind_of_object\":\"Pod\",\"name\":\"worker-7cd49696bf-pl7lh\",\"container\":\"spec.containers{worker}\",… \"message\":\"Back-off restarting failed container worker in pod worker-7cd49696bf-pl7lh_lookout-demo(…)\",… \"enrichment\":{\"bundle\":\"kind=bundle.target … sections=spec,radius,logs\n kind=spec.container … container=worker image=python:3.12-alpine requests=…,memory=32Mi limits=…,memory=64Mi\n kind=radius.neighbor … kind_of_object=Node name=lookout-examples-worker2 … relation=downstream hop=1\n kind=log.template … template=\"worker: tick\" count=3 …\n overflow section=edges cmd=\"lookout state edges --workload=Deployment/lookout-demo/worker\"\"}}"} INJECT sid=stub-sess-0004 kind=objectstate.restart_burst token=present body={"message":"{\"kind\":\"objectstate.restart_burst\",\"reason\":\"restart_burst\",\"namespace\":\"lookout-demo\",\"kind_of_object\":\"Pod\",\"name\":\"worker-7cd49696bf-pl7lh\",… \"message\":\"container restart count grew by 3 within 10m0s (total=3)\",… \"severity\":\"warning\",\"fingerprint\":\"sha256:e869fa95…\",…}"} ``` The sentinel’s own log shows the decisions: the fire, dedup absorbing the repeats, and a leading-indicator source joining the same incident rather than opening a second one (abridged): ```console 2026/07/31 12:09:33 dedup BackOff pod=lookout-demo/worker-7cd49696bf-pl7lh (count=2, window active) 2026/07/31 12:09:56 dedup restart_burst pod=lookout-demo/worker-7cd49696bf-pl7lh (count=3, window active) 2026/07/31 12:09:57 followup restart_burst lookout-demo/worker-7cd49696bf-pl7lh → sid=stub-sess-0004 (cross-source join: objectstate joined a k8s-event-opened incident) 2026/07/31 12:10:00 dedup BackOff pod=lookout-demo/worker-7cd49696bf-pl7lh (count=4, window active) ``` Now investigate from the read path, like an agent would: ```sh lookout triage events --namespace=lookout-demo ``` ```console kind=event.normal severity=info namespace=lookout-demo kind_of_object=Pod name=worker-7cd49696bf-pl7lh reason=Started message="Container started" count=5 first_seen=2026-07-31T12:09:12Z last_seen=2026-07-31T12:10:42Z source=kubelet kind=event.warning severity=warning namespace=lookout-demo kind_of_object=Pod name=worker-7cd49696bf-pl7lh reason=CrashLoopBackOff message="Back-off restarting failed container worker in pod worker-7cd49696bf-pl7lh_lookout-demo(…)" count=4 first_seen=2026-07-31T12:09:17Z last_seen=2026-07-31T12:10:45Z source=kubelet scanned=46 findings=42 elapsed=28ms ``` ```sh lookout bundle --workload=Deployment/lookout-demo/worker ``` ```console kind=bundle.target severity=info namespace=lookout-demo kind_of_object=Deployment name=worker workload=Deployment/lookout-demo/worker pods=1 sections=spec,delta,edges,radius,logs kind=spec.container severity=info namespace=lookout-demo kind_of_object=Deployment name=worker section=spec container=worker image=python:3.12-alpine requests="cpu=10m,memory=32Mi" limits="cpu=100m,memory=64Mi" kind=spec.condition severity=warning namespace=lookout-demo kind_of_object=Deployment name=worker reason=MinimumReplicasUnavailable message="Deployment does not have minimum availability." section=spec condition="Available=False" since=2026-07-31T12:10:45Z kind=workload.rollout severity=critical namespace=lookout-demo kind_of_object=Deployment name=worker reason=RolloutIncomplete section=delta desired=1 ready=0 updated=1 available=0 kind=radius.neighbor severity=info kind_of_object=Node name=lookout-examples-worker2 section=radius relation=downstream hop=1 scanned=158 findings=13 elapsed=154ms ``` Or skip the manual reads and hand it to your agent mid-crash: > Something keeps restarting in the lookout-demo namespace — find it and tell me why. Check its answer, then heal the workload: ```sh examples/scenarios/crashloop/verify # asserts the wire + read-path evidence examples/scenarios/crashloop/revert ``` ## 3. The flagship: a bad deploy your users cannot see [Section titled “3. The flagship: a bad deploy your users cannot see”](#3-the-flagship-a-bad-deploy-your-users-cannot-see) `web` runs `maxUnavailable=0`, so rolling it to a broken image parks one crashing surge pod next to the healthy old revision. Dashboards stay green; users keep getting 200s. This is the failure class the sentinel exists for. ```sh examples/scenarios/bad-rollout/inject ``` ```console T0 (rollout onset): 2026-07-31T12:23:31Z ``` `verify` proves the user-invisibility claim mid-stall, through the Service, from inside the cluster: ```console ▸ proving user-invisibility: 5 requests through the Service HTTP 200 HTTP 200 HTTP 200 HTTP 200 HTTP 200 ✓ 5/5 requests answered 200 mid-stall ``` At 96 seconds after onset — ahead of `progressDeadlineSeconds` and ahead of any user noticing — the sentinel fires at the Deployment altitude and opens an enriched incident session: ```console 2026/07/31 12:25:08 fire rollout_stall pod=lookout-demo/web → sid=stub-sess-0007 (mode=per-incident) ``` On the wire, the payload says exactly what an agent needs to decide (abridged): ```console INJECT sid=stub-sess-0007 kind=rollout.stall token=present body={"message":"{\"kind\":\"rollout.stall\",\"reason\":\"rollout_stall\",\"namespace\":\"lookout-demo\",\"kind_of_object\":\"Deployment\",\"name\":\"web\",… \"message\":\"rollout stalled: new ReplicaSet web-59ffb5fc9c new_ready=0/1 old_ready=2/2 elapsed=1m36s — new-revision pods failing while the old revision stays healthy (probable bad deploy, fired ahead of progressDeadlineSeconds)\",… \"cluster\":\"lookout-examples\",… ``` The read path sees it too — note the altitude trap this catches: `ready=2` looks fine at the pod level, and the rollouts category is degraded anyway: ```sh lookout health ``` ```console kind=health.category severity=warning category=rollouts status=degraded total=1 top="workload.rollout lookout-demo/web" kind=workload.rollout severity=warning namespace=lookout-demo kind_of_object=Deployment name=web reason=RolloutIncomplete fingerprint=sha256:f954cac0… category=rollouts desired=2 ready=2 updated=1 available=2 scanned=36 findings=11 elapsed=91ms ``` And “what changed?” is one command — the new template revision is named, with its image (abridged): ```sh lookout triage changes --workload=Deployment/lookout-demo/web ``` ```console kind=change.rollout severity=info namespace=lookout-demo kind_of_object=ReplicaSet name=web-59ffb5fc9c reason=NewReplicaSet message="new template revision created inside the window" at=2026-07-31T12:12:42Z relation=upstream origin=api revision=4 image=python:3.11-alpine scanned=229 findings=5 elapsed=114ms source=live-approximation window=2026-07-31T11:55:38Z..2026-07-31T12:25:38Z ``` Agent prompt to try instead: > We just shipped web in lookout-demo and dashboards look fine, but the sentinel opened an incident. Is it real? Should we roll back? Now fix it and watch the loop close. `revert` runs `kubectl rollout undo` and waits — the sentinel observes the recovery hold stable, then injects a verified `resolved` **into the same session**, with proof attached. No agent polling, no human guessing it is safe to close: ```sh examples/scenarios/bad-rollout/revert ``` ```console 2026/07/31 12:27:07 resolved rollout_stall pod=lookout-demo/web → sid=stub-sess-0007 (resolution=recovered, cleared_after=53.602705831s, stable_for=1m6.298204053s) ``` ```console INJECT sid=stub-sess-0007 kind=resolved token=present body={"message":"{\"kind\":\"resolved\",\"reason\":\"rollout_stall\",\"namespace\":\"lookout-demo\",\"kind_of_object\":\"Deployment\",\"name\":\"web\",… \"fingerprint\":\"sha256:35bf767b…\",\"cluster\":\"lookout-examples\",… ``` That is the whole story on one screen: detected before users noticed, enriched at open, correlated at the right altitude, and closed with observed proof. ## 4. Where next [Section titled “4. Where next”](#4-where-next) * **Eight more failure classes** — OOM, cert expiry, PDB gridlock, empty endpoints, node death (one storm session, not thirty), and more: each has the same `inject`/`verify`/`revert` shape under [`examples/scenarios/`](https://github.com/go-steer/k8s-lookout/tree/main/examples/scenarios), and `examples/e2e` runs the non-destructive set unattended. Re-runs are deliberately quieter — dedup, `resolved.reverted`, and storm absorption are the sentinel working as designed. * **Test your own agent against it** — inject a scenario, hand your agent the prompt from its README, compare its findings with `verify`’s: [`examples/agent-harness.md`](https://github.com/go-steer/k8s-lookout/blob/main/examples/agent-harness.md). * **Deploy for real** — the same manifests, minus the stub, plus a real sink: [Deploy the sentinel](/k8s-lookout/getting-started/deploy/), then [Connect to core-agent](/k8s-lookout/getting-started/connect-core-agent/) or [any webhook receiver](/k8s-lookout/getting-started/integrations/). * **Clean up** — `examples/kind/down`. # Not found > The page you were looking for doesn't exist. Try the Overview or the search. ## Sorry, that page doesn’t exist. [Section titled “Sorry, that page doesn’t exist.”](#sorry-that-page-doesnt-exist) A few starting points: * [Overview](/k8s-lookout/) — what `k8s-lookout` is. * [Getting started](/k8s-lookout/getting-started/) — install and first commands. * [Reference](/k8s-lookout/reference/) — every command, the sentinel’s flags, signal kinds, metrics. You can also search the site with `Ctrl+K` / `Cmd+K`. # Concepts: how lookout thinks > The mental model behind lookout — the two halves, one map of the cluster, signals instead of alerts, sessions instead of pages, the safety stance, and who is trusted to do what. This page is the ramp into the detail pages: the mental model behind `lookout`’s behavior, in plain terms, before any schema or flag. Read it if you have run a few commands (or are deciding whether to deploy the sentinel) and want to understand why the output looks the way it does. Nothing here requires having read the design spec. ## Two halves [Section titled “Two halves”](#two-halves) `lookout` splits along one line: are you asking the cluster a question, or is the cluster telling you something? The first half is a set of one-shot diagnostic commands you (or your agent) run mid-investigation — “what’s broken”, “what changed”, “show me the logs, distilled” — each answering one question and always ending with an explicit summary line, so “nothing wrong” never looks like a command that silently failed. The second half is the **sentinel** (`lookout watch`), a long-running process deployed in the cluster that notices trouble as it develops — a rollout that stalled, memory climbing toward a limit, a certificate counting down — and hands your agent an incident with the relevant context already attached. Both halves are the same binary running the same underlying checks; the details are in [Architecture: two paths, one binary](/k8s-lookout/concepts/architecture/). ## One map of the cluster [Section titled “One map of the cluster”](#one-map-of-the-cluster) Most incident questions are about relationships, and the Kubernetes API does not answer those directly: finding everything connected to one pod takes a dozen separate lookups. `lookout` maintains an in-memory map — the **topology graph** — that answers “what does this workload depend on, who is affected if it breaks, and what changed around it” in a single query, against the live cluster or against any past moment the sentinel recorded. How the graph is built, kept consistent, and queried back in time is [The topology graph](/k8s-lookout/concepts/topology-graph/). ## Fewer, richer signals — not more alerts [Section titled “Fewer, richer signals — not more alerts”](#fewer-richer-signals--not-more-alerts) Traditional monitoring optimizes for detection: fire an alert per symptom and let a human sort the pile. `lookout` optimizes for investigation: fewer observations, each carrying more, already correlated. Everything it emits — a scan finding, a sentinel incident — has one fixed shape (a **signal**) and carries a **fingerprint**, a stable hash that names the *class* of problem rather than the individual occurrence, so the same incident seen by a scan and by the sentinel is recognized as one thing, not reported twice. The schema, the severity classes, and the fingerprint recipe are in [Signals & fingerprints](/k8s-lookout/concepts/signals-and-fingerprints/). ## Sessions, not pages [Section titled “Sessions, not pages”](#sessions-not-pages) When the sentinel finds something serious, it does not page — it opens a **session**: an ongoing record of one incident that your agent joins. Follow-up observations land in the same session; thirty pods evicted by one dead node become one session, not thirty; warning-level noise is batched into a shared digest instead of interrupting anyone; and when the symptom stays clear, the sentinel writes a verified “resolved” into the session — so every incident ends with an observed outcome, not a human guessing it is safe to close. That whole lifecycle is [The closed loop](/k8s-lookout/concepts/closed-loop/). ## Nothing secret ever leaves [Section titled “Nothing secret ever leaves”](#nothing-secret-ever-leaves) Every output surface passes through one sanitizer before anything is printed, returned over MCP, or injected into a session: Secret values render as names and sizes, credential-shaped strings are redacted, and the topology graph never stores secret values at all. This is enforced by CI tests that plant fake credentials and fail the build if one ever appears in output. What exactly is masked, how it is proven, and the documented limits are in [Sanitization guarantees](/k8s-lookout/concepts/sanitization/). ## Runs anywhere, degrades loudly [Section titled “Runs anywhere, degrades loudly”](#runs-anywhere-degrades-loudly) Most of `lookout` is plain Kubernetes and works on any conformant cluster, a local kind cluster included. The cloud-specific parts (GKE/GCP today) sit behind a strict boundary, and a missing capability always announces itself — an explicit “unavailable” finding or a named startup error, never a crash and never silence an agent could mistake for “all clear”. The exact split is [Portability & providers](/k8s-lookout/concepts/portability/). ## Who is trusted to do what [Section titled “Who is trusted to do what”](#who-is-trusted-to-do-what) The trust model is an escalation with a hard stop. `lookout` *observes*: scans produce findings, the sentinel turns findings into sessions. Your agent *decides*: it reads those sessions, diagnoses, and records its judgment. And when something must actually change in the cluster, the agent *acts through its own permission gates* — `lookout` holds read-only credentials and never writes to the cluster, so the blast radius of the eyes is zero by construction. How an agent’s judgment feeds back into routing and scan output is part of [The closed loop](/k8s-lookout/concepts/closed-loop/). *** The detail pages are best read in the order above. The normative specification behind all of them is [`docs/DESIGN.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/DESIGN.md) in the repository; these pages are the user-facing distillation. # Architecture: two paths, one binary > The read-path (one-shot diagnostic commands) and the watch-path (the resident sentinel) share one binary, one output contract, and one check implementation. This page explains how `lookout` is put together: two ways in — one-shot commands you run mid-investigation, and a resident watcher that lives in the cluster — and why both share a single binary. If you have wondered why the output is so terse, or why a healthy resource prints nothing, the answer is one of a handful of principles everything else follows from: * **Token density.** Raw telemetry costs money, evicts context, and slows the loop. Deterministic pre-compression — dedup by template, strip nominal state — sits between every high-volume source and the context window. * **Determinism.** A compiled graph traversal does not hallucinate an EndpointSlice. Checks are exact; the model reasons over their output, never re-derives it. * **Fewer round trips.** One dense, correlated payload beats five sequential tool calls. This pushes toward fewer, wider tools (`lookout bundle`, `lookout health`) and pre-warmed incident sessions. * **Leading indicators over autopsies.** A Kubernetes Event is the autopsy. State transitions, trend slopes, and countdowns run ahead of it — the watch-path exists to surface them first. * **Zero nominal state, never ambiguous silence.** Healthy resources are omitted; every invocation ends with an explicit summary line (`scanned=412 findings=0 elapsed=1.2s`) so “cluster healthy” is distinguishable from “broken invocation”. * **Read-only in the cluster.** Tools inspect and diagnose. The only sanctioned write actions — GitOps PRs and quota-increase requests — route through the `core-agent` daemon’s permission gate, never raw write authority. ## The read-path [Section titled “The read-path”](#the-read-path) One-shot diagnostic commands an agent (or a human) runs mid-investigation: `triage`, `state`, `stab`, `perf`, `cloud`, `net`, plus the composed `bundle` and `health`. All of them share a single check implementation, consumed by three invocation surfaces: ```plaintext CLI (lookout ) ──┐ MCP (lookout mcp) ───────┼──→ shared checks ──→ sanitizer + output envelope ──→ stdout / MCP response enrichment (inside lookout watch) ───┘ │ / inject attachment └──→ topology graph (radius, edges, changes, --at) ``` * **CLI** — for deployments where the agent has a shell. * **MCP** — `lookout mcp` serves every registered command 1:1 as an MCP tool (stdio or loopback HTTP), for distroless daemons that cannot shell out. * **In-process enrichment** — the sentinel calls the same checks directly to pre-warm incident sessions; no fork/exec, shared informer cache. Whatever the surface, the output contract is identical: one finding per line (logfmt by default, `--format=json` for JSON-per-line), keys in fixed order, healthy resources silent, a mandatory final summary line. Exit `0` is pure payload on stdout; exit `1` puts diagnostics on stderr only, so a captured stream never corrupts a context window; exit `2` is a usage error. ## The watch-path [Section titled “The watch-path”](#the-watch-path) `lookout watch` is a resident per-cluster **sentinel**: pluggable signal sources feeding one pipeline, ending in injects to `core-agent` sessions. ```plaintext sources ──→ filter ──→ dedup ──→ storm correlation ──→ severity routing ──→ enrichment ──→ inject ──→ core-agent sessions ``` The sources (enabled per deployment via `--sources`) cover reactive and leading classes: `k8s-events` (the reactive baseline), `object-state` (transitions: node flaps, emptied endpoints, gridlocked PDBs), `rollout` (stalls caught while the old revision still serves), `workload` (failed Jobs and CronJob schedules that stop producing runs), `saturation` (slope → ETA forecasts), `degradation` (ready-ratio trends), `expiry` (certificate countdowns), `capacity` and `quota` (cluster-autoscaler and project-quota seams), `notifications` (the provider’s own upgrade events and security bulletins), and `token-burn` (agent spend as a saturation dimension). The full flag surface is in [Reference → `lookout watch`](/k8s-lookout/reference/watch/); every signal kind is cataloged in [Reference → Signal kinds](/k8s-lookout/reference/signal-kinds/). A sentinel with `--store` also keeps a bounded, TTL’d SQLite file: every emitted signal with its routing outcome, plus topology snapshots and the per-delta change log. That store is what powers point-in-time queries (`--at`) and the triaged-reality merge described in [The closed loop](/k8s-lookout/concepts/closed-loop/). ## Why one binary [Section titled “Why one binary”](#why-one-binary) Earlier designs specced a matrix of per-check binaries; each would have statically linked client-go and cloud SDKs into a multi-gigabyte image with dozens of drifting flag surfaces. Instead there is one multicall binary, `lookout`, busybox-style: one release, one image, one client bootstrap, one output-envelope implementation — and an agent discovers the entire surface from one `--help`. The same discipline applies to documentation: command metadata (name, flags, when-to-use line, output-field glossary) is declared once and generates `--help`, the MCP schemas, the skill reference docs in [`skills/`](https://github.com/go-steer/k8s-lookout/tree/main/skills), and the [Reference](/k8s-lookout/reference/) section of this site. Drift tests fail CI when any generated surface goes stale. ## The contract with core-agent [Section titled “The contract with core-agent”](#the-contract-with-core-agent) Three boundaries, all pre-existing: the sentinel posts sessions and injects to the daemon’s HTTP API; the `token-burn` source reads the daemon’s cost stack; and fleet-level tooling consumes the [frozen signal schema](/k8s-lookout/concepts/signals-and-fingerprints/). Fleet scope is explicitly out of scope here — `lookout` deploys per cluster (or per project, for the quota source), and cross-cluster rollup joins signals, not graphs. # The closed loop > Recovery injects, storm correlation, the watchboard, and triage-status records — sessions with verified outcomes instead of alerts. Most monitoring tools send alerts: one-shot notifications that someone must collect, connect, and eventually silence. When `lookout`’s sentinel spots a problem it instead opens a **session** — an ongoing record of one incident that accumulates the follow-up observations, the diagnosis, and finally proof the problem is actually gone. This page explains the four mechanisms that make that work; for the inventory of what’s watched in the first place, see [What the sentinel watches](/k8s-lookout/detect/sentinel/). ## Recovery injects: fix-verify without polling [Section titled “Recovery injects: fix-verify without polling”](#recovery-injects-fix-verify-without-polling) The dedup cache binds each incident to its session. Each source that can observe a symptom can also observe its absence — pod Ready and restart-stable, rollout completed, endpoints back to full ratio, cert renewed, node Ready again. When a bound incident’s symptom stays clear for `--recovery-stable-for` (default 5m), the sentinel injects `kind=resolved` into the *same session*, carrying `cleared_after`, `observed_stable_for`, and a structured `resolution` (`recovered` or `object_deleted`). Recurrence within the window fires `kind=resolved.reverted` instead. From a live drill: a crashlooping pod’s ConfigMap was patched, nothing else touched — 76 seconds later (stability window + one tracker tick) the incident’s session received, with zero polling by anyone: ```json {"kind":"resolved","reason":"CrashLoopBackOff","namespace":"fixlab", "kind_of_object":"Pod","name":"payment-869d9b5594-d6gtm", "cleared_after":"1m36s","observed_stable_for":"1m10.695183392s", "resolution":"recovered", "…":"…"} ``` This closes the fix-and-verify loop from the signal side: the agent no longer polls to confirm its fix stuck, sessions can auto-conclude — and every incident becomes a trajectory with an externally verified outcome (symptom → diagnosis → action → result), harvestable as labeled data because outcome records are schema-stable structs, never prose. ## Storm correlation: one session, not thirty [Section titled “Storm correlation: one session, not thirty”](#storm-correlation-one-session-not-thirty) A naive per-incident model turns one node failure into \~30 sessions for 30 evicted pods. With storm correlation on (`--storm` defaults to auto: on whenever the graph grants are present), a second-level correlation window groups new incidents sharing a **blast-radius key** — the nearest common ancestor in the [topology graph](/k8s-lookout/concepts/topology-graph/) (node, owner chain, shared ConfigMap/PVC, namespace) — into one `kind=storm` session: *“Node X NotReady; N pods affected across M namespaces; representative incidents attached.”* Members are recorded as `kind=storm.member` followups instead of opening sessions; incidents that fired before the formation threshold are superseded into the storm (`kind=storm.member_superseded` pointers, dedup bindings rebound so their followups and outcomes route to the storm). As membership grows, `kind=storm.update` refreshes the headline size. Recovery composes with it: member resolutions flow into the storm session, and the storm’s own aggregate `resolved` fires once all members — including the node — clear. The measured drill is the [node-failure guide](/k8s-lookout/guides/node-failure/). ## The watchboard: warning noise, bounded [Section titled “The watchboard: warning noise, bounded”](#the-watchboard-warning-noise-bounded) Warning-class signals batch into a shared **watchboard** session as `kind=watchboard.digest` injects (flushed on `--watchboard-batch` or `--watchboard-flush`, whichever first). The board rotates by **size**, not calendar: after `--watchboard-rotate` digests (default 200), the next flush opens a successor session and the old one closes with a `kind=watchboard.rotated` lineage pointer. Size-based rotation bounds the *agent’s* cost of consuming the board regardless of how noisy the cluster is, while a quiet cluster keeps one session for months. Incidents bound to a rotated board keep their followups and outcomes routing there — rotation never orphans an open fix-verify loop. The full rationale is [`docs/watchboard-rotation-design.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/watchboard-rotation-design.md). ## Triage-status records: scans report triaged reality [Section titled “Triage-status records: scans report triaged reality”](#triage-status-records-scans-report-triaged-reality) An incident agent triages a crashloop at 08:00; a health scan at 08:15 must not re-report it as a fresh unknown, re-page, or re-burn tokens re-deriving the diagnosis. Raw telemetry says “broken”; *triaged reality* says “diagnosed, PR open, downgraded”. The mechanism: the diagnosing agent writes a compact record — status, root-cause hypothesis, action taken, and optionally a `severity_override` — keyed by the incident’s `fingerprint` and resource, via [`lookout triage status`](/k8s-lookout/reference/triage-status/) against the sentinel’s store. Three consumers honor it: * **`lookout health` and `bundle --store`** join open findings against the records, so scan output carries the diagnosis and paper trail (`triage_status=`, `triage_root_cause=`, `triage_action=`, `triage_session=`), and the scorecard severity downgrades with the agent’s judgment. * **Sentinel routing** honors `severity_override`: a downgraded incident’s next dedup cycle routes to the watchboard instead of re-paging; `escalated` keeps it hot. If a downgraded incident’s recurrence rate spikes, the sentinel emits `kind=triage.regressed` *evidence* into the bound session — it never overrides the agent’s judgment automatically. * **Lifecycle is automatic:** when recovery observes the symptom clear, the record flips to `resolved` — no manual TTL bookkeeping. `--status` accepts only the agent-written states (`investigating|triaged|actioned|escalated`); `resolved` is reserved for the sentinel’s observed-stability flip, so the outcome labels stay trustworthy. The [capacity guide](/k8s-lookout/guides/capacity-quota/) shows the whole flow live. ## Why sessions, not alerts [Section titled “Why sessions, not alerts”](#why-sessions-not-alerts) Put together: a symptom opens one session (not thirty), pre-warmed with the context the first tool calls would have fetched; followups, correlated observations from other sources, and the agent’s own diagnosis accumulate in it; routing respects what has already been triaged; and the world — not the agent — declares the outcome. An alert asks a human to find out what happened. A `lookout` session already knows, and can prove when it’s over. # Portability & providers > What runs on any conformant Kubernetes cluster, what needs a cloud provider, and how absence degrades loudly instead of silently. You do not need GKE — or any cloud — to use `lookout`: around 80% of the suite is pure `client-go` and works on **any conformant Kubernetes cluster**, a local kind cluster included. This page explains exactly which pieces do need a cloud provider, and how a missing capability announces itself plainly instead of erroring out or staying silent — the surface is small, enumerable, and walled off behind a provider boundary. ## The split [Section titled “The split”](#the-split) Portable everywhere (vanilla Kubernetes, kind included): * the entire `triage` group, `state edges|webhooks|volumes`, `stab drift|drain`, `bundle`, `health`, `net probe`; * the topology graph and everything built on it; * the sentinel sources `k8s-events`, `object-state`, `rollout`, `workload`, `saturation` (metrics.k8s.io + kubelet stats), `degradation`, `expiry`, and `token-burn`. ## GKE Autopilot [Section titled “GKE Autopilot”](#gke-autopilot) Autopilot runs everything above with one platform limitation: GKE Warden denies `nodes/proxy` to **every** principal — including cluster-admin — so the saturation source’s PVC dimension (kubelet stats-summary reads) cannot work there, and no RBAC grant can change that. The startup probe recognizes the platform denial: saturation still enables with CPU and memory forecasting (`metrics.k8s.io` works normally on Autopilot) and the log reports the PVC dimension degraded, quoting the authorizer’s reason. This holds under `--sources=auto` and an explicit list alike — the `nodes/proxy` read is an *optional* requirement, so only missing REQUIRED grants make an explicit list fail fast. Provider-gated (GKE/GCP today): * the `cloud` command group (`stockout|orphans|ipspace|quota`); * `state wi` (Workload Identity verification) and the `perf probe` metric packs (Cloud Monitoring is the only metrics backend so far); * the `quota` and `notifications` sources, and one of the capacity source’s sub-seams (below). ## The boundary is architectural, and tested [Section titled “The boundary is architectural, and tested”](#the-boundary-is-architectural-and-tested) Check and source code never imports cloud SDKs; cloud-touching functionality asks a `Provider` interface for capabilities (metrics, quota, stockout, orphans, ipspace, workload identity). The **default build links no cloud SDK at all** — a CI test builds the default binary and scans its symbol table for GCP package markers, so the isolation is pinned, not aspirational. The GKE provider compiles in behind build tags (`gke` / `allproviders`). Released images follow the same split, from one Dockerfile with only `BUILD_TAGS` differing: `ghcr.io/go-steer/lookout:` (the provider-free default) and `ghcr.io/go-steer/lookout:-gke` (with the GKE provider compiled in). ## No provider → explicit, not broken [Section titled “No provider → explicit, not broken”](#no-provider--explicit-not-broken) A missing provider is never a crash and never silence: * Provider-gated commands exit 0 with an explicit finding — `kind=cloud.unavailable reason=CapabilityUnavailable … provider=none` — and a summary-line marker, so an agent can tell “no cloud provider here” from “swept and clean”. * Provider-gated sources refuse loudly at startup: `--sources=…,quota` in the default binary names the missing provider instead of running an empty watch. The same fail-loudly rule applies to RBAC: a source whose scope the ServiceAccount cannot support is a named startup error, never a silently empty informer. * `lookout mcp` and `--help` mark or omit unavailable commands. ## Degradation inside features [Section titled “Degradation inside features”](#degradation-inside-features) The boundary is per-capability, not per-command: * The **capacity source** runs on the upstream-portable cluster-autoscaler seams everywhere — `NotTriggerScaleUp`/`TriggeredScaleUp` Events and the `cluster-autoscaler-status` ConfigMap — so scale-up failures fire on any CA-running cluster. The third seam, structured provider scale decisions naming the *why* (`GCE_STOCKOUT`, `GCE_QUOTA_EXCEEDED`), lights up only with the GKE provider. On a vanilla cluster the startup log says exactly that, and the portable seams keep firing. * `triage top` answers point-in-time from metrics.k8s.io everywhere; `--history` needs the provider metrics backend and says so. * The `health` scorecard’s control-plane category reports `unavailable` with a reason on clusters without provider metrics; the other categories answer regardless. ## Other clouds [Section titled “Other clouds”](#other-clouds) A future EKS/AKS provider is a new provider implementation — IRSA is the `state wi` analog, capacity-insufficiency events the stockout analog — with no engine, schema, or skill changes. A Prometheus metrics backend for `perf probe` is deferred until a non-GKE consumer materializes, but the pack queries avoid Cloud-Monitoring-only constructs where a PromQL equivalent is obvious. # Sanitization guarantees > What is stripped and masked on every output surface, how CI enforces it with golden tripwires, and the documented recall gaps. Anything `lookout` emits can end up in a model’s context window, a chat transcript, or a log file — places a database password must never appear. This page explains what is stripped or masked before any output leaves the process, how CI proves it, and the guarantee’s honest limits. The mechanism is a single sanitizer applied in the emit layer, before anything reaches stdout, an MCP response, or a session inject — not opt-in per tool, not skippable by a new command that forgets to call it. ## What is stripped and masked [Section titled “What is stripped and masked”](#what-is-stripped-and-masked) * **Secret material is masked everywhere.** `Secret.data` values render as key names plus byte sizes only (`keys=password(19B)`); env vars sourced from Secrets render as the *reference* (`DB_PASSWORD=secretKeyRef:checkout-db.password`), never the value; credential-shaped strings (key-anchored values, JWTs, PEM blocks) become `[REDACTED]` wherever they appear. * **System metadata is stripped.** `managedFields`, `resourceVersion`, `uid`, and noisy status are removed; defaulted fields are elided. This is the “kubectl describe, but token-dense and secret-safe” contract of `triage spec`. * **The topology graph never stores secret values at all** — only names, keys, and content hashes, so `triage changes` can report “secret db-credentials changed” without ever having held the payload. ## How it is enforced [Section titled “How it is enforced”](#how-it-is-enforced) Two layers of proof, not policy: * **Golden tripwire tests in CI.** Fixtures plant secrets in every position the sanitizer knows about — env, envFrom, volumes, annotations — and a payload containing an unmasked credential fixture fails CI. The fixtures are the promise: adding a new output surface without sanitizer coverage breaks the build. * **Live drill evidence.** A live validation drill planted a marker value (`SUPERSECRETVALUE_M1`) in a cluster Secret, mounted it as env in a broken workload, and ran the full investigation surface over it — bundle, spec, edges, health, and a complete MCP session. Every captured stdout and stderr byte was then grepped for the marker and its base64 form: ```plaintext $ grep -r SUPERSECRETVALUE_M1 /tmp/kl-m1-evidence/ # → no matches (exit 1) $ grep -r U1VQRVJTRUNSRVRWQUxVRV9NMQ /tmp/kl-m1-evidence/ # base64 form → no matches (exit 1) ``` The value’s only traces in any output were the reference and the length. ## The documented gaps — honest scope [Section titled “The documented gaps — honest scope”](#the-documented-gaps--honest-scope) The guarantee covers **what `lookout` renders from cluster objects**. Two recall limits are documented rather than papered over: * **Free-form application logs.** If an application prints its own secret into its log stream, `triage logs` masks it only when it is credential-shaped (key-anchored, JWT, PEM, …). An arbitrary string with no credential shape — a passphrase that looks like a sentence — is not recognizable as a secret in free text. The heuristics’ scope is documented in the sanitizer source. * **Value-shape heuristics are heuristics.** They are tuned for recall on known credential shapes, and the golden fixtures pin exactly which shapes are covered. A shape outside that set is a fixture to add, and the tripwire convention makes that a one-file change. If you need a secret’s *value*, `lookout` will not give it to you — by design, on every surface. ## Defense in depth — withhold the grant entirely [Section titled “Defense in depth — withhold the grant entirely”](#defense-in-depth--withhold-the-grant-entirely) Masking is the guarantee for what `lookout` reads. If you would rather it never read Secret values in the first place, withhold the `secrets: list` grant from the sentinel’s role: the bundle and enrichment paths tolerate the resulting `Forbidden` and degrade to a documented partial (a `skipped=secrets` note on the head) instead of failing. The bundle is then secret-free *at the source* — the sanitizer never even has a value to mask. Run-time knobs (`--lists=all,-secrets`, `--enrich-lists=all,-secrets`) select the same posture without editing RBAC. See [Deploy the sentinel → Narrowing the role](/k8s-lookout/getting-started/deploy/#narrowing-the-role--partial-bundles-not-errors). # Signals & fingerprints > The frozen v1 wire schema, severity classes, dedup families, and the incident-class fingerprint that makes fleet rollup a join instead of a parsing project. Everything `lookout` tells you — a finding printed by a scan, an incident the sentinel opens — arrives in one shape: a **Signal**. This page explains that shape, the **fingerprint** that names an incident’s *class* so the same problem seen twice is counted once, and why the schema is frozen as v1: other tools parse it, so it changes only by agreement. The complete kind catalog is generated from the same ledger the freeze tests pin: [Reference → Signal kinds](/k8s-lookout/reference/signal-kinds/); the normative contract is [`docs/signal-schema-v1.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/signal-schema-v1.md). ## The payload [Section titled “The payload”](#the-payload) The inject payload carries the incident’s identity (`kind`, `reason`, `namespace`, `kind_of_object`, `name`, `uid`), its history (`count`, `first_seen`, `last_seen`), fleet join dimensions (`cluster`, `project`, `zone`), the class key (`fingerprint`), and optional attachments: a `forecast` (`eta`, `confidence_basis`) on trend signals, an `enrichment.bundle` on warmed sessions, and a `quota_increase_draft` on quota forecasts. Kinds are namespaced by source — `rollout.stall`, `workload.job_failed`, `saturation.forecast`, `capacity.stockout`, `quota.forecast`, `token.burn` — plus cross-cutting kinds like `resolved`, `storm`, and `watchboard.digest`. One freeze sits inside the freeze: the reactive `k8s-event` / `k8s-event-followup` pair stays byte-identical for playbook back-compat, and never gains the newer identity fields. Every other kind carries them. ## The fingerprint [Section titled “The fingerprint”](#the-fingerprint) The incident-class key: ```plaintext "sha256:" + hex(sha256(kind ∥ NUL ∥ reason-class ∥ NUL ∥ object-class ∥ NUL ∥ zone)) ``` * `reason-class` is **canonicalized** — `ErrImagePull` and `ImagePullBackOff` hash identically, mirroring the dedup family collapse. * `object-class` is the *kind* of the affected object (`Pod`, `Node`, `NodeGroup`), never its name or UID. * `zone` is inside the hash; `cluster` is not. Zone-scoped causes — stockouts, zonal outages — are exactly what fleet rollup must group: the same stockout hitting 40 clusters in a zone carries 40 identical fingerprints, with `cluster`/`project` riding alongside as join dimensions. That makes the fleet-tier rollup **a join, not a parse**. From a multi-cluster drill — two sentinel instances, one staged zonal stockout, grouped by `fingerprint` alone: ```plaintext fleet group sha256:0aad7654…5034c → clusters [prod-east prod-west] (capacity.stockout) fleet group sha256:95fa2f13…fbd18 → clusters [prod-east prod-west] (capacity.quota_blocked) ``` ## One schema for push and pull [Section titled “One schema for push and pull”](#one-schema-for-push-and-pull) Read-path findings are Signals too, with `source: "scan"` instead of `"sentinel"`. A point-in-time scan observes a *symptom*, so scan findings fingerprint under the reactive kind — the same class key the sentinel would stamp. `lookout health` and `lookout triage delta` emit `fingerprint=` on every symptom-class finding, which is what lets: * `health` merge “the sentinel paged on this 20 minutes ago” and “the scan still sees it” into one finding instead of two; * the [triage-status join](/k8s-lookout/concepts/closed-loop/) recognize a finding an agent already diagnosed; * fleets avoid double-counting a symptom reported by both paths. ### Scan fingerprints carry no zone, on purpose [Section titled “Scan fingerprints carry no zone, on purpose”](#scan-fingerprints-carry-no-zone-on-purpose) There is one place the two paths deliberately disagree, and it looks enough like a bug to be worth naming. A sentinel with zone stamping wired hashes its zone into the fingerprint. A read-path scan hashes an **empty** zone — always, even against a cluster whose sentinel stamps one. So the same real incident carries two different fingerprints depending on which path saw it. That is not an oversight. A scan is a point-in-time invocation, very often from a laptop against a kubeconfig, and nothing in that invocation honestly identifies a failure domain. Stamping a guess would make the two sides *look* like they agree while grouping findings under a zone nobody verified — worse than a visible mismatch. Nothing downstream depends on the two agreeing, because the [triage-status join](/k8s-lookout/concepts/closed-loop/) keys on the **pair** `(fingerprint, resource_key)` and the resource key is the pin. The fingerprint’s job there is narrow: disambiguating several open records on one object. So a zone-stamped record still joins a zone-less scan finding, and — the direction that matters more, because it is the common deployment — a zone-less record does too. A guardian test (`TestJoiner_ZoneMismatchStillJoins`) holds both at once, since only the resource-key pin can satisfy both. Where the zone does its real work is fleet rollup, and there both sides of a grouping come from sentinels. ## Severity classes and routing [Section titled “Severity classes and routing”](#severity-classes-and-routing) Every signal kind has a default severity (`critical` / `warning` / `info`), overridable per deployment with `--severity=kind=level`. Severity is a *routing* decision: | Severity | Default routing | | ---------- | -------------------------------------------------------------- | | `critical` | its own per-incident session, enrichment attached | | `warning` | batched into the shared watchboard session as a rolling digest | | `info` | stored only (with `--store`); surfaced by read-path queries | Leading indicators must not each open a page-priority session — that is the entire reason routing exists. The watchboard and its rotation are covered in [The closed loop](/k8s-lookout/concepts/closed-loop/). ## Dedup families [Section titled “Dedup families”](#dedup-families) Dedup keys on `(uid, canonical reason)`, and *families* collapse the same underlying incident observed from different angles into one session: * leading ↔ reactive: `objectstate.node_notready` and the `NodeNotReady` Event; `objectstate.restart_burst` and `CrashLoopBackOff`; * cross-source capacity joins: `capacity.pending` / `capacity.pending-aged` and the `FailedScheduling` Event; `quota.forecast` and `capacity.quota_blocked` collapse on a `quota:/` key — the forecast that predicted exhaustion and the autoscaler failure that confirmed it are one incident, not two alerts a human joins. ## Evolution [Section titled “Evolution”](#evolution) Additions are v1-additive (new omitempty field at the end of a struct, new kinds extending the inventory, ledger and doc updated in the same change). Removing or renaming a field, or touching the fingerprint recipe, is a v2 negotiation with the fleet consumer — a unilateral change would silently split every fleet-wide rollup into disjoint halves during a rolling upgrade. # The topology graph > The pod-nexus index behind radius, edges, and changes — copy-on-write snapshots live, snapshot-plus-replay for any past instant. The first questions in any incident are about relationships: what does this pod depend on, who talks to it, what else shares its node? The Kubernetes API is flat and resource-centric — reconstructing “what relates to this pod” costs an agent 10–15 round trips unless something maintains the relations for it. This page explains that something: the in-memory topology index, which has no CLI of its own but sits behind `state edges`, `triage radius|changes|events|spec`, `bundle`, storm correlation, and session enrichment. ## The pod-nexus model [Section titled “The pod-nexus model”](#the-pod-nexus-model) Typed nodes and edges centered on the Pod, connecting the traffic and policy layers above it to the infrastructure below: ```plaintext Gateway/Ingress → Service/EndpointSlice → [NetworkPolicy, RBAC] → POD POD → Containers | ConfigMaps/Secrets | PVCs/Volumes → Node → Zone ``` It is a directed *graph*, not a DAG — selector relationships and shared mounts create cycles, and traversals carry visited-sets rather than assuming acyclicity. The graph never stores secret *values*: only names, keys, and content hashes, so a change record can say “secret db-credentials changed” without ever holding the payload. ## One index, three questions [Section titled “One index, three questions”](#one-index-three-questions) | Question | Command | Query shape | | ------------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Is the wiring *correct*? | [`state edges`](/k8s-lookout/reference/state-edges/) | outbound edges of a workload + per-edge validity checks (ConfigMap/Secret keys, selectors and endpoint readiness, Ingress backends, RBAC refs, TLS expiry) | | Who is *affected*? | [`triage radius`](/k8s-lookout/reference/triage-radius/) | bounded BFS: upstream routes (Services, Ingresses — user-facing impact), lateral co-tenants (shared node/config/volume), downstream dependencies | | What *changed*? | [`triage changes`](/k8s-lookout/reference/triage-changes/) | the graph delta log joined with the event timeline, scoped to the target’s neighborhood | `edges` and `radius` are deliberately complementary: edges verifies correctness of dependencies; radius enumerates impact. `bundle` runs both in one pass. ## Consistency: copy-on-write snapshots [Section titled “Consistency: copy-on-write snapshots”](#consistency-copy-on-write-snapshots) Readers query an atomic copy-on-write snapshot and never take locks; a single writer batches informer deltas and publishes a new snapshot at most every few hundred milliseconds. That discipline exists for correctness, not throughput: a blast-radius answer computed during churn is taken against one consistent topology, never a half-applied update. The implementation is intentionally plain (Go maps behind a compact interface). Benchmarks at 1k and 10k pods put the graph’s memory and query costs at a small fraction of what the informer caches already spend, and the measured thresholds that would justify a compact rewrite are recorded in [`docs/graph-q5-gate.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/graph-q5-gate.md) — none have tripped. ## History: `--at` and time-travel [Section titled “History: --at and time-travel”](#history---at-and-time-travel) Persistence exists for **time-travel, not recovery** (after a restart the API server re-syncs the graph in seconds anyway). A sentinel running with `--store` writes two things into its SQLite store: * a compressed topology **snapshot** every `--graph-snapshot-interval` (default 5m), tagged with its generation; * the continuous per-delta **change log** — which doubles as the data source for `triage changes`. A query with `--at=` resolves to the nearest earlier snapshot and replays the change log forward to the requested instant. The summary line always names its source: `source=history at=…` for a store answer, `source=live` for the current graph, and `source=live-approximation` when `triage changes` reconstructs what it can from current API state without a store. Two properties worth knowing: * **History outlives the objects.** A post-mortem radius query returns pods and ReplicaSets the live cluster has already deleted and forgotten — that is the point. History stores topology, not status, so fields like pod readiness are omitted in history mode rather than guessed. * **Replay does not cross a sentinel restart.** Snapshot generations are per-process, so a `--at` window spanning a restart is currently unanswerable from the store — a known, documented gap. Post-mortems inside one sentinel incarnation work as designed. One-shot CLI invocations serve `--at` only when pointed at a sentinel’s store file via `--store`; history reads are fully offline — copy the SQLite file off the node and query it with no cluster access at all. The [what-changed guide](/k8s-lookout/guides/what-changed/) walks through exactly that. # Adding a check > How a new read-path command gets written — the scaffolder, the one declaration that generates five surfaces, and the touchpoints a generator cannot decide for you. A *check* is one read-path command: `lookout audit netpol`, `lookout state volumes`, `lookout triage delta`. Adding one is a scaffolder invocation plus four decisions. The full walkthrough — one real check taken end to end, with the rationale for each part of the declaration — lives in the tree at [`docs/adding-a-check.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/adding-a-check.md), next to the code it describes. This page is the orientation. ## Start with the scaffolder [Section titled “Start with the scaffolder”](#start-with-the-scaffolder) ```sh dev/tools/new-check --group=state --check=quotas \ --summary="When creates are rejected with 'exceeded quota': which ResourceQuotas are at their limit ..." ``` That writes the command, its test suite and its first golden, registers it, and runs the generated golden test to prove the scaffold compiles. What comes out is a working command that finds nothing — the job is to turn it into one that finds something, and only something. The scaffolder reads the group package before it writes: a group whose `Deps` carries a Kubernetes client gets the client guard and a fake-clientset fixture, one that carries only a clock gets neither. A group that does not exist is refused rather than created, because a group is a claim about a *class* of question and that is not a template decision. ## One declaration, five surfaces [Section titled “One declaration, five surfaces”](#one-declaration-five-surfaces) Every command is a single `checks.Command` value registered at init time, and that one declaration is the source for all of: | Surface | Generated by | | ------------------------------------------------------------------ | ------------------------------------------------------------------- | | `lookout --help` | `Command.Help()`, at runtime | | The MCP tool schema | `internal/mcpserver`, at runtime | | Skill references | `dev/tools/gen-skill-refs` (committed; a drift test fails if stale) | | These reference pages | `dev/tools/gen-site-docs` (committed; same) | | The [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) | `Registry.KindGlossary()` | Nothing is written twice and nothing needs keeping in sync — but a field left empty is a hole in five places at once, so the registry validates the declaration and panics at init on an invalid one. ## What the generator will not decide [Section titled “What the generator will not decide”](#what-the-generator-will-not-decide) Four things, each with a test that fails until you answer: 1. **The claim itself** — the rationale comment, the finding kinds, the output glossary, and the detector. Every rule worth writing has a legitimate look-alike; naming it and excluding it is most of the work. 2. **Whether a bare `lookout scan` runs it.** Every registered command is either in scan’s default stage or in its exclusion table with a recorded reason. A coverage test fails until one of the two is true. 3. **RBAC**, if the check reads an API resource nothing else reads. A test parses the deployed ClusterRole against the declared requirements. 4. **Skills**, if a workflow should reach for the command. A command no skill names is reachable by an agent that already knows it exists, and by no other. ## The house rules [Section titled “The house rules”](#the-house-rules) * **Silent when healthy.** Healthy resources are omitted, and every invocation ends with `scanned= findings= elapsed=` so “cluster healthy” is never confused with “wrong flag”. * **`scanned` is what was examined, not what was found.** It is the denominator of the coverage claim. * **Exit 2 is a usage error, exit 1 is a runtime one.** Exit 1 is the one a caller retries. * **Findings carry a fingerprint** — class-level for posture claims, instance-level for incidents. * **Goldens update one way**, `UPDATE_GOLDEN=1 go test ./pkg/checks/`. Then `dev/tools/gen-skill-refs`, `dev/tools/gen-site-docs` (last, or its output is stale), and `dev/tools/ci`. # Overview > What each of lookout's three modes looks for: the zero-argument incident scan, the posture audit, and the resident sentinel. One binary does three things, and each looks for a different class of problem. This section is one coverage page per mode — what it examines, what it can conclude, and what it deliberately leaves to the others. | Mode | The question it answers | Needs | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | | [`lookout scan`](/k8s-lookout/detect/scan/) | *What is broken in this cluster right now?* Every target-free incident check in one call, then a dependency-edge drill-down into whatever it flagged. | a kubeconfig | | [`lookout audit`](/k8s-lookout/detect/audit/) | *What has no safety net, while it is still healthy?* Standing posture claims — no PDB, single replica, privileged containers, no NetworkPolicy, upgrades nobody is watching. | a kubeconfig | | [The sentinel](/k8s-lookout/detect/sentinel/) | *What is about to break, and did it recover?* A resident in-cluster process turning leading indicators into agent sessions, and closing them when the symptom stays clear. | a deployment | The first two need nothing deployed and take no arguments — run them against a cluster you have never seen. The sentinel is the one that has to live somewhere, because watching is not something a one-shot command can do. ## Incidents, posture, and leading indicators [Section titled “Incidents, posture, and leading indicators”](#incidents-posture-and-leading-indicators) The split between the three is a claim about **what clears the finding**, and it is why they are separate commands rather than one flag: * An **incident** is broken now and clears itself when fixed. `scan` reports these, which is what makes its output a worklist. * A **posture** finding never self-clears — a workload with one replica has one replica until someone decides otherwise. `audit` reports these, and they are `--exemptions`-auditable precisely because the answer is often “yes, deliberately”. * A **leading indicator** is neither: nothing is broken yet. Only a process that has been watching can see a slope, a flap, or a countdown, which is the sentinel’s whole reason to exist. Mixing them would flood a healthy cluster’s first run and swamp the `findings diff` transition stream with a flat backlog, so `audit` is off in a bare scan and named in the summary’s `skipped=` note so it stays discoverable while off. ## The exhaustive catalogs [Section titled “The exhaustive catalogs”](#the-exhaustive-catalogs) These pages are organized for reading. When you have a `kind=` in hand and want to know what it claims, the flat catalogs are the faster lookup: * [Finding kinds](/k8s-lookout/reference/finding-kinds/) — every kind the read path (`scan`, `audit`, and every other command) can emit, in one table. * [Signal kinds](/k8s-lookout/reference/signal-kinds/) — the sentinel’s frozen signal-schema v1 wire vocabulary. # lookout audit > Every posture claim lookout audit makes, grouped by subcommand — the absence of a safety net around something that is currently healthy. `lookout audit` asks a different question from everything else: not “what is broken” but **“what has no safety net, while it is still healthy”**. A posture finding is a standing claim — it never self-clears, because a workload with one replica has one replica until somebody decides otherwise. That is also why every claim here is `--exemptions`-auditable: the answer is often “yes, deliberately”, and a git-reviewed exemption file records the reason and an expiry instead of the finding quietly disappearing. Covered findings are **annotated** with their reason and counted as `exempt=`, never dropped. Reachable two ways: the subcommands below, or `lookout scan --include=audit` for the whole sweep alongside the incident checks. Like `scan`, it needs nothing deployed — a kubeconfig is the whole setup. ## [`lookout audit cluster`](/k8s-lookout/reference/audit-cluster/) [Section titled “lookout audit cluster”](#lookout-audit-cluster) Cluster-level security configuration posture, read from the cloud provider: Workload Identity off cluster-wide or bypassed by a node pool, node pools still serving the legacy metadata endpoints, and a control-plane endpoint the internet can reach with nothing narrowing it. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. | Kind | Severity | What it means | | ----------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `audit.workload_identity_off` | warning | Workload Identity is off cluster-wide, or a node pool bypasses it — pods authenticate to the cloud as the node | | `audit.legacy_metadata` | warning | a node pool still serves the pre-v1 instance-metadata endpoints, which any pod can read | | `audit.public_control_plane` | warning, info | the control-plane endpoint is reachable from the internet; info when authorized networks narrow it | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## [`lookout audit exemptions`](/k8s-lookout/reference/audit-exemptions/) [Section titled “lookout audit exemptions”](#lookout-audit-exemptions) Audit the exemption file itself: which reviewed exemptions have lapsed (and are therefore no longer annotating anything) and which are about to. The mechanism that keeps an exemption file from becoming a permanent, unread list of things nobody checks any more. | Kind | Severity | What it means | | -------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | | `audit.exemption_expired` | warning | an exemption entry has lapsed: the findings it used to annotate are being reported unqualified again | | `audit.exemption_expiring` | info | an exemption entry lapses within —within — renew it or let it go deliberately | ## [`lookout audit hardening`](/k8s-lookout/reference/audit-hardening/) [Section titled “lookout audit hardening”](#lookout-audit-hardening) Workload security posture: containers running privileged or holding node-root capabilities, pods sharing the host network/PID/IPC namespaces, hostPath mounts, default-ServiceAccount tokens that something actually uses, and namespaces with no Pod Security Admission enforcement. Judges every pod-template owner in scope — Deployments, StatefulSets, DaemonSets, CronJobs, unowned Jobs and unowned Pods — plus the namespaces around them. Scope with —namespace or -A; scanned counts pod templates examined, the namespaces note counts namespaces. | Kind | Severity | What it means | | ---------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | `audit.privileged_container` | warning | a container runs privileged or holds a node-root capability (ALL, SYS\_ADMIN): a container escape is a node compromise | | `audit.host_namespace` | warning | the pod shares the node’s network, PID, or IPC namespace | | `audit.hostpath_mount` | warning, info | the pod mounts a host path; warning when it is writable, info when read-only | | `audit.default_sa_automount` | warning | the pod runs as the namespace’s default ServiceAccount with its token automounted, and something in the pod can use it | | `audit.podsecurity_gaps` | warning | the namespace enforces no Pod Security Admission level, so none of the above is prevented | ## [`lookout audit netpol`](/k8s-lookout/reference/audit-netpol/) [Section titled “lookout audit netpol”](#lookout-audit-netpol) NetworkPolicy coverage posture: namespaces where nothing restricts ingress or egress at all, and individual workloads that fell through the selectors of the policies covering their neighbours. Coverage means isolation — some policy selects the pod and names the direction — not that the rules it then applies are tight. hostNetwork templates are excluded, since NetworkPolicy cannot constrain them. Scope with —namespace or -A; scanned counts pod templates examined. | Kind | Severity | What it means | | ---------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.netpol_missing` | warning, info | nothing restricts this direction for the subject — a namespace with no policy at all, or a workload the covering policies’ selectors miss; info for the egress direction, where no policy is a defensible default | ## [`lookout audit upgrades`](/k8s-lookout/reference/audit-upgrades/) [Section titled “lookout audit upgrades”](#lookout-audit-upgrades) Upgrade and patch readiness, read from the cloud provider: how far the control plane and its node pools are behind what the provider publishes, and whether anything is set up to close that gap on its own — release channel, node auto-upgrade and auto-repair, a maintenance window, active maintenance exclusions, node images on the removed Docker runtime, and upgrade notifications. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. | Kind | Severity | What it means | | -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.version_behind` | warning, info | the control plane or a node pool is behind what the provider publishes, or a node pool has skewed from the control plane; info while the gap is still within the supported skew | | `audit.upgrade_unmanaged` | warning | nothing will close that gap on its own: no release channel, or node auto-upgrade/auto-repair off | | `audit.upgrade_blocked` | warning, info | an active maintenance exclusion, or a node image on the removed Docker runtime, will stop the upgrade when it comes | | `audit.upgrade_unattended` | info | upgrades will happen with nobody watching: no maintenance window, or no upgrade notifications | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## [`lookout audit workloads`](/k8s-lookout/reference/audit-workloads/) [Section titled “lookout audit workloads”](#lookout-audit-workloads) Workload reliability posture for workloads that are healthy right now: no PodDisruptionBudget, only one replica, no readiness/liveness probe, no spread across nodes, placement pinned to too few nodes, autoscalers that structurally cannot scale, and CronJobs left suspended long enough to have skipped runs. Answers “what has no safety net”, as against `stab drain`, which answers “what breaks if I drain THIS node now”. Scope with —namespace, -A, or —workload; scanned counts workloads examined. | Kind | Severity | What it means | | -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.no_pdb` | warning | the workload has no PodDisruptionBudget: a drain can take every replica at once | | `audit.single_replica` | warning | the workload runs a single replica, so any disruption is an outage | | `audit.no_readiness_probe` | warning | a container has no readiness probe, so traffic reaches it before it can serve | | `audit.no_liveness_probe` | info | a container has no liveness probe, so a wedged process is never restarted | | `audit.no_spread` | info | the workload’s replicas are not spread across nodes or zones | | `audit.rigid_scheduling` | warning, info | placement constraints pin the workload to too few nodes to survive losing one | | `audit.hpa_cannot_scale` | warning | the autoscaler structurally cannot scale: min equals max, the target is missing, or a container has no request for its utilization target to divide by | | `audit.suspended_cronjob` | warning | a CronJob has been suspended past —cron-suspended and has skipped activations because of it: whatever it does is not happening, and nothing else reports that | ## See also [Section titled “See also”](#see-also) * [Exemptions reference](/k8s-lookout/reference/audit-exemptions/) — the file format, and the check that audits the exemption file itself. * [What `lookout scan` finds](/k8s-lookout/detect/scan/) — the incident half. * [Finding kinds](/k8s-lookout/reference/finding-kinds/) — every read-path kind in one flat table, when you have a `kind=` and want its claim. # lookout scan > Everything a zero-argument lookout scan looks for, grouped by the stage that looks for it — and what --include adds. `lookout scan` is the entry point for “something is wrong and I do not know what”: no target, no flags, nothing deployed. It runs every target-free incident check in one invocation, then drills into the dependency edges of whatever it flagged. Every finding is stamped `check=` — which is also the command to run for the detail behind it. That is why this page is grouped by stage rather than alphabetically: the heading is the follow-up call. A kind absent from a run means the check looked and found nothing. A check that could not run says so explicitly, in the stream — see [what scan says about itself](#what-scan-says-about-itself) at the bottom. ## Stage 1 — the target-free incident checks [Section titled “Stage 1 — the target-free incident checks”](#stage-1--the-target-free-incident-checks) These run on every scan, in this order: the broadest check first, so the thing that is wrong is usually named before you finish reading. ### [`lookout triage delta`](/k8s-lookout/reference/triage-delta/) [Section titled “lookout triage delta”](#lookout-triage-delta) Every abnormal object in one scan — the first call for “anything wrong in this cluster?”: broken/pending pods, stalled rollouts, workloads blocked from creating pods at all, node pressure/NPD/preemption, gridlocked PDBs, degraded kube-system add-ons, quotas at their limits. | Kind | Severity | What it means | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pod.crashloop` | critical | a container is crash looping | | `pod.imagepull` | critical | a container cannot pull its image | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | | `pod.restarts` | warning | a container has restarted at least —restarts times | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | | `pod.failed` | warning | the pod reached phase Failed | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | | `job.failed` | warning | a Job’s Failed condition is set | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | | `node.notready` | critical | the node’s Ready condition is not True | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | ### [`lookout state webhooks`](/k8s-lookout/reference/state-webhooks/) [Section titled “lookout state webhooks”](#lookout-state-webhooks) When creates/updates hang or fail cluster-wide with “failed calling webhook”, or before relying on a policy engine: audit every admission webhook — dead backends × failurePolicy (Fail + dead backend rejects every matching admission), the namespace/rule blast radius, timeout stall risk, CA-bundle expiry. The full check; health’s webhooks category delegates here. | Kind | Severity | What it means | | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------- | | `webhook.failing_closed` | critical | the webhook has no working backend and failurePolicy=Fail: every gated write is rejected cluster-wide | | `webhook.dead_backend` | warning | the webhook’s service backend is missing, has no ready endpoints, or does not serve the named port | | `webhook.slow_risk` | info | the webhook’s timeout is long enough to slow every gated write if the backend degrades | | `webhook.ca_expired` | critical | the webhook’s caBundle has expired: the API server cannot verify it | | `webhook.ca_expiring` | warning | the webhook’s caBundle expires within —cert-warn | ### [`lookout state volumes`](/k8s-lookout/reference/state-volumes/) [Section titled “lookout state volumes”](#lookout-state-volumes) When pods hang in ContainerCreating with Multi-Attach or FailedAttachVolume events — join VolumeAttachment + PV/PVC + pods to name the exact conflict: RWO claims wanted on two nodes, attachments stuck in error, cross-zone PV locks, orphaned attachments. | Kind | Severity | What it means | | ---------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- | | `volume.multi_attach` | critical | an RWO claim is wanted by pods on more than one node — the second pod never starts | | `volume.zone_conflict` | critical | the PV is locked to a zone the pod’s node is not in | | `volume.attach_error` | critical, warning | the attach or detach is failing; critical once it has been failing long enough to be stuck rather than slow | | `volume.orphaned_attachment` | info | a VolumeAttachment survives its PV or its node | ### [`lookout state storage`](/k8s-lookout/reference/state-storage/) [Section titled “lookout state storage”](#lookout-state-storage) When a PersistentVolumeClaim sits Pending and the pod behind it will not schedule — name the reason: a StorageClass that does not exist, no class and no cluster default, a static-only class with nothing pre-provisioned, plus the default-class ambiguity and stranded volumes behind it. | Kind | Severity | What it means | | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `storage.missing_class` | critical | the claim names a StorageClass that does not exist — it will stay Pending forever | | `storage.no_default_class` | critical | the claim names no class and the cluster has no default StorageClass | | `storage.no_provisioner` | warning | the claim’s class is static-only (kubernetes.io/no-provisioner) and no matching PV is available | | `storage.multiple_defaults` | warning | more than one StorageClass is annotated as the cluster default; which one wins is not defined | | `storage.pv_failed` | warning | a PersistentVolume is Failed: its reclaim did not complete, so the backing disk stays allocated and the volume cannot be reused | | `storage.pv_released` | info | a PersistentVolume is Released — retained on purpose, but its capacity is unusable until spec.claimRef is cleared | ### [`lookout state gateway`](/k8s-lookout/reference/state-gateway/) [Section titled “lookout state gateway”](#lookout-state-gateway) When traffic through the Gateway API does not arrive — walk GatewayClass → Gateway → listener → HTTPRoute → Service and report every hop that is rejected, unprogrammed, or points at something that is not there. Silent, and cheap, on clusters without the Gateway API installed. | Kind | Severity | What it means | | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | `gateway.missing_class` | critical | the Gateway names a GatewayClass that does not exist — nothing will program it | | `gateway.class_not_accepted` | critical | the Gateway’s GatewayClass is not Accepted by its controller | | `gateway.not_accepted` | critical | the Gateway itself is not Accepted | | `gateway.not_programmed` | critical | the Gateway is Accepted but not Programmed: no data plane is carrying its traffic | | `gateway.listener_invalid` | warning | one listener of an otherwise working Gateway is not resolved or not programmed | | `route.missing_parent` | critical | the route’s parentRef names a Gateway that does not exist | | `route.not_accepted` | critical | the Gateway refused the route’s attachment (listener, hostname, or namespace policy) | | `route.missing_backend` | critical | the route’s backendRef Service does not exist | | `route.backend_port` | critical | the route’s backendRef Service exists but does not expose the named port | | `crd.unavailable` | info | the API group this check reads is not served by the cluster, so nothing was examined (no coverage lies) | ### [`lookout state wi`](/k8s-lookout/reference/state-wi/) [Section titled “lookout state wi”](#lookout-state-wi) When a GKE pod gets 403s or metadata-server errors calling GCP APIs, verify the Workload Identity chain — KSA annotation (iam.gke.io/gcp-service-account) → roles/iam.workloadIdentityUser binding on the GSA — reporting only the broken links; vanilla clusters report an explicit unavailable. | Kind | Severity | What it means | | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `wi.gsa_missing` | critical | the annotated Google service account does not exist — every GCP call from these pods fails | | `wi.unbound` | critical | the KSA annotates a GSA but the roles/iam.workloadIdentityUser binding is missing or malformed | | `wi.unannotated_use` | info | a pod sets GOOGLE\_APPLICATION\_CREDENTIALS but its ServiceAccount carries no Workload Identity annotation | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ### [`lookout stab drift`](/k8s-lookout/reference/stab-drift/) [Section titled “lookout stab drift”](#lookout-stab-drift) Find spec fields of Deployments/StatefulSets/DaemonSets owned by a manager other than the GitOps controller (managedFields) — out-of-band kubectl edits and rogue co-managers. Reports manager strings (tool names, not people); —identity additionally resolves each drift write to the audited principal via the cloud provider’s audit trail (GKE Cloud Audit Logs), reporting an explicit unavailable on clusters without one. Default scope: all namespaces; scanned counts workload objects examined. | Kind | Severity | What it means | | ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `drift.manual_edit` | critical, warning | a manager other than the GitOps controller owns spec fields on this object; critical when one of them is high blast radius (image, replicas, env) | ## Stage 2 — the dependency-edge drill-down [Section titled “Stage 2 — the dependency-edge drill-down”](#stage-2--the-dependency-edge-drill-down) Every workload stage 1 flagged at warning or above then has its dependency edges verified: one cluster List pass and N in-memory `state edges` evaluations, each rolled up to its outermost controller, so twenty crashlooping pods of one Deployment are one drill-down rather than twenty. `--max-drilldown` bounds it (default 20) and the summary reports what it dropped. | Kind | Severity | What it means | | ------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | | `edge.missing_ref` | critical | a referenced ConfigMap, Secret, ServiceAccount, TLS secret, IngressClass, StorageClass, or governing Service does not exist | | `edge.missing_key` | critical | the referenced key is absent from an existing ConfigMap/Secret | | `edge.invalid_ref` | warning | the referenced object exists but is the wrong type to serve the reference | | `edge.unclassed` | warning | the Ingress names no class and no IngressClass declares itself the cluster default — no controller will claim it | | `edge.selector_empty` | critical | a Service selector selects zero pods, so the service routes nowhere | | `edge.selector_unready` | critical, warning | the Service selects pods but some are not Ready; critical when none are | | `edge.endpoints_missing` | critical | a selecting Service has no EndpointSlices at all | | `edge.endpoints_orphaned` | warning | an endpoint targetRef names a pod that no longer exists | | `edge.endpoints_unready` | critical, warning | the endpoint ready-count disagrees with the selected pods (stale or lagging slices); critical at zero ready | | `edge.backend_missing` | critical | an Ingress backend service, or the port it names, does not exist | | `edge.cert_expired` | critical | a TLS certificate’s NotAfter is in the past | | `edge.cert_expiring` | warning | a TLS certificate expires within —cert-warn | | `edge.cert_invalid` | warning | tls.crt is missing or unparseable, or the secret is not kubernetes.io/tls | | `edge.rbac_dangling` | warning | a (Cluster)RoleBinding for the workload’s ServiceAccount points at a missing (Cluster)Role | ## What `--include` adds [Section titled “What --include adds”](#what---include-adds) Three groups are left out of a bare scan, each for a reason that is a property of the whole group. `--include=all` takes every one; `-` subtracts (`all,-cloud`). ### `--include=audit` [Section titled “--include=audit”](#--includeaudit) Best-practice posture: the absence of a safety net around a workload or cluster that is currently healthy — a different claim from the incident groups, which is why it is a different group Its 24 kinds have their own page — [what `lookout audit` checks](/k8s-lookout/detect/audit/). ### `--include=cloud` [Section titled “--include=cloud”](#--includecloud) GCP-side reads: stockouts, orphaned resources, IP space, quota #### [`lookout cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/) [Section titled “lookout cloud ipspace”](#lookout-cloud-ipspace) Pod/Service/node CIDR utilization per subnet, judged: warning at 80%, critical at 95% — IP space is incompressible, an exhausted range fails the next node or pod block outright. Consumption rate/ETA lives in the sentinel’s capacity source. | Kind | Severity | What it means | | ------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ipspace.range` | critical, warning, info | a pod/service/node range is at 80% of its CIDR or worse; critical from 95%, info for a range the cloud APIs cannot rate and for an —all row below the line | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | #### [`lookout cloud orphans`](/k8s-lookout/reference/cloud-orphans/) [Section titled “lookout cloud orphans”](#lookout-cloud-orphans) Billing-active cloud leftovers: unattached GCE disks older than —min-age and forwarding rules/LBs routing to zero endpoints — cost and hygiene sweep, not an incident read. | Kind | Severity | What it means | | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `orphan.disk` | warning | a GCE disk has been unattached for at least —min-age and is still billing | | `orphan.lb` | warning | a forwarding rule or load balancer routes to zero endpoints and is still billing | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | #### [`lookout cloud quota`](/k8s-lookout/reference/cloud-quota/) [Section titled “lookout cloud quota”](#lookout-cloud-quota) Per-project cloud quota usage vs limit, ranked nearest-to-exhaustion: findings from —quota-warn (default 80%), critical at 95% — quota is incompressible (scale-ups fail at the limit) and increases need lead time. Trend/ETA lives in the quota source. | Kind | Severity | What it means | | ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `quota.pressure` | critical, warning, info | a cloud quota is at or above —quota-warn percent of its limit; critical from 95%, info for an —all row below the line | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | #### [`lookout cloud stockout`](/k8s-lookout/reference/cloud-stockout/) [Section titled “lookout cloud stockout”](#lookout-cloud-stockout) GCE capacity stockouts (ZONE\_RESOURCE\_POOL\_EXHAUSTED) per zone/machine-type over —since (default 24h), with event-derived reroute candidates — the cloud-side why behind pods stuck Pending on failed scale-ups. | Kind | Severity | What it means | | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `stockout.zone` | warning | the cloud had no capacity for a machine type in this zone during the window — the reason a scale-up failed and pods stayed Pending | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ### `--include=perf` [Section titled “--include=perf”](#--includeperf) Control-plane and startup performance via Cloud Monitoring query packs #### [`lookout perf probe`](/k8s-lookout/reference/perf-probe/) [Section titled “lookout perf probe”](#lookout-perf-probe) Control-plane and startup performance via metrics query packs: —pack=apiserver (p99 latency by verb/resource), apf (queue saturation + 429 rejects), etcd (WAL fsync p99 + DB size), startup (pod-first-ready p95 trend); apf/etcd need GKE control-plane metrics enabled — absence degrades to an explicit pack\_unavailable finding. | Kind | Severity | What it means | | ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `perf.apiserver_p99` | critical, warning | apiserver request latency p99 crossed the pack threshold for a verb/resource — warning from 1s, critical from 4s | | `perf.apf_saturation` | critical, warning | an API Priority and Fairness level is holding a sustained queue — warning from 10 queued, critical from 100 | | `perf.apf_rejects` | critical, warning | APF is shedding load: the apiserver is returning 429s at a priority level | | `perf.etcd_fsync` | critical, warning | etcd WAL fsync p99 crossed the pack threshold — warning from 10ms, critical from 100ms | | `perf.etcd_db_size` | critical, warning | the etcd database is approaching its quota — warning from 4 GiB, critical from 5.5 GiB | | `perf.startup_p95` | critical, warning | pod first-ready p95 crossed the pack threshold — warning from 60s, critical from 300s | | `perf.pack_unavailable` | warning | a metric the requested pack needs is not in the metrics workspace, so part of the pack could not run; the rest still did (no coverage lies) | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## What scan says about itself [Section titled “What scan says about itself”](#what-scan-says-about-itself) A scan that could not run something reports that, rather than reporting a smaller cluster. These kinds are the coverage claim, and they are why an empty scan means “nothing is wrong” and not “nothing ran”. | Kind | Severity | What it means | | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scan.check_skipped` | info | a stage declined this invocation because a zero-argument scan cannot supply something it needs — the coverage claim is smaller than it looks | | `scan.check_failed` | warning | a stage errored; the scan continued without it, so this run saw less than a whole cluster — unless EVERY stage failed and none read anything, which is a runtime error (exit 1) rather than a scan | | `scan.incomplete` | warning | the —timeout expired with stages still to run; not\_run names them | ## See also [Section titled “See also”](#see-also) * [`lookout scan` reference](/k8s-lookout/reference/scan/) — flags, output fields, the full kind table in one list. * [What `lookout audit` checks](/k8s-lookout/detect/audit/) — the posture half. * [What the sentinel watches](/k8s-lookout/detect/sentinel/) — the things a one-shot command structurally cannot see. # The sentinel > The failure classes the sentinel monitors, which signal source covers each, and what --sources=auto (the default) turns on by probing your deployment's grants. The sentinel is one process per cluster, and what it watches is the set of signal sources that are enabled. Out of the box that is `--sources=auto`: at startup the sentinel probes each portable source’s needs — RBAC grants, plus a metrics API for `saturation`, the Gateway API CRDs for `gateway`, and the ComputeClass CRD for `compute-class` — and enables everything your deployment supports, announcing each decision with one startup line. Three of the sixteen sources are never auto-enabled and stay explicit opt-ins: `quota` (a per-GCP-project deployment decision), `notifications` (needs an operator-created Pub/Sub subscription), and `token-burn` (a polling loop against the core-agent daemon’s cost stack). ## The coverage map [Section titled “The coverage map”](#the-coverage-map) Organized by what fails, not by how the code is arranged. Every “Example trigger” is either captured drill output or the source’s own shipped threshold. | Watches for | Example trigger | Source name | On by default? | Extra needs | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Failures the control plane already reported | A pod enters `CrashLoopBackOff`; an image tag that doesn’t exist (`ErrImagePull`) | `k8s-events` | **Auto** (always on — a sentinel that cannot watch events refuses to start) | none | | Nodes going bad | A node’s Ready condition flips to NotReady, or flaps 3 times inside 10 minutes | `object-state` | **Auto** | none | | Nodes running out of room, and the evictions that follow | A node’s `MemoryPressure`/`DiskPressure`/`PIDPressure` condition goes True and stays True for 5 minutes; 3 pod evictions on one node inside 10 minutes, folded into one node-scoped signal | `object-state` | **Auto** | none | | Services going dark, drains about to stall | A Service’s ready-endpoint count drops to zero; a PodDisruptionBudget’s allowed disruptions hit 0 with pods behind it | `object-state` | **Auto** | none | | Crash loops and stuck rollouts, before the events | A pod’s restart count climbs 3 in 10 minutes — ahead of the kubelet’s `BackOff` events; a Deployment burns 80% of its progress deadline with unready replicas | `object-state` | **Auto** | none | | Bad deploys, while the old version still serves | New pods crash-looping while the old version still serves: zero ready-count progress for 3 minutes (`--rollout-observe`) with the old ReplicaSet healthy | `rollout` | **Auto** | none | | Failed batch work and dead schedules | A Job’s `Failed` condition goes True (`BackoffLimitExceeded`, `DeadlineExceeded`); an unsuspended CronJob passes a scheduled activation without running — three consecutive misses escalate to critical | `workload` | **Auto** | none | | Autoscalers out of headroom, or silently dead | An HPA sits at `maxReplicas` with its metric still over target for 10 minutes (critical past 30); an HPA’s `ScalingActive` goes False with a `FailedGet*` reason for 15 minutes — scaling has stopped and nothing says so | `autoscaling` | **Auto** | none | | Resources trending toward exhaustion | A pod leaking \~1 MiB every 30 s, forecast to hit its 64 Mi memory limit in \~14 minutes; a PVC filling in \~3 h | `saturation` | **Auto** | metrics-server (`metrics.k8s.io`) — absent, auto skips the source with one loud line | | Service capacity eroding before the outage | A backend’s ready endpoints declining 5/5 → 3/5 across the trend window; a readiness probe that keeps flapping below the reactive threshold | `degradation` | **Auto** | none | | Certificates and tokens running out | A TLS certificate 13 days from expiry; a cert-manager `Certificate` whose last renewal failed | `expiry` | **Auto** | none | | The autoscaler failing to deliver nodes | A pod Pending and unschedulable past 5 minutes; a nodegroup that asked the cloud for a node and didn’t get one for 3 minutes | `capacity` | **Auto** | a running cluster-autoscaler; GCP provider (`-gke` image) for the structured whys — stockout vs quota vs IP exhaustion | | Load balancers that never get programmed (Ingress) | An `ingress-gce` Warning `Sync` (“Error syncing to GCP: …”) or `Translate` event on an Ingress; a NEG-controller `AttachFailed`/`SyncNetworkEndpointGroupFailed` on a Service — endpoints never reach the load balancer while the Ingress object looks fine | `ingress` | **Auto** | none (nothing fires on clusters without `ingress-gce`/NEG controllers) | | Load balancers that never get programmed (Gateway API) | A Gateway or listener holds `Programmed=False` past the 5-minute grace, with `observedGeneration` caught up and the reason not `Pending`; an HTTPRoute parent holds `Accepted=False`/`ResolvedRefs=False` — the route config never became routable | `gateway` | **Auto** | the Gateway API CRDs served — absent, auto skips the source with one loud line (RBAC alone can’t tell, so this is a discovery check) | | Workload placement drifting across topology domains | A Deployment that declared a `DoNotSchedule` spread constraint is violating it, sustained past the 10-minute dwell; a workload’s replicas pile into one zone and stay there. Scored against a declared constraint, an intent inferred from what the workload does say, or — Tier C, metrics-only unless `--topology-tier-c-signals` — its own learned normal | `topology-drift` | **Auto** | none (pods, nodes and replicasets — grants the sentinel already holds) | | A whole topology domain with nothing schedulable left in it | Every node in a zone goes NotReady, is deleted, or is cordoned — the subject is the **domain**, so this is one signal for the cluster and not one per workload that drifted because of it (those are suppressed while the zone is out). The three cases are told apart as the suspected cause: nodes gone is `consolidation`, nodes present and none Ready is `domain_outage`, nodes Ready and none schedulable is `taint_exclusion`. Judged on zone and region by default — `--topology-domain-unavailable-keys`, and an empty value turns it off | `topology-drift` | **Auto** | none (nodes — a grant the sentinel already holds) | | Workloads quietly running on a compute class’s fallback hardware | A GKE custom compute class is an ordered list of machine shapes; when the first choice has no capacity GKE provisions the next one down, the pod runs, the Deployment stays at full replica count, and nothing anywhere says so. The source resolves each node’s preference RANK (which is not the raw `ccc_priority_index` — a class that sets `priorityScore` can rank its list in the opposite order), accumulates pod-seconds per rank, and fires when a class is wedged (Pending pods, `DoNotScaleUp`), running 90% of its time on its least-preferred rank, or not migrating back once preferred capacity returns. A whole priority nothing has occupied for 30 days is Tier C — metrics-only unless `--compute-class-tier-c-signals` | `compute-class` | **Auto** | the `cloud.google.com/v1` ComputeClass CRD served — a GKE feature; absent, auto skips the source with one loud line (RBAC alone can’t tell, so this is a discovery check) | | Cloud quota exhaustion, days out | `CPUS/us-east1` at 98% of limit, exhausted in \~16 h at the current slope — drafted increase request attached | `quota` | No — explicit | GCP provider (`-gke` image); project tier — exactly one sentinel per GCP project enables it | | Agent token spend burning out of control | One session’s token rate at 4× the cross-session median, sustained two polls; a session budget projected to exhaust inside 30 minutes | `token-burn` | No — explicit | `core-agent` daemon — its cost stack is the data source | | The provider’s own announcements: upgrades and security bulletins | A control-plane or node-pool upgrade starts (recorded for incident-window correlation); a security bulletin affecting the cluster lands on the watchboard | `notifications` | No — explicit | GKE notificationConfig topic + a Pub/Sub subscription (`--notifications-subscription`) | “Auto” means the source is on whenever the startup probe finds its grants (the shipped `deploy/` manifests carry all of them) — a miss skips the source with a startup line naming the missing grant and the fix, never silently. Every kind these sources can emit — 57 in the frozen schema — is cataloged in the [Signal kinds reference](/k8s-lookout/reference/signal-kinds/); every threshold above is a flag documented in the [`lookout watch` reference](/k8s-lookout/reference/watch/). ## What happens when something fires [Section titled “What happens when something fires”](#what-happens-when-something-fires) A source emits a signal; the pipeline dedups it per object and reason, so a pod that crashes forty times inside the dedup window is one incident with a rising count — not forty pages. Severity then decides the route: a critical signal opens its own agent session on the daemon, warnings batch into the shared watchboard digest, and info signals are stored (with `--store`) rather than surfaced. A critical session arrives enriched: the initial inject carries a pre-warmed, size-capped bundle — sanitized spec, recent changes, dependency edges, blast radius, distilled log tails — so the agent’s first tool calls are already answered. And when the symptom clears and stays clear, the sentinel injects a `kind=resolved` record into the same session: the incident ends with verified proof, not silence. The full mechanics — recovery, storms, the watchboard, triage-status — are in [The closed loop](/k8s-lookout/concepts/closed-loop/). ## What auto gives you [Section titled “What auto gives you”](#what-auto-gives-you) With no `--sources` flag at all, startup resolves the portable set against what your deployment can actually do and prints one line per decision — the summary block, enabled lines included: ```plaintext sources: auto — probing the portable set (RBAC per source; metrics.k8s.io for saturation); misses are skipped loudly — pin --sources explicitly to make a miss fatal (§11) source k8s-events: enabled (always on — a sentinel that cannot watch events is misdeployed) source object-state: enabled source rollout: enabled source workload: enabled source autoscaling: enabled source saturation: disabled (metrics.k8s.io unavailable — install metrics-server) source degradation: enabled source expiry: enabled source capacity: enabled source ingress: enabled source gateway: disabled (Gateway API CRDs not served — install a GKE Gateway class or the upstream gateway.networking.k8s.io CRDs, or name gateway in --sources to make this fatal) sources: auto resolved → k8s-events,object-state,rollout,workload,autoscaling,degradation,expiry,capacity,ingress (quota, notifications, and token-burn stay explicit-only: project tier, the notification subscription, and the core-agent cost stack) ``` `--storm` defaults to auto the same way: the graph informer grants (pods/nodes/replicasets list+watch) present resolve storm correlation on, a miss resolves it off with a line naming the grant. Storm is what turns a dead node’s thirty pod incidents into one session naming the node. The one skip auto never makes is `k8s-events`: a sentinel that cannot watch events is misdeployed, and that is a fatal startup error, not a line in the block. Add `--store` to complete the experience — the store is what makes info signals durable, scans aware of prior triage, and post-mortem queries possible; its path is deliberately always explicit (`--store=/var/lib/lookout/lookout.db`, on a volume — the shipped Deployment now wires one). ## Pinning sources explicitly [Section titled “Pinning sources explicitly”](#pinning-sources-explicitly) An explicit list is the strict mode, and its semantics are unchanged: every named source’s startup probe failure is a fatal error naming the exact grant (`source "object-state" requires permission to "list nodes cluster-wide" …`) — never a silently empty watch, and never a skip. Pin a list when you’d rather crash-loop than run with less than you asked for — the shipped `deploy/51` manifest carries the strict list as a ready-to-uncomment alternative, since it ships alongside the full RBAC: ```plaintext --sources=k8s-events,object-state,rollout,workload,autoscaling,saturation,degradation,expiry,capacity,ingress,gateway,token-burn --storm=on --store=/var/lib/lookout/lookout.db --enrich=critical ``` `--sources=k8s-events` reproduces the pre-auto default surface byte-for-byte. The three explicit-only sources have deployment-specific homes: `quota` is a per-GCP-project opt-in on the `-gke` image, `notifications` needs a Pub/Sub subscription on the project’s GKE notification topic (`--notifications-subscription`), and `token-burn` reads the `core-agent` daemon’s cost stack (it disables itself, loudly, under the webhook sink) — which is why the strict list above names `token-burn` explicitly and leaves the other two out. Naming `gateway` in an explicit list makes a cluster without the Gateway API CRDs a fatal startup error rather than a skip. The shipped manifests in `deploy/` carry everything every portable source needs; see [Troubleshooting](/k8s-lookout/operations/troubleshooting/) for the source-by-source requirements and the summary-block anatomy. ## Where next [Section titled “Where next”](#where-next) * [Deploy the sentinel](/k8s-lookout/getting-started/deploy/) — the manifests, RBAC tiers, and the rest of the flag walkthrough. * [Signal kinds](/k8s-lookout/reference/signal-kinds/) — the exhaustive catalog of everything that can go on the wire. * [`lookout watch`](/k8s-lookout/reference/watch/) — every flag, generated from the live flag surface. # Guides > Scenario walkthroughs with real captured output — broken workloads, stuck rollouts, resource exhaustion, node failures, post-mortems, and capacity planning. This section is for the moment something is wrong — or just was. Each guide starts from a symptom you might be staring at and walks the real investigation, command by command, to a diagnosis and a verified outcome. By the end of a guide you can run the same workflow on your own cluster, and you will know which commands answer which questions. ## Which guide do I need? [Section titled “Which guide do I need?”](#which-guide-do-i-need) | What you’re seeing | Guide | | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Pods are crashing or won’t start, and you don’t know why | [Investigate a broken workload](/k8s-lookout/guides/broken-workload/) | | You shipped a deploy and it never finished rolling out | [Your rollout is stuck](/k8s-lookout/guides/stuck-rollout/) | | Memory or CPU keeps climbing and an OOM kill looks inevitable | [Catch resource exhaustion early](/k8s-lookout/guides/resource-exhaustion/) | | A node went down and everything on it is failing at once | [A node just died](/k8s-lookout/guides/node-failure/) | | The incident is over and you need to know what changed before it | [What changed before the incident](/k8s-lookout/guides/what-changed/) | | You’d rather hit quota and capacity limits on your terms than in an outage | [Capacity & quota ahead of time](/k8s-lookout/guides/capacity-quota/) | Each guide walks the real workflow, using output captured during live validation drills (abridged, never invented): * [Investigate a broken workload](/k8s-lookout/guides/broken-workload/) — the bundle-first flow: root-causing a double fault in one call. * [Your rollout is stuck](/k8s-lookout/guides/stuck-rollout/) — `rollout.stall` fires while the old revision still serves; roll back to a verified `resolved`. * [Catch resource exhaustion early](/k8s-lookout/guides/resource-exhaustion/) — a memory-leak forecast lands a session 14 minutes before the OOM kill. * [A node just died](/k8s-lookout/guides/node-failure/) — storm correlation: one session for a 33-object blast, and its full member lifecycle. * [What changed before the incident](/k8s-lookout/guides/what-changed/) — `--at` post-mortems from a copied sentinel store, offline. * [Capacity & quota ahead of time](/k8s-lookout/guides/capacity-quota/) — the correlated quota incident, the drafted increase request, and the cloud sweep commands. Every guide ends with a pointer to the matching agent skill in [`skills/`](https://github.com/go-steer/k8s-lookout/tree/main/skills) — the same workflows, packaged for the agents themselves. # Investigate a broken workload > The bundle-first flow — root-causing a double fault in one call, then narrowing with targeted reads. Real captured drill output. **The problem:** `shop/checkout` is broken and you don’t yet know how. In this (real) case it was broken twice over: the image was updated to a nonexistent tag (`busybox:1.36-nonexistent-m1`), *and* the ConfigMap key its pod template references (`log.level`) was deleted. A healthy Deployment (`web`) runs alongside. All output below is captured from the a live validation drill on a kind cluster, abridged. ## 1. `bundle` first — one correlated payload [Section titled “1. bundle first — one correlated payload”](#1-bundle-first--one-correlated-payload) `lookout bundle` converts the first 4–5 reads of an investigation into one call: sanitized spec, everything abnormal, broken dependency edges, blast radius, and distilled logs, scoped to the workload: ```sh lookout bundle --workload=Deployment/shop/checkout ``` ```txt kind=bundle.target severity=info namespace=shop kind_of_object=Deployment name=checkout workload=Deployment/shop/checkout pods=3 sections=spec,delta,edges,radius,logs kind=spec.container severity=info namespace=shop kind_of_object=Deployment name=checkout section=spec container=checkout image=busybox:1.36-nonexistent-m1 env="LOG_LEVEL=configMapKeyRef:checkout-config.log.level,DB_PASSWORD=secretKeyRef:checkout-db.password" kind=pod.imagepull severity=critical namespace=shop kind_of_object=Pod name=checkout-5898857498-vw894 reason=ImagePullBackOff message="Back-off pulling image \"busybox:1.36-nonexistent-m1\": ErrImagePull: rpc error: code = NotFound …" section=delta container=checkout image=busybox:1.36-nonexistent-m1 kind=workload.rollout severity=warning namespace=shop kind_of_object=Deployment name=checkout reason=RolloutIncomplete section=delta desired=2 ready=2 updated=1 available=2 kind=edge.missing_key severity=critical namespace=shop kind_of_object=ConfigMap name=checkout-config reason=CreateContainerConfigError message="key log.level not found in configmap checkout-config (env LOG_LEVEL in container checkout)" section=edges workload=Deployment/shop/checkout container=checkout env=LOG_LEVEL key=log.level pods=3 kind=edge.selector_unready severity=warning namespace=shop kind_of_object=Service name=checkout reason=PodsNotReady message="service selects 3 pod(s), 2 ready" section=edges workload=Deployment/shop/checkout selector="app=checkout" selected=3 ready=2 kind=log.template severity=warning namespace=shop section=logs template="ERROR db pool exhausted retry=<*>" count=12 pods=2 level=error first_seen=2026-07-24T20:51:18Z last_seen=2026-07-24T20:52:28Z sample="ERROR db pool exhausted retry=1" …(abridged)… scanned=257 findings=21 elapsed=1.195s ``` Both root causes are named with exact references in the first screen of one call: the bad image tag (`pod.imagepull`, with the tag inline) and the missing ConfigMap key (`edge.missing_key`, naming the key, the env var, the container, and the blast count). Note what is *not* here: the healthy `web` Deployment emits nothing anywhere in this guide — healthy resources are always silent, and the summary line proves they were scanned. If your session started from a sentinel inject, pass the payload straight in — `--incident='{"kind":"k8s-event",…}'` resolves the pod to its owning workload through the graph’s owner chain and produces the same bundle. ## 2. Narrow with targeted reads [Section titled “2. Narrow with targeted reads”](#2-narrow-with-targeted-reads) Cluster-wide sanity check — exactly the abnormal objects, nothing else: ```sh lookout triage delta ``` ```txt kind=pod.imagepull severity=critical namespace=shop kind_of_object=Pod name=checkout-5898857498-vw894 reason=ImagePullBackOff … container=checkout image=busybox:1.36-nonexistent-m1 kind=workload.rollout severity=warning namespace=shop kind_of_object=Deployment name=checkout reason=RolloutIncomplete desired=2 ready=2 updated=1 available=2 scanned=20 findings=2 elapsed=148ms ``` Verify the config wiring claim, and confirm the key really is gone (abridged): ```sh lookout state edges --workload=Deployment/shop/checkout lookout triage spec cm/shop/checkout-config ``` ```txt kind=edge.missing_key severity=critical namespace=shop kind_of_object=ConfigMap name=checkout-config reason=CreateContainerConfigError message="key log.level not found in configmap checkout-config (env LOG_LEVEL in container checkout)" workload=Deployment/shop/checkout container=checkout env=LOG_LEVEL key=log.level pods=3 scanned=146 findings=2 elapsed=230ms kind=spec.resource severity=info namespace=shop kind_of_object=ConfigMap name=checkout-config keys=feature.flags(9B) scanned=1 findings=1 elapsed=64ms ``` The ConfigMap now holds only `feature.flags` — `log.level` is confirmed missing. And the secret-safety contract at work: reading the Secret shows key name and byte size only — ```txt kind=spec.resource severity=info namespace=shop kind_of_object=Secret name=checkout-db keys=password(19B) scanned=1 findings=1 elapsed=61ms ``` ## 3. What the application itself said [Section titled “3. What the application itself said”](#3-what-the-application-itself-said) ```sh lookout triage logs --workload=Deployment/shop/checkout --since=30m ``` ```txt kind=log.fetch_error severity=warning namespace=shop kind_of_object=Pod name=checkout-5898857498-vw894 reason=LogFetchFailed message="container \"checkout\" … is waiting to start: trying and failing to pull image" container=checkout kind=log.template severity=warning namespace=shop template="ERROR db pool exhausted retry=<*>" count=14 pods=2 level=error … sample="ERROR db pool exhausted retry=1" kind=log.template severity=info namespace=shop template="INFO handled request path=<*> status=<*> dur=<*>" count=100 pods=2 level=info … scanned=124 findings=5 elapsed=157ms ``` 124 raw lines distilled to a handful of templates with counts and pod spread — plus an honest `log.fetch_error` for the pod that cannot start, instead of silence. ## The shape of the flow [Section titled “The shape of the flow”](#the-shape-of-the-flow) 1. `bundle` (or `bundle --incident=…`) — the wide, correlated read. The root cause is usually in its critical `delta`/`edges` findings. 2. Targeted reads to confirm and dig: `triage delta`, `state edges`, `triage spec`, `triage logs`. 3. Sudden regression instead? Ask [what changed](/k8s-lookout/guides/what-changed/) *first*. In the drill this double fault was fully root-caused with `lookout` reads alone — no kubectl was needed for the diagnosis. ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) Agents learn this exact decision tree — bundle first, when to go direct, how to read the envelope — from [`skills/k8s-triage`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-triage), with per-symptom playbooks in [`skills/playbooks`](https://github.com/go-steer/k8s-lookout/tree/main/skills/playbooks). # Capacity & quota ahead of time > The correlated quota incident with a drafted increase request attached, and the proactive cloud sweeps — stockout, quota, IP space. Real captured drill output. **The problem:** capacity exhaustion has days of lead time when watched and zero when not. A quota at 98% is invisible right up until the autoscaler fails with `GCE_QUOTA_EXCEEDED` — and then it is an outage with a multi-day increase-request turnaround in the middle of it. Two halves to staying ahead of it: the resident `quota`/`capacity` sources (watch-path, one quota source per GCP project), and the on-demand `cloud` sweeps (read-path). Output below is from a live validation drill — the engine legs run the real merged pipeline over recorded cloud fixtures per the standing drill policy, the Kubernetes legs live on kind; all abridged. ## The escalation, staged as designed [Section titled “The escalation, staged as designed”](#the-escalation-staged-as-designed) **1. The warning forecast does not page.** CPUS/us-east1 at 85%, growing 50/day → ETA \~6 days: a watchboard digest entry, not a session. **2. The critical escalation opens the incident — draft attached.** At 98% and ETA \~16h, one inject carries the diagnosis *and* the paperwork: ```json {"kind":"quota.forecast","reason":"quota_forecast","kind_of_object":"Quota","name":"CPUS", "uid":"quota:CPUS/us-east1", "message":"quota CPUS in us-east1 at 98.0% (usage 1960 / limit 2000), growing 60/day over the last 7d (8 points) — exhausted in ~16h0m0s at current slope; drafted increase to 3000 attached — file it via core-agent's permission gate", "cluster":"kl-m4-drill", "forecast":{"eta":"2026-07-27T04:42:06Z","confidence_basis":"linear-7d-window"}, "quota_increase_draft":{ "quota_id":"compute.googleapis.com/cpus","region":"us-east1", "current_usage":1960,"current_limit":2000, "suggested_limit":3000,"slope_per_day":60, "justification":"CPUS in us-east1 is at 1960 of 2000 (98.0%). Usage grew 60/day over the observation window; at that slope the quota is exhausted in ~16h0m0s (around 2026-07-27). Requesting an increase to 3000 to cover twice the expected request turnaround at the observed growth."}} ``` The draft is formula-pinned (suggested limit covers twice the expected request turnaround at the observed slope, floored at 1.5× the current limit), with a human-grade justification generated from the same numbers the forecast fired on. **`lookout` drafts; it never files.** Submitting the increase request is the agent’s move, through the `core-agent` daemon’s permission gate — the one place in the suite where the managed write path is a clean API call with paperwork attached. **3. The reactive confirmation is the same incident.** When the autoscaler then actually hit the wall (`Quota 'CPUS' exceeded. Limit: 2000.0 in region us-east1.`), the signal re-keyed to the same `quota:CPUS/us-east1` identity and folded into the open session: ```txt store: kind=capacity.quota_blocked canonical_reason=QuotaExhausted route=suppressed session_id=sess-xx ← the critical forecast's session ``` Final ledger, asserted in CI forever: two injects (warning digest + critical incident), two sessions, zero re-fires of the same critical state — one diagnosed incident, not two alerts a human joins. The Kubernetes-visible half ran live: a pod stuck Unschedulable produced three observation angles — the reactive `FailedScheduling` Event, the CA’s `NotTriggerScaleUp`, and the `--pending-age` sweep — all collapsing into one session via the dedup family: ```txt 13:14:32 k8s-event FailedScheduling critical injected stub-sess-0007 13:15:01 capacity.pending pending warning suppressed stub-sess-0007 13:15:43 capacity.pending-aged pending-aged warning suppressed stub-sess-0007 (canonical_reason=FailedScheduling on all three) ``` ## The proactive sweep [Section titled “The proactive sweep”](#the-proactive-sweep) The same questions on demand, from the `cloud` group (these need the GKE-provider build — the `:-gke` image; the default binary reports an explicit `cloud.unavailable` instead). Representative output from the recorded-fixture suites, abridged: **Stockouts, with reroute candidates** — [`cloud stockout`](/k8s-lookout/reference/cloud-stockout/): ```txt kind=stockout.zone severity=warning kind_of_object=Zone name=us-east1-b reason=ZoneResourcePoolExhausted message="GCE stockout: e2-medium exhausted in us-east1-b ×1 in the last 24h0m0s — reroute candidates (same region, no stockout for this type in window): us-east1-c" machine_type=e2-medium events=1 first_seen=2026-07-25T08:40:00Z last_seen=2026-07-25T08:40:00Z reroute=us-east1-c scanned=6 findings=4 elapsed=100ms window=24h0m0s ``` **Quota headroom, ranked nearest-to-exhaustion** — [`cloud quota`](/k8s-lookout/reference/cloud-quota/): ```txt kind=quota.pressure severity=critical kind_of_object=Quota name=IN_USE_ADDRESSES reason=QuotaExhausted message="IN_USE_ADDRESSES exhausted in us-east1 (8/8) — scale-ups fail with GCE_QUOTA_EXCEEDED until an increase lands" scope=us-east1 usage=8 limit=8 pct=100 kind=quota.pressure severity=critical kind_of_object=Quota name=CPUS reason=QuotaNearLimit message="CPUS at 98% of limit in us-east1 — scale-ups fail at 100%; increases need lead time, file now (§10.3)" scope=us-east1 usage=588 limit=600 pct=98 scanned=6 findings=3 elapsed=100ms ``` **IP space, the forgotten quota** — [`cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/): ```txt kind=ipspace.range severity=critical kind_of_object=Subnetwork name=prod-subnet reason=IPRangeNearExhaustion message="pods range at 96.9% of 10.8.0.0/14 — the next allocation fails at 100%: IP space is incompressible" cidr=10.8.0.0/14 purpose=pods used=253952 capacity=262144 pct=96.9 scanned=4 findings=4 elapsed=100ms ``` Rounding out the group, [`cloud orphans`](/k8s-lookout/reference/cloud-orphans/) sweeps for unattached billing-active disks and load balancers targeting zero pods. Why the stockout/quota distinction matters: the remedies are disjoint. Stockout → reroute the node pool to a clean zone (the `reroute=` field and the sentinel’s distilled zone history inform which); quota → file the increase, with the lead time the forecast just bought you. ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) The full workflow — the sweeps, reading `quota.forecast` and the draft, the pending-pod dedup family, filing through the permission gate, and recording the outcome with `triage status` — is taught to agents by [`skills/k8s-capacity`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-capacity). # A node just died > Storm correlation in practice — 33 affected objects, one storm session, and the full member/update/resolved lifecycle. Real captured drill output. **The problem:** a worker node goes dark. Thirty-plus pods are suddenly NotReady, and a naive per-incident pipeline opens thirty-plus sessions — none of which mentions the actual cause. With storm correlation on (`--storm=auto`, the default, resolves on when the graph grants are present), the sentinel groups incidents sharing a **blast-radius key** — the nearest common ancestor in the topology graph — into one `kind=storm` session. All output below is from a live validation drill, abridged: 30 victim pods pinned to `kl-m2-worker2`, then `docker stop kl-m2-worker2` at 00:50:38 — a hard kubelet death. ## Detection and formation [Section titled “Detection and formation”](#detection-and-formation) ```txt 00:51:18 fire node_notready pod=/kl-m2-worker2 → sid=stub-sess-0005 (mode=per-incident) 00:51:18 fire NodeNotReady pod=kube-system/kube-proxy-pkx94 → sid=stub-sess-0006 (mode=per-incident) 00:51:18 enrich storm Node kl-m2-worker2: 5273B (outcome=ok, sections=1, truncated=false, errors=0) 00:51:18 storm formed on Node kl-m2-worker2: 3 incidents across 2 namespace(s) → sid=stub-sess-0007 (mode=per-incident) 00:51:18 storm attach NodeNotReady stormlab/victim-… (sid=stub-sess-0007, members=4) ⋮ (one attach line per member) 00:51:20 storm attach NodeNotReady stormlab/victim-7d47b46468-mnf6b → Node kl-m2-worker2 (sid=stub-sess-0007, members=33) ``` Forty seconds from kubelet death to detection. The leading `objectstate.node_notready` transition opened the node incident *before* the reactive `NodeNotReady` Event arrived (which then joined its dedup family); the storm formed the same second; all 33 members — 30 victims plus the node’s own `kube-proxy` and CNI pods, which are real storm members — attached within two seconds. ## The session ledger: 3, not 33 [Section titled “The session ledger: 3, not 33”](#the-session-ledger-3-not-33) The complete session/inject ledger for the burst window, from the captured daemon side: ```txt SESSION-CREATE ×3 (stub-sess-0005, -0006, -0007) INJECT sid=stub-sess-0005 kind=objectstate.node_notready (the seed incident) INJECT sid=stub-sess-0006 kind=k8s-event (2nd pre-storm arrival) INJECT sid=stub-sess-0007 kind=storm (THE storm session) INJECT sid=stub-sess-0007 kind=storm.member ×30 INJECT sid=stub-sess-0005 kind=storm.member_superseded INJECT sid=stub-sess-0006 kind=storm.member_superseded ``` Zero of the 30 victim pods opened a session. The first two arrivals fired per-incident before the formation threshold (`--storm-min=3`) was reachable — inherent to any burst — and were immediately superseded: `storm.member_superseded` pointers landed in their sessions and their dedup bindings were rebound, so all their followups and outcomes route to the storm. The `kind=storm` inject itself (abridged) names the ancestor, the spread, and representative incidents, and carries a radius-only enrichment bundle — the blast map of the node from the live topology snapshot: ```json {"kind":"storm","fingerprint":"sha256:48bb2e3a…","severity":"critical", "cluster":"kl-m2","ancestor_kind":"Node","ancestor_name":"kl-m2-worker2", "reason":"NodeNotReady", "message":"Node kl-m2-worker2: 3 incidents across 2 namespace(s) share this blast-radius key; 3 representative incident(s) attached; member sessions are suppressed and route here", "affected_count":3,"namespaces_count":2, "…":"…"} ``` `affected_count` is the formation-time number; as membership grows, the sentinel injects schema-stable `kind=storm.update` refreshes (`affected_count`, `namespaces_count`, `new_members_since_last`) so the headline size is readable without folding the whole session. (That followup kind exists *because* this drill showed the formation payload underselling the final blast radius.) ## Recovery: the storm collapses the bookkeeping too [Section titled “Recovery: the storm collapses the bookkeeping too”](#recovery-the-storm-collapses-the-bookkeeping-too) `docker start kl-m2-worker2` at 00:52. As victims returned Ready and held stable, 32 member `kind=resolved` records (`resolution=recovered`, `cleared_after≈2m50s`) flowed into the storm session — and once every member *including the node’s own incident* clears, the storm’s aggregate `resolved` fires. One session tells the whole story: cause, spread, representative details, and verified recovery. Working such a session as a human, the reads are the usual ones: ```sh lookout triage radius Node//kl-m2-worker2 # the live blast map lookout triage delta --only=pods,nodes # what is still abnormal now lookout stab drain # before maintenance: what will block a drain ``` ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) The incident-investigation decision tree an agent applies inside a storm session — radius for impact, delta for current state, bundle for any member worth its own dig — is [`skills/k8s-triage`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-triage). # Catch resource exhaustion early > A staged memory leak, forecast by slope — warning 24 minutes before failure, a critical session 14 minutes before the OOM kill, ETA accurate to 31 seconds. Real captured drill output. **The problem:** a pod is leaking memory. Nothing is failing yet — it is Running, Ready, zero restarts — and nothing reactive will say a word until the kernel OOM-kills it. The first conventional failure marker *is* the outage. The sentinel’s `saturation` source samples continuously (metrics.k8s.io + kubelet volume stats) and fits a slope: not “at 54% of limit” but “limit reached in \~14m at the observed trend”. Deterministic arithmetic, not ML — and it owns the time series a one-shot read never had. All output below is from a live validation drill, abridged: pod `drill-b/leaker` with a 64Mi memory limit, leaking \~1MiB every 30s, started 10:50:27. The drill ran `--saturation-window=10m` (production default 90m). ## The timeline [Section titled “The timeline”](#the-timeline) ```txt 10:56:25 watchboard: buffered saturation.forecast drill-b/leaker (severity=warning) (+5m58s) pod at signal time: phase=Running ready=True restarts=0 (17Mi/64Mi) 11:05:56 fire forecast_memory pod=drill-b/leaker → sid=stub-sess-0010 (+15m29s, critical escalation) pod at signal time: phase=Running ready=True restarts=0 11:20:04 OOMKilled (restartCount 0→1) — the FIRST failure marker of any kind ``` Three stages, by design: 1. **Warning** once enough samples span the window (severity routing sends it to the watchboard digest — a trend worth knowing, not a page). 2. **Critical escalation** when the ETA drops under the critical threshold (default 15m): a per-incident session opens while the pod is still healthy by every conventional measure. 3. **The failure itself**, which in this drill arrived 14m08s after the session opened — and 31 seconds off the forecast ETA. The critical inject, captured verbatim on the wire: ```json {"kind":"saturation.forecast","reason":"forecast_memory", "namespace":"drill-b","kind_of_object":"Pod","name":"leaker","container":"leaker", "message":"memory saturation forecast for leaker: current=34.8MiB limit=64.0MiB slope_per_min=2.0MiB — limit reached in ~14m39s at the observed trend (20 samples over 10m0s)", "cluster":"kl-m3","context":{"node":"kl-m3-worker"}, "forecast":{"eta":"2026-07-26T11:20:35.49782084Z","confidence_basis":"linear-10m-window"}} ``` Read the `forecast` attachment: `eta` is the projected exhaustion instant; `confidence_basis` names the fit and window (`linear-10m-window` here because of the drill flag — `linear-90m-window` at defaults), so a consumer knows exactly how much to trust it. A forecast is only emitted when samples span at least half the window — insufficient data produces no forecast, never a wild one. ## What to do with the lead time [Section titled “What to do with the lead time”](#what-to-do-with-the-lead-time) With \~14 minutes of margin the remedies are ordinary instead of emergency: raise the limit, roll the pod at a quiet moment, or fix the leak. Point-in-time confirmation and neighborhood checks: ```sh lookout triage top --namespace=drill-b # saturation vs limits, right now lookout triage radius Pod/drill-b/leaker # who shares the node with it ``` The same slope → ETA machinery covers the other incompressible resources: PVC fill (`forecast_volume`), project quota headroom ([`quota.forecast`](/k8s-lookout/guides/capacity-quota/)), IP space, and even agent token spend (`token.burn`). Not every saturation problem is a trend, and the drill caught one of the other kind too: deleting a pod dropped a Service’s ready ratio 2/2 → 1/2, and the `degradation` source fired `degradation.capacity` with the step timeline in the message (`ratio 1.00→0.50 … timeline: 2/2 → 2/3 → 2/2 → 1/2`) — then resolved it when the replacement came Ready. ## The epilogue writes itself [Section titled “The epilogue writes itself”](#the-epilogue-writes-itself) Whatever the fix, the loop closes as always: the source watches the ETA recede (clearance requires it to recede well past the firing threshold — hysteresis, no flapping), and a `kind=resolved` record lands in the session. In the drill run the process recorded ten recoveries with zero reverts. ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) Agents working a `saturation.forecast` session learn the follow-up reads — `triage top`, `triage radius`, `bundle` — from [`skills/k8s-triage`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-triage); scheduled whole-cluster sweeps that would catch the same trend are taught by [`skills/cluster-health`](https://github.com/go-steer/k8s-lookout/tree/main/skills/cluster-health). # Your rollout is stuck > rollout.stall fires while the old revision still serves — a session before any user notices, a rollback, and a verified resolved record. Real captured drill output. **The problem:** you shipped a deploy and nothing seems to be happening. With `maxUnavailable=0`, a bad deploy *cannot* hurt users — as long as somebody notices before it proceeds. The default noticer is `progressDeadlineSeconds` (600s by default), which is an autopsy timer. The sentinel’s `rollout` source is evidence-based instead: a new ReplicaSet making zero ready-count progress for `--rollout-observe` (default 3m) *while the old revision stays healthy* is a probable bad deploy — fired well before the deadline. All output below is from a live validation drill (kind cluster, sentinel with `--sources=…,rollout,…`), abridged. The staged failure: `drill-a/webapp` (2 replicas serving HTTP behind a Service, `maxUnavailable=0 maxSurge=1`) updated to a valid image with a crashing command. ## The timeline — leading beats reactive at the right altitude [Section titled “The timeline — leading beats reactive at the right altitude”](#the-timeline--leading-beats-reactive-at-the-right-altitude) Sentinel log from the drill, bad deploy applied at 10:51:41: ```txt 10:51:47 fire BackOff pod=drill-a/webapp-55866d5cff-cwgp4 → sid=stub-sess-0004 (reactive, pod-level, +6s) 10:54:55 watchboard: buffered rollout.stall drill-a/webapp (severity=warning) (leading, Deployment-level, +3m14s) 10:55:59 watchboard: digest 1 entry(ies) → sid=stub-sess-0009 10:58:24 kubectl rollout undo 10:58:40 resolved rollout_stall drill-a/webapp → sid=stub-sess-0009 (resolution=recovered) ``` The reactive pod events are earlier (+6s) but name the wrong object at the wrong altitude: a crashing pod, not a stalled Deployment. `rollout.stall` is the signal that says “probable bad deploy, old revision healthy” — and in this drill it beat `progressDeadlineSeconds`’ own leading indicator by almost five minutes. **Proven user-invisible**, captured mid-stall, between the signal and the rollback: ```txt HTTP/1.0 200 OK × 5 (Server: SimpleHTTP/0.6 Python/3.12.13 — the OLD revision) webapp-55866d5cff-cwgp4 0/1 CrashLoopBackOff 4 webapp-77f8d7558c-7gvwx 1/1 Running 0 webapp-77f8d7558c-c2nmj 1/1 Running 0 ``` ## Choosing the routing: watchboard or page [Section titled “Choosing the routing: watchboard or page”](#choosing-the-routing-watchboard-or-page) `rollout.stall` defaults to warning class — it lands in the shared watchboard digest, not a page. That’s the right default for production (the deploy is *not hurting users*). For a staging cluster where bad deploys should page, turn the severity knob; the drill’s second run did exactly that (`--severity=rollout.stall=critical`): ```txt 11:09:12 bad deploy #2 applied (new revision, crashing command) 11:12:22 enrich drill-a/webapp: 3019B (outcome=ok, sections=4) 11:12:22 fire rollout_stall pod=drill-a/webapp → sid=stub-sess-0019 (+3m10s, own session, enriched) 11:13:32 kubectl rollout undo 11:13:52 resolved rollout_stall → sid=stub-sess-0019 (resolution=recovered) ``` The session opens with the full enrichment bundle attached, and the signal’s message carries the evidence verbatim: ```txt rollout stalled: new ReplicaSet webapp-6cfdc68df new_ready=0/1 old_ready=2/2 elapsed=3m10s — new-revision pods failing while the old revision stays healthy (probable bad deploy, fired ahead of progressDeadlineSeconds) ``` ## The fix, and the loop closing [Section titled “The fix, and the loop closing”](#the-fix-and-the-loop-closing) The fix is a standard rollback — `kubectl rollout undo` (or a GitOps revert). Nobody polls to confirm: the same source that watched the stall appear watches the rollout complete, and a schema-stable `kind=resolved` (`resolution=recovered`) record lands in the same session. Session ledger: one create, one enriched stall inject, one resolved. Done. If you’re investigating a stall by hand rather than from an inject, `lookout triage delta` surfaces it as `workload.rollout reason=RolloutIncomplete` with the desired/ready/updated counts, and `lookout triage changes Deployment// --since=30m` names the rollout that started it — see [what changed before the incident](/k8s-lookout/guides/what-changed/). ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) The investigation surface for stalled workloads — bundle-first flow, `triage changes` for the trigger, the output-envelope semantics — is taught to agents by [`skills/k8s-triage`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-triage). # What changed before the incident > Post-mortems with --at — blast radius and change timeline as of onset, answered offline from a copied sentinel store, 29 minutes after the cluster moved on. Real captured drill output. **The problem:** the incident is over — or at least the cluster has moved on. The broken ReplicaSet was scaled to zero, its pods deleted, neighbors replaced. Now comes the post-mortem, and its two central questions — *what was the blast radius at onset?* and *what changed just before?* — are about a topology that no longer exists. A sentinel running with `--store` records exactly that topology: periodic graph snapshots plus a per-delta change log in its SQLite store. The graph-backed commands accept `--at=` with `--store=` and answer as of that instant. All output below is from a live validation drill, abridged. The setting: a bad deploy of `drill-a/webapp` stalled at **10:54:55** (the onset — see [Your rollout is stuck](/k8s-lookout/guides/stuck-rollout/)); it was rolled back at 10:58; a neighbor pod was deleted and replaced at 11:15; dozens of snapshot intervals elapsed. ## 1. Get the store [Section titled “1. Get the store”](#1-get-the-store) History reads are fully offline — no cluster access on the query path. Copy the store off the node and query it anywhere: * The store lives where the sentinel’s `--store` flag points (the drill: `/data/lookout.db` on a hostPath; a standard deployment: `/var/lib/lookout/lookout.db` on the sentinel’s volume). * The distroless sentinel image has no `tar`, so `kubectl cp` cannot reach it. On kind: `docker cp :/var/lib/lookout/lookout.db .` — on GKE, node-pool SSH plus `gcloud compute scp` (the drill runbooks in [`dev/drills/`](https://github.com/go-steer/k8s-lookout/tree/main/dev/drills) spell it out). SQLite’s WAL absorbs copying next to the live writer. ## 2. Blast radius as of onset [Section titled “2. Blast radius as of onset”](#2-blast-radius-as-of-onset) At 11:23:29 — 28m34s after onset — against the copied store: ```sh lookout triage radius webapp-55866d5cff-cwgp4 -n drill-a --at=2026-07-26T10:54:55Z --store=lookout.db ``` ```txt kind=radius.neighbor … kind_of_object=ReplicaSet name=webapp-55866d5cff direction=upstream relation=Owns hop=1 kind=radius.neighbor … name=webapp-77f8d7558c-7gvwx direction=lateral relation=shared-node hop=2 shared=Node/kl-m3-worker kind=radius.neighbor … name=webapp-77f8d7558c-c2nmj direction=lateral relation=shared-node hop=2 shared=Node/kl-m3-worker …(9 same-node co-tenants)… kind=radius.neighbor … kind_of_object=Node name=kl-m3-worker direction=downstream relation=RunsOn hop=1 scanned=69 findings=13 elapsed=6ms source=history at=2026-07-26T10:54:55Z ``` The at-onset answer contains the broken-revision pod, its ReplicaSet, and the two old-revision pods that were still serving. The same question asked live proves the point: ```txt $ lookout triage radius webapp-55866d5cff-cwgp4 -n drill-a # same question, LIVE lookout triage radius: workload Pod/drill-a/webapp-55866d5cff-cwgp4 not found in the topology ``` The live cluster no longer knows the pod existed. History and live demonstrably differ — that difference is what the store is for. ## 3. The change timeline before onset [Section titled “3. The change timeline before onset”](#3-the-change-timeline-before-onset) ```sh lookout triage changes webapp-55866d5cff-cwgp4 -n drill-a --at=2026-07-26T10:54:55Z --since=10m --store=lookout.db ``` ```txt …(abridged)… kind=change.rollout … kind_of_object=ReplicaSet name=webapp-55866d5cff reason=Added at=2026-07-26T10:51:41Z relation=upstream origin=log kind=change.rollout … kind_of_object=Pod name=webapp-55866d5cff-cwgp4 reason=Added at=2026-07-26T10:51:41Z relation=self origin=log scanned=149 findings=12 … source=history at=2026-07-26T10:54:55Z window=2026-07-26T10:44:55Z..2026-07-26T10:54:55Z ``` The last change before onset is the bad-revision rollout, 3m14s before the stall — the “what changed” answer, with provenance (`origin=log`) and the window printed on the summary line. ## Reading the summary-line source [Section titled “Reading the summary-line source”](#reading-the-summary-line-source) Always check `source=` before trusting a historical answer: * `source=history at=…` — served from the store’s snapshot + replay; the full recorded delta log. * `source=live` — the current graph; no history involved. * `source=live-approximation` — no store available: `triage changes` reconstructs rollouts and recent scale events from current API state and Events (the drill’s comparison run recovered the per-revision images and all four scale steps this way), but cannot see un-timestamped updates — ConfigMap edits, label flips, old cordons. The honest degraded answer, marked as such. Two current limits, found and documented by the drill: replay cannot cross a sentinel restart (query within one incarnation), and historical targets must be a Pod or ReplicaSet (the sentinel graph feed holds Deployments identity-only). Both are known, tracked gaps. ## As an agent skill [Section titled “As an agent skill”](#as-an-agent-skill) The post-hoc `--at`/`--store` procedure is taught to agents in [`skills/k8s-triage`](https://github.com/go-steer/k8s-lookout/tree/main/skills/k8s-triage) (the “state at onset” section); change-timeline auditing on GitOps clusters is [`skills/gitops-drift`](https://github.com/go-steer/k8s-lookout/tree/main/skills/gitops-drift). # k8s-lookout > Cluster diagnostics for AI troubleshooting agents — compact, secret-safe answers about what's broken in a Kubernetes cluster, and an optional watcher that opens incidents the moment trouble starts. `k8s-lookout` helps AI troubleshooting agents see what is happening inside a Kubernetes cluster. It answers the questions an investigation runs on — what is broken, what changed, who is affected — in a form small enough and safe enough to hand straight to a language model, and it can watch the cluster and tell your agent the moment something starts going wrong. ## The problem [Section titled “The problem”](#the-problem) An AI agent troubleshooting a cluster is only as good as what it can see. Point it at raw `kubectl` output and it drowns: describing one crash-looping payment service takes a dozen slow commands, most of the output is routine noise, and some of it — environment variables, mounted Secrets — is exactly the material that must never reach a model. And even a well-equipped agent only investigates when asked. Nobody tells it the moment a bad image tag ships or a certificate is three days from expiring. ## What’s in the box [Section titled “What’s in the box”](#whats-in-the-box) Everything ships as one binary, `lookout`, used three ways — each one works without the others: * **The `lookout` CLI** — one-shot diagnostic commands (`lookout health`, `lookout triage delta`, `lookout bundle`, …) that you or a shell-capable agent run against your current kubeconfig. Nothing gets deployed; this is the two-minute starting point. * **The MCP server** — `lookout mcp` exposes every one of those commands 1:1 as MCP tools, for agent runtimes that speak MCP instead of shelling out. Same checks, same output, same sanitizer. * **The sentinel** — `lookout watch`, an optional daemon you deploy into the cluster. It watches for trouble as it develops — a stalling rollout, memory climbing toward a limit, a certificate counting down — and opens an incident session for your agent, often before users notice anything. `lookout` never writes to your cluster. It reads, watches, and reports; any fix goes through your agent’s own approval process. ## A taste [Section titled “A taste”](#a-taste) “Any issues with this cluster?” is one command: ```sh lookout health ``` ```console kind=health.category severity=info category=nodes status=healthy kind=health.category severity=warning category=crashloops status=degraded total=8 top="pod.restarts agent-sandbox-system/agent-sandbox-controller-7c69875fcc-n7xms; pod.restarts kube-system/coredns-7d764666f9-g82j9; …" kind=health.category severity=info category=rollouts status=healthy scanned=16 findings=18 elapsed=537ms ``` (Real output against a kind cluster, abridged.) Every category reports, healthy resources stay quiet, and the last line always says what was scanned — so “all clear” is never ambiguous silence. ## Where next [Section titled “Where next”](#where-next) * **Just want to point it at a cluster?** → [Getting started](/k8s-lookout/getting-started/) — install the CLI and run the first commands, nothing deployed. * **Want a 20-minute guided demo?** → [Tutorial](/k8s-lookout/getting-started/tutorial/) — a disposable kind cluster, staged failures, and the full detect → enrich → resolve loop. * **Are you an AI agent setting this up?** → [For AI agents](/k8s-lookout/agents/) — the dense, copy-runnable version, plus [llms.txt](https://go-steer.github.io/k8s-lookout/llms.txt). * **Want to know what it actually detects?** → [What lookout detects](/k8s-lookout/detect/) — one coverage page per mode: [`scan`](/k8s-lookout/detect/scan/) for incidents, [`audit`](/k8s-lookout/detect/audit/) for posture, [the sentinel](/k8s-lookout/detect/sentinel/) for what only a resident process can see. * **Want incidents to open themselves?** → [What the sentinel watches](/k8s-lookout/detect/sentinel/) — what it monitors out of the box and what you can turn on, then [deploy it](/k8s-lookout/getting-started/deploy/) with one `kubectl apply`. * **Wiring up an agent?** → [MCP setup](/k8s-lookout/getting-started/mcp/) for any MCP-speaking runtime, or [Integrations](/k8s-lookout/getting-started/integrations/) for shell-capable agents and webhook receivers. * **Want to understand how it works?** → [Concepts](/k8s-lookout/concepts/) — the mental model behind the commands. Container images live at `ghcr.io/go-steer/lookout`; everything works out of the box with [`core-agent`](https://github.com/go-steer/core-agent). The full command surface is in the [Reference](/k8s-lookout/reference/), and day-2 sentinel material in [Operations](/k8s-lookout/operations/). # Operations > Running the sentinel in production — the occurrence store, the watchboard, drills, metrics, and troubleshooting. This section is for whoever keeps the sentinel running after it is deployed — a platform or SRE hat more than an agent-author one. By the end you will know what the sentinel stores on disk and for how long, how warning-level noise stays bounded, how to verify the sentinel is healthy and what to alert on, and what its startup errors mean. Day-2 material for a deployed sentinel (deploying it in the first place is [Getting started → Deploy the sentinel](/k8s-lookout/getting-started/deploy/)): * [The occurrence store](/k8s-lookout/operations/store/) — what `--store` records, its TTL/size bounds, copying it off a pod for post-mortems, `--at` time-travel queries, and epoch semantics across restarts. * [The watchboard](/k8s-lookout/operations/watchboard/) — how warning-class noise is batched into digests, the size-based rotation lifecycle, and lineage. * [Drills & verification](/k8s-lookout/operations/drills/) — the staged-failure runbooks in `dev/drills/` and when to run them. * [Observing `lookout`](/k8s-lookout/operations/observability/) — the Prometheus metrics, `/healthz`, startup-log verification, and what to alert on. * [Troubleshooting](/k8s-lookout/operations/troubleshooting/) — RBAC probe failures, source-by-source requirements, common startup errors verbatim, and what the `unavailable` markers mean. * [Scoping a sentinel](/k8s-lookout/operations/scoping/) — narrowing what one sentinel watches: `--exclude-namespace` as a real watch scope, why `--namespace` is not one, and which sources can be split into their own deployment without paying for a second cache. The generated [`lookout watch` flag table](/k8s-lookout/reference/watch/) and [Prometheus metrics reference](/k8s-lookout/reference/metrics/) are the authoritative surfaces these pages link into. # Drills & verification > The staged-failure runbooks in dev/drills/ — what each proves, when to run them, and the captured evidence they replay. [`dev/drills/`](https://github.com/go-steer/k8s-lookout/tree/main/dev/drills) contains runbooks that replay the scenarios `lookout` is validated against — staged failures, run against a **real GKE staging cluster** — plus the fixtures they use: * [`stub-daemon.py`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/stub-daemon.py) — a small capture daemon implementing `POST /sessions` and `POST /sessions//inject`, logging every request body. `kubectl logs` of the stub is the wire-level evidence capture. * [`memory-leaker.py`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/memory-leaker.py) — the tunable leak fixture for the memory-leak drill. Every drill is **staging-only** by design — they kill nodes, ship crashing images, and saturate quotas. Each runbook opens with its blast warning; take it literally. ## When to run them [Section titled “When to run them”](#when-to-run-them) * **After first deploying the sentinel to a new environment** — a drill is the end-to-end proof that RBAC, the daemon wiring, and the enabled sources actually work on your cluster, with real timing (real image pulls, real node-monitor grace periods) rather than the kind-cluster originals. * **Before turning on a new source or flag set in production** — the drills’ flag blocks are the tested reference configurations. * **To produce corpus records** — each drill ends with schema-stable `kind=resolved` outcomes; the captured store and stub log are harvestable labeled trajectories (`dev/tools/harvest-corpus`). ## The drills [Section titled “The drills”](#the-drills) | Runbook | What it proves | Recorded run | | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [`node-failure.md`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/node-failure.md) | Storm correlation + fix-verify: a killed node produces **1 storm session, not 30** (the kind run: 3 session creates for 33 affected objects), and recovery injects close every member without any agent polling. Includes the VM-stop-vs-drain distinction — a graceful drain exercises a different storm key and is the rehearsal, not the replay. | [`docs/milestones/M2.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/milestones/M2.md) | | [`bad-deploy.md`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/bad-deploy.md) | A bad rollout under `maxUnavailable=0` fires `rollout.stall` on the Deployment (\~3m, ahead of `progressDeadlineSeconds` by \~5m) while users keep getting 200s from the old revision; plus the post-mortem half — copying the store off the node and answering “blast radius at onset” with `--at` after the cluster has moved on. | [`docs/milestones/M3.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/milestones/M3.md) | | [`memory-leak.md`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/memory-leak.md) | A slow leaker under a memory limit produces `saturation.forecast` — ETA and confidence basis attached — while the pod is still Running/Ready, minutes before the kernel OOM-kills it (kind run: critical session 14 minutes before the OOM, forecast ETA accurate to 31 seconds). Explains the window-vs-drill-time tradeoff (`--saturation-window`). | [`docs/milestones/M3.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/milestones/M3.md) | | [`quota-exhaustion.md`](https://github.com/go-steer/k8s-lookout/blob/main/dev/drills/quota-exhaustion.md) | The full quota story against real GCP APIs: a quota driven toward its limit yields `quota.forecast` with the drafted increase request attached; the autoscaler slamming into it folds into the **same** incident; filing the draft goes through the permission gate (you run the `gcloud` command — `lookout` only reads); plus the mid-incident `health --store` triage-state check. Maps every test fixture to the real API it stands in for. | [`docs/milestones/M4.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/milestones/M4.md) | The runbooks share infrastructure deliberately: the same stub daemon, the same `deploy/` manifests applied unmodified, and flag sets that build on each other (the bad-deploy and memory-leak drills use one sentinel configuration). Each names the exact flags of its recorded run, with drill-tuned values (shorter windows, faster snapshots) marked against the production defaults. Keep the captures. Stub logs, sentinel logs, metrics scrapes, and the copied store are the drill record — the recorded runs linked above are exactly that material for the original runs, and the resolved payloads in yours are corpus records. # The standalone leeway binary > Running the placement subsystem on its own as a metrics-only process — what it is for, what it gives up, and why it must never run alongside lookout watch on the same cluster. `lookout watch` already runs the placement subsystem. If you are running the sentinel, you have topology drift and compute-class ranks already, with a store behind them and findings on the wire, and this page is not for you. `leeway` is a second, much smaller binary that runs the same two sources — `topology-drift` and `compute-class` — against one cluster and exports their metrics. Nothing else: no event watcher, no store, no inject path, no read-path commands. It ships in the same image and the same release as `lookout`. The two sources behave identically either way, so everything in [Tuning placement findings](/k8s-lookout/operations/placement/) — the tiers, the flags that move them, the cardinality controls and the SLIs that say whether the numbers are sound — applies here unchanged. ## The constraint [Section titled “The constraint”](#the-constraint) **Do not run `leeway` against a cluster that is already running `lookout watch`.** Both processes build a Pod informer. On a large cluster that is the single most expensive watch there is, and running two of them doubles the apiserver’s outbound traffic and the memory held by the caches, in exchange for a second copy of numbers you already have. Consolidating onto one shared informer is a thing the sentinel went out of its way to do; standing a second process next to it undoes that in one step. [Sizing a sentinel](/k8s-lookout/operations/sizing/) is what that second cache costs — the pod-cache row applies unchanged here, because it is the same cache. There is no interlock that stops you. Nothing in either binary can see the other, and a cluster has no place to record “a sentinel is already here”. It is a deployment-time decision, which is why it is written down here rather than enforced in code. If both are deployed by accident, the symptom is not an error. It is a doubled `apiserver_longrunning_requests`, two scrape targets reporting almost-but-not-quite the same `lookout_leeway_*` series, and findings that appear twice in whatever consumes them. To tell the two targets apart, look for `lookout_leeway_standalone_info` — the standalone exports it and the sentinel does not. ## What it is for [Section titled “What it is for”](#what-it-is-for) Three cases, and they are the only three: **A cluster that does not run lookout.** You want the placement signal and you are not ready to adopt the sentinel. The RBAC is small — pods, nodes and replicasets, read-only — and the output is a scrape endpoint. **A metrics-only posture.** The consumer is Grafana and a rotation, not an agent. The sentinel’s inject path, store and watchboard are all things you would be running and not reading. **Scale and soak work.** Isolating the subsystem under kwok is how its cost is measured without the rest of the sentinel in the same RSS number. `--exit-after` exists for exactly this: a bounded run that ends itself rather than being killed. ## What it gives up [Section titled “What it gives up”](#what-it-gives-up) | | `lookout watch` | `leeway` | | ------------- | -------------------------------------------- | ------------------------------------------------------ | | Findings | signals → inject path, watchboard, sinks | `/metrics` only | | Store | dwell timers and baselines survive a restart | in-memory; a restart resets them | | Other sources | all of them | these two | | Flag surface | the full set | the knobs that change what is watched or what it costs | | Multi-cluster | `--clusters-from`, one process, N runners | one cluster per process | Running store-less is a supported posture, not a degraded one: current placement is rebuilt from the informers on every start, so what a restart costs is the dwell timers that were part-way through and the learned baselines, not correctness. A finding that was real before the restart becomes real again one dwell later. ## Running it [Section titled “Running it”](#running-it) In a pod, with the same service account the sentinel would use: ```yaml containers: - name: leeway image: ghcr.io/go-steer/lookout:latest # The image entrypoint is `lookout watch`; the standalone is a # second binary in the same image and has to be asked for. command: ["/leeway"] args: - "--in-cluster" - "--cluster-name=prod-eu-west1" - "--metrics-addr=:9090" ports: - name: metrics containerPort: 9090 livenessProbe: httpGet: { path: /healthz, port: metrics } readinessProbe: httpGet: { path: /readyz, port: metrics } ``` Locally, against whatever your kubeconfig points at: ```plaintext leeway --metrics-addr=127.0.0.1:0 --exit-after=5m ``` `--metrics-addr` accepts port `0`, which binds a free one and logs which; that is how you run it on a machine that is already using 9090. Set `--cluster-name` whenever more than one of these reports to one backend. It becomes the `cluster` label on every series, and without it two clusters are indistinguishable in the same query. ## Sources [Section titled “Sources”](#sources) `--sources` defaults to both. `compute-class` reads a GKE CRD, so on any cluster that does not serve it — kind, kwok, anything not GKE — it is **skipped with a line on stderr** and the process carries on with `topology-drift` alone. Naming it explicitly means something different. `--sources=compute-class` on a cluster without the CRD is a startup failure, because an operator who typed it is relying on it, and the likely cause is the wrong kubeconfig rather than the wrong cluster. This is the same asymmetry the sentinel’s `--sources=auto` draws. ## Endpoints [Section titled “Endpoints”](#endpoints) * `/metrics` — the Prometheus endpoint. Everything documented under [Metrics](/k8s-lookout/reference/metrics/) for these two sources appears here unchanged, plus `lookout_leeway_standalone_info` and `lookout_leeway_standalone_signals_total`. * `/healthz` — liveness. Up means the process is not wedged. It deliberately does not consult the sources: a process that cannot reach the apiserver is not helped by being restarted. * `/readyz` — readiness. 503 until every source’s initial LIST has drained, naming the one that has not. Until then the gauges are an undercount rather than a reading, which is not a state to route a rollout past. `--otel-exporter=otlp` adds a push reader configured by the standard `OTEL_EXPORTER_OTLP_*` environment. It does not turn `/metrics` off — the scrape endpoint is unconditional. ## Findings [Section titled “Findings”](#findings) There is nowhere for a finding to go, so it goes to two places that are not the wire: `lookout_leeway_standalone_signals_total`, labelled by source, kind and severity, and one line per finding on stderr. The counter is a rate, not a state. Which subjects are in breach right now is what the sources’ own gauges say; how often the subsystem decided something was worth saying is what this says, and that is the thing no gauge can reconstruct. # Observing lookout > The Prometheus metrics surface, /healthz and /readyz, startup-log verification, and which counters to alert on. The watcher of the cluster needs watching too. The sentinel’s own observability surface is `--metrics-addr` (the shipped manifest sets `:9090`), which serves Prometheus metrics on `/metrics` plus two probes. ## The two probes [Section titled “The two probes”](#the-two-probes) They answer different questions, and the shipped Deployment points one each at them: * **`/healthz`** (liveness) — a static 200. The process is up. It deliberately does not depend on `/metrics` or on any cluster call, so a cluster outage does not get the sentinel killed and restarted into the same outage. * **`/readyz`** (readiness) — 200 only once every source with an initial-LIST barrier has crossed it, for every cluster this process watches. A sentinel spends its first seconds listing each informer’s world; it is running and blind in that window, so it reports `503` with the reason: ```plaintext not ready: informer caches syncing ``` A process watching a named fleet (`--clusters`, or a single `--cluster-name`) names the stragglers instead, since there the question is *which* cluster: ```plaintext not ready: waiting on 1 of 2 cluster(s): [prod-west (syncing)] ``` The poll-driven sources (`expiry`, `quota`, `saturation`, `notifications`, `token-burn`) have no cache to fill and never hold readiness. A runner that exits and is waiting on its supervisor backoff withdraws from readiness too — `not ready: cluster runner not started`, or `… (not started)` in the named-fleet form. Neither probe fails because of RBAC. A permission the sentinel does not have is a config problem an operator has to fix; restarting the pod or pulling it out of a rollout does not fix it, and doing either turns one missing grant into an outage. What a denial does instead is [give up on that one cluster](#a-cluster-the-sentinel-has-given-up-on). ### `?verbose` [Section titled “?verbose”](#verbose) `/readyz?verbose` adds a line per cluster — on the `200` as well as the `503`, in the same shape kube-apiserver uses: ```plaintext $ curl -s localhost:9090/readyz?verbose [!]prod-ap degraded: access_denied [-]prod-eu syncing [+]prod-us watching readyz check failed: waiting on 1 of 2 cluster(s): [prod-eu (syncing)] ``` `[+]` watching, `[-]` not there yet, `[!]` given up on — excluded from the verdict, which is why the count says 2 and not 3. ### A cluster the sentinel has given up on [Section titled “A cluster the sentinel has given up on”](#a-cluster-the-sentinel-has-given-up-on) When a runner’s startup probe is refused by the cluster’s authorizer (§11), retrying cannot help: the answer will be the same until someone edits a ClusterRoleBinding. So the supervisor stops restarting that runner, logs the refusal once, and marks the cluster degraded: ```plaintext runner[prod-ap]: exited: source "k8sevents" requires permission to "watch events cluster-wide" … runner[prod-ap]: NOT restarting — that failure is settled (access_denied), so every retry would be refused the same way … ``` The process keeps watching every other cluster and stays ready, because it is still fit to serve them. **The alert to write is on the metric**: ```plaintext lookout_runner_terminal{cluster="prod-ap",reason="access_denied"} 1 ``` A series at `1` means a cluster in your fleet is dark and will stay dark until a grant changes. If *every* cluster goes that way the process exits non-zero instead — there is nothing left to be ready for, and the kubelet’s backoff is the right retry. Every other exit is still transient and still restarts, now with a backoff that doubles from 10s to a 5m ceiling and resets after a runner has stayed up two minutes (`lookout_runner_restarts_total`). ### A grant that goes away later [Section titled “A grant that goes away later”](#a-grant-that-goes-away-later) The same thing happens to a cluster whose grant is revoked *while* the sentinel is running, because the startup probe is only a point-in-time answer. Every source’s declared access is re-reviewed every `--access-recheck` (default `2m`), and a denial confirmed over two consecutive sweeps sets: ```plaintext lookout_source_denied{cluster="prod-ap",source="k8s-events",resource="events",required="true"} 1 ``` Back to `0` if the grant returns — the series is “is coverage missing right now”, not “was it ever”. A **required** permission takes the cluster down the terminal path above; an **optional** one (`required="false"`, saturation’s `nodes/proxy`) leaves the source running with one dimension dark. Either way a `kind=sentinel.access_revoked` signal is injected, because a source that has gone quiet because it lost permission to look is not a source reporting a healthy cluster. Full walkthrough in [Troubleshooting](/k8s-lookout/operations/troubleshooting/#a-grant-revoked-after-startup). ### A cluster that never got a runner [Section titled “A cluster that never got a runner”](#a-cluster-that-never-got-a-runner) The two cases above are clusters the sentinel *started* watching. A third never got that far: in a fleet, a cluster whose credentials cannot be resolved at startup — deleted but still in a discovery listing, or a stale kubeconfig context — is **skipped**, and the rest of the fleet starts normally. ```plaintext multi-cluster: cluster "torn-down" SKIPPED — cannot resolve credentials: … multi-cluster: watching 2 of 3 cluster(s); skipped "torn-down" — this sentinel reports nothing about the skipped clusters, so do not read their silence as healthy lookout_cluster_resolve_errors_total{cause="credentials",cluster="torn-down"} 1 ``` The other `cause` is `duplicate_name`: two clusters in one fleet answering to the same name. The name is the sentinel’s only handle on a cluster — this metric’s label, every per-runner series’ `cluster` label, the wire field, the `/readyz` entry, and the per-cluster store and dedup files — so a duplicate is ambiguous everywhere at once and **neither** cluster is watched (the counter moves by the number dropped, so a pair reads `2`). Give them distinct names with explicit `--clusters` pairs. A skipped cluster does not appear in `/readyz?verbose` at all — not as `[!]`, not as anything. It was never expected, so it cannot hold readiness down, and there is no runner to report a state. **The metric is the only signal**, so alert on it: it is written once at startup and never again, which makes any non-zero series a standing coverage gap rather than a transient. If *no* cluster resolves the process exits non-zero instead of supervising an empty fleet. Readiness matters most during a rollout: the Deployment uses `strategy: Recreate` with one replica, so the new pod must come up before anything is watching again, and `/readyz` is what tells you when that has happened rather than when the process merely started. ## Metrics [Section titled “Metrics”](#metrics) Every metric carries the `lookout_` prefix. A real scrape from a live validation drill: ```plaintext lookout_events_seen_total{namespace="default",reason="BackOff"} 4 lookout_events_injected_total{namespace="default",reason="BackOff"} 1 lookout_events_deduped_total{namespace="default",reason="BackOff"} 3 lookout_session_creates_total{outcome="ok"} 5 lookout_active_incidents 5 ``` The generated [Prometheus metrics reference](/k8s-lookout/reference/metrics/) covers all 40 metrics — pipeline counters, recovery, storms, watchboard, store, enrichment, distiller, and triage-status routing — with types, labels, and meanings derived from the live collectors. ### One of them is about what was found [Section titled “One of them is about what was found”](#one-of-them-is-about-what-was-found) Every metric above measures the *machine*: informer lag, queue depth, dispatch latency, store size. `lookout_findings_total{kind,severity}` is the exception — it measures what the sentinel found. ```plaintext lookout_findings_total{cluster="prod-us",kind="pod.crashloop",severity="critical"} 12 lookout_findings_total{cluster="prod-us",kind="objectstate.restart_burst",severity="warning"} 41 ``` It counts **once per distinct finding**, at the moment a fresh dedup window opens and before any routing decision — so an info-class signal that is only stored, a warning batched into the watchboard, and a critical that opens its own session all count the same. That is the difference from `events_injected_total`, which measures delivery: a downgraded finding is still a finding. The `cluster` label is always present, so a multi-cluster process produces one series per watched cluster. **Namespace is deliberately not a label** — `kind` is bounded and `severity` is three values, but namespace is unbounded, and that is where a findings metric turns into an outage of the monitoring stack it feeds. For per-namespace questions use [the occurrence store](/k8s-lookout/operations/store/) or the read path. `rate(lookout_findings_total{severity="critical"}[1h])` is the cluster-health trend line; a step change in it is usually the first graph worth looking at. ### Something to scrape [Section titled “Something to scrape”](#something-to-scrape) `deploy/17-service-watcher.yaml` publishes a ClusterIP Service, `lookout-watch-metrics`, on `:9090`. Prometheus-operator users can additionally apply the ServiceMonitor: ```sh kubectl apply -k "github.com/go-steer/k8s-lookout/deploy/prometheus-operator?ref=v0.26.0" ``` It ships outside the base bundle because `ServiceMonitor` is a CRD and `kubectl apply -k deploy/` must not fail on a cluster that does not have it. On Google Managed Prometheus the equivalent is a `PodMonitoring`; the port name and interval carry over. ### Something to look at [Section titled “Something to look at”](#something-to-look-at) `deploy/dashboards/leeway.json` is a Grafana dashboard over the placement subsystem — drift, compute-class ranks, and the SLIs that say whether either is being measured correctly. Import the JSON, or apply the ConfigMap wrapper for the Grafana sidecar: ```sh kubectl apply -k "github.com/go-steer/k8s-lookout/deploy/dashboards?ref=v0.26.0" ``` It is the only dashboard that ships with the sentinel, because it is the only part of the output that is a time series first and a finding second. Everything else is better read through the findings themselves. See [Tuning placement findings](/k8s-lookout/operations/placement/#the-dashboard) for what is on it and the namespace the sidecar has to be searching. Alongside the `lookout_*` series the endpoint carries the standard Go and process collectors — `process_resident_memory_bytes`, `go_memstats_heap_inuse_bytes`, `go_memstats_next_gc_bytes` and the rest. They are not in the [metrics reference](/k8s-lookout/reference/metrics/), which documents lookout’s own instruments, but they are how you check a sentinel against [Sizing a sentinel](/k8s-lookout/operations/sizing/). Two things to check if a scrape comes back empty: the NetworkPolicy in `deploy/16` admits **same-namespace** scrapers only — monitoring in its own namespace needs that `namespaceSelector` block uncommented — and the Service selects on both `app.kubernetes.io/name` and `app.kubernetes.io/component`, so a renamed Deployment needs both. ## What to alert on [Section titled “What to alert on”](#what-to-alert-on) Prefix `lookout_` omitted: | Metric | Why it pages | | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `inject_errors_total` | Inject or session-create attempts returning non-2xx or a transport error. Increase means the daemon is unreachable, the token is wrong, or `--owner` fails the proxy-identity check — the sentinel is seeing incidents and failing to deliver them. | | `session_creates_total{outcome!="ok"}` | The session-create half of the same failure surface. | | `store_write_drops_total` | Occurrence records **lost** by the store’s non-blocking write path (`buffer_full` or `write_error`). The store is telemetry, not a system of record — drops are loud, never blocking — but a sustained rate means your audit ledger and post-mortem history have holes. | | `store_pruned_rows_total{cause="size"}` | Size-based eviction: the store hit `--store-max-mb` and is discarding oldest history. TTL pruning (`cause="ttl"`) is normal; size pruning means the bound is too small for the signal volume. | | `enrichment_failures_total` / `enrichments_total{outcome="failed"}` | Enrichment stage failures (by stage: resolve, spec, delta, edges, radius, logs). Failures never block the inject — they surface as `enrichment_error` trailers — but a constant rate usually means an RBAC gap on the enrichment read paths. | | `recovery_drops_total{cause="unknown_session"}` | A resolved outcome had nowhere to go — the incident binding was lost, typically a restart without `--dedup-persist`. Fix the volume; every drop is a fix-verify loop that could not close. | | `info_dropped_total` | Info-class signals counted and discarded because no `--store` is set. Not a fault, but if you expected the store to have them, this is the tell. | | `watchboard_buffered` (gauge) | Stuck above `--watchboard-batch` across scrapes means flushes are failing — see `inject_errors_total`. | | `source_denied` | A permission the sentinel held at startup is denied now (`--access-recheck`). Any series at `1` means coverage you used to have is gone, so that source’s silence no longer means the cluster is healthy — `required="true"` also means the cluster’s runner has stopped. | | `otlp_export_last_success_timestamp_seconds` | Only with `--otel-exporter=otlp`. Alert on its **age**: a collector that is down, wrong, or refusing the batch makes every dashboard fed by the push path silently stale. The scrape endpoint is unaffected, which is the point of alerting from it. | | `otlp_points_dropped_total` | The size of what the staleness above cost. There is no retry queue — a dead collector costs samples, not memory — so a sustained rate means holes in the pushed series for as long as it lasts. | | `leeway_counter_mismatch_total` | A subject’s incrementally-maintained placement distribution disagreed with a rebuild from the pod cache. Threshold **zero**. The disagreement is repaired in place, so the exported numbers are right — but the increments that produced them were not. | | `leeway_preference_disagreement` | A node’s compute-class rank annotation and the sentinel’s own inference of the same rank disagree. Threshold **zero**, and the more serious of the two: the annotation wins, so this is not a wrong number on a graph, it is a reading that our understanding of the class’s rules is wrong. | | `findings_total{severity="critical"}` | The only entry here that is about the cluster rather than the sentinel. A rate step change means something broke; a rate that goes to zero on a cluster that normally has one means the sentinel stopped seeing, which the machine metrics above will not tell you. | `active_incidents`, `storms_active`, and `recovery_tracking` are the load gauges worth graphing rather than alerting on. The two `leeway_` rows above are the trustworthiness half of the placement subsystem’s instruments; the rest of that set, including the two gauges that say whether a quiet estate is quiet or suppressed, is in [Tuning placement findings](/k8s-lookout/operations/placement/#is-the-measurement-sound). ## The startup log is a checklist [Section titled “The startup log is a checklist”](#the-startup-log-is-a-checklist) Every armed stage announces itself, and every degradation is a named line — read it once after each deploy or flag change. From recorded drill runs: ```plaintext storm: topology graph ready (54 nodes, 68 edges) recovery: clearance observer backed by the object-state source's pod informer watchboard: enabled (batch=5, flush=1m0s, rotate after 200 …) enrichment: enabled (severities=critical … read path: live-graph (scoped-list fallback)) store: enabled (path=/data/lookout.db, ttl=720h …) graph history: enabled (snapshot every 1m0s + per-delta change log …) expiry: cert-manager CRD not found — Certificate renewal-state scanning disabled; TLS secrets and webhook CA bundles are still scanned ``` A missing “enabled/ready” line for a stage you configured, or any startup RBAC-probe error, is a misconfiguration — see [Troubleshooting](/k8s-lookout/operations/troubleshooting/). ## Pushing metrics over OTLP [Section titled “Pushing metrics over OTLP”](#pushing-metrics-over-otlp) `--otel-exporter=otlp` adds a **second reader** over the same meter provider, so every series above is both scraped and pushed. `OTEL_METRICS_EXPORTER` overrides the flag, and the resolved endpoint (`OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, then `OTEL_EXPORTER_OTLP_ENDPOINT`, then `http://localhost:4318`) is logged at startup along with the interval and the per-export deadline. **`/metrics` is unconditional.** No value of `--otel-exporter` can empty the scrape endpoint — which is what makes it possible to alert on a pipeline that does not itself depend on the collector being up. **There is no export queue, by design.** The reader holds one point per series and ships it on a timer; a batch that fails is gone rather than retained. So an unreachable collector costs *samples*, not memory, and it cannot slow the scoring pass down — a blocked recording path would turn a collector outage into a detection outage. Temporality is cumulative, so what a failed export loses is the sample and not the counter value: the next export that lands carries the full running total, and the graph has a hole rather than a reset. The cadence defaults to 60s and is set with the standard `OTEL_METRIC_EXPORT_INTERVAL` (milliseconds). The per-export deadline is derived from it — half, clamped to 5s–30s — and is always **strictly below** it. That matters if you shorten the interval: the reader collects, exports and waits in one goroutine, so a deadline at or above the interval would let one wedged export own the loop, and the SLIs that would say so would stop moving at the same instant. The exporter’s retry budget is bounded by the same deadline rather than by the SDK’s 60s default. `OTEL_METRIC_EXPORT_TIMEOUT` (milliseconds) overrides the derived deadline, but is **capped at three quarters of the interval** so it cannot reintroduce that hazard; the cap is logged when it applies. If your collector needs longer than that, raise the interval too. The push path reports on itself, on the scrape endpoint: | Metric | Meaning | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `lookout_otlp_exports_total{outcome}` | Export attempts, `ok` or `failed`. Both exist at zero from the first scrape. | | `lookout_otlp_points_exported_total` | Data points the collector accepted. | | `lookout_otlp_points_dropped_total` | Data points discarded because their export failed or ran out of time. | | `lookout_otlp_export_last_success_timestamp_seconds` | When the last batch landed; zero until the first one. | | `lookout_otlp_export_inflight` | `1` while an export is running. Stuck at `1` across scrapes means wedged against the deadline. | **Alert on `time() - lookout_otlp_export_last_success_timestamp_seconds`, not on the failure counter alone** — the counter also stays flat when the export path stops running at all, and those two look identical from the backend that is not receiving anything either way. Failures are additionally printed as `lookout: otel-export: …` at most once per interval; the metrics are the thing to page on, because they survive the outage that took the collector with it. `dev/tools/soak-otlp` runs the whole path against a collector that accepts every connection and answers none, and prints heap, RSS, drops and series count over the run. ## Traces [Section titled “Traces”](#traces) The sentinel exports OpenTelemetry spans with `--otel-exporter=console|otlp`; `none` (the default) makes no outbound tracing calls at all. Three things are worth knowing: * **`OTEL_TRACES_EXPORTER` overrides the flag** — the OTel-standard env var wins, so one shared Deployment can carry per-Pod exporter targets without forking the manifest. * **`otlp` is OTLP over HTTP** and reads the standard env vars: `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, then `OTEL_EXPORTER_OTLP_ENDPOINT`, then the spec default `http://localhost:4318`. The resolved target is logged at startup. Set `GOOGLE_CLOUD_PROJECT` when shipping to Cloud Trace — its OTLP ingress rejects batches with no `gcp.project_id` resource attribute. * **Export failures are loud.** An unreachable collector, TLS mismatch, or wrong port prints `lookout: otel-export: …` on stderr rather than silently dropping spans; `OTEL_LOG_LEVEL=debug` raises SDK diagnostics too. The W3C `traceparent` propagator is registered in every mode, including `none`, so outbound POSTs to the daemon carry trace context the moment an operator flips the exporter on. # Tuning placement findings > What the three confidence tiers mean, which flags move each one, how to keep the per-domain series under control, and the SLIs that say whether the numbers are worth reading at all. Two of the sentinel’s sources answer questions nothing else in a cluster does. `topology-drift` asks whether a workload’s objects are spread the way somebody meant them to be. `compute-class` asks whether the nodes a workload actually landed on are the ones its compute class preferred. Both emit under the `leeway.` prefix, both are on by default, and both are quiet on a cluster where nothing has moved. This page is about turning them up and down. For what each flag does line by line see the [`watch` reference](/k8s-lookout/reference/watch/); for what each metric is see the [metrics reference](/k8s-lookout/reference/metrics/); for running the subsystem as its own process see [The standalone `leeway` binary](/k8s-lookout/operations/leeway-standalone/). There is a [prebuilt Grafana dashboard](#the-dashboard) over everything on this page. ## The three tiers [Section titled “The three tiers”](#the-three-tiers) Every placement finding carries a tier, and the tier is not a severity dressed up — it is a statement about **where the expectation came from**. | Tier | Where the expectation came from | Severity | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | **A** | You declared it, in a `DoNotSchedule` topology spread constraint or a required pod anti-affinity, and Kubernetes is not honouring it. | `critical` | | **B** | You signalled it — a `ScheduleAnyway` constraint, a preferred anti-affinity — or the spread is far enough off an even split to stand out on its own. | `warning` | | **C** | Nobody declared anything. The workload has moved away from **its own** history. | `info`, or `warning` on a max-domain-share breach | Tier A is the interesting one. The scheduler enforces a `DoNotSchedule` constraint at admission, so a violation means the pods were placed correctly and *then* the world changed underneath them — a node was relabelled, a zone’s nodes went away, or the pods predate the constraint. No admission-time control can catch those, which is most of the reason this subsystem exists. **An expectation the sentinel assumed can never reach Tier A.** If you give it your cluster’s scheduler defaults with `--topology-cluster-defaults`, findings derived from them are capped at Tier B even when the default says `DoNotSchedule`. Raising a `critical` on a contract nobody wrote down is the failure mode that makes an operator stop trusting the whole source. ### Tier C is off the wire by default [Section titled “Tier C is off the wire by default”](#tier-c-is-off-the-wire-by-default) Tier C findings are exported as metrics but do not become signals unless you ask: ```plaintext --topology-tier-c-signals # leeway.topology_drift at Tier C --compute-class-tier-c-signals # leeway.rank_tier_unused ``` They are off because Tier C has no declared intent behind it. A workload that has always run three pods in one zone and now runs them in two has *changed*, and that is worth a graph; it is not obviously worth waking anybody. Turn them on once the metrics have convinced you the baselines are sane on your cluster — which usually takes a few days, because a baseline needs history before it means anything. ## Which flag moves which tier [Section titled “Which flag moves which tier”](#which-flag-moves-which-tier) Nothing here changes what is measured. Every flag below changes what is *reported*, and the metrics stay complete either way — which is why the honest order of operations is to watch the series first and tighten the signals afterwards. **Tier A and B — `topology-drift`** | Flag | Default | What moving it does | | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--topology-dwell` | `10m` | How long a breach must persist before it is a finding. Placement is rebuilt constantly by rollouts and evictions; the dwell is what keeps a rollout from being an incident. Raise it on a cluster that deploys continuously. | | `--topology-keys` | zone, region | The axes scored at all. Adding an axis multiplies subjects; a cluster that partitions on something else entirely (a rack label, a cell) names it here. | | `--topology-capacity-ratio` | `1.25` | How unequal a subject’s eligible zones must be, by allocatable CPU, before an even split stops being the expectation. Lower it on a fleet of deliberately heterogeneous pools. | | `--topology-cluster-defaults` | — | Your kube-scheduler `defaultConstraints`. Without them, a workload that declares nothing is scored at Tier C rather than against the rule the scheduler is actually applying. | **Tier C — baselines** | Flag | Default | What moving it does | | ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--topology-learn-baselines` | `true` | Off means a workload with no declared constraint is never scored against its own history. The Tier C tier disappears; A and B are unaffected. | | `--topology-baseline-band` | `4` | How many learned deviations wide the tolerance band is. **The false-positive knob.** Widen it first if Tier C is noisy; narrowing it below 3 will find ordinary rescheduling. | | `--topology-baseline-half-life` | `12h` | How fast a baseline absorbs a step change. Shorter follows a cluster that is legitimately rebalancing; longer keeps a memory of what normal was before somebody started moving things. | **`compute-class`** | Flag | Default | What moving it does | | ----------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--compute-class-dwell` | `10m` | As `--topology-dwell`, for rank verdicts. | | `--compute-class-window` | `1h` | How much history a rank share is a share of. The counters are cumulative, so the window is what stops a bad hour six weeks ago from being permanent. | | `--compute-class-last-rank-ceiling` | `0.9` | Fires when more than this share of a class’s pod-time ran at its **least**-preferred priority. | | `--compute-class-rank0-floor` | off | Fires when less than this share ran at its **most**-preferred priority. Off by default because a class whose top priority is a scarce machine type is *expected* not to reach it. | ## Cardinality [Section titled “Cardinality”](#cardinality) The per-domain breakdown — `lookout_leeway_domain_objects` and `lookout_leeway_domain_expected` — is one series per subject, per axis, per domain, per state. That is the one part of this subsystem that can hurt a Prometheus, so it is off for most subjects by default and the controls are deliberately several: | Flag | Default | Effect | | ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--topology-per-domain-min-drift` | `0.05` | Subjects drifting at least this much get a breakdown. This is the default admission rule: a subject that is placed correctly does not need per-domain series to prove it. | | `--topology-per-domain-series` | off | Every tracked subject gets one, ignoring the drift floor. Know your subject count before setting this. | | `--topology-per-domain-namespaces` | all | Narrow the breakdown to named namespaces. | | `--topology-per-domain-exclude-namespaces` | — | Applied **ahead** of the include list: a namespace in both is excluded. | | `--topology-per-domain-max-keys` | `4` | How many axes one subject may break down on. | | `--topology-per-domain-collapse-states` | `true` | Folds four scheduling states onto two labels, halving the count. | | `--topology-max-node-groups` | `200` | Past the bound, **no** node group is scored — not an arbitrary subset, because a partial answer to “is this pool concentrated” is worse than none. | Whenever one of these withholds something, `lookout_leeway_domain_series_withheld` says so, by reason. Read it before concluding a subject has no objects in a domain: an absent series and a zero are not the same statement, and this gauge is how you tell them apart. ## Is the measurement sound? [Section titled “Is the measurement sound?”](#is-the-measurement-sound) A dashboard that shows drift without showing whether drift is being measured correctly is exactly the failure this next set exists to prevent. Four series answer “can I believe the four above”: | Metric | Threshold | What a non-zero reading means | | ---------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lookout_leeway_counter_mismatch_total` | **zero** | A subject’s incrementally-maintained distribution disagreed with a rebuild from the pod cache. The disagreement is repaired in place, so the numbers you see are right — but the increments that produced them were not, and a rate here means a delta rule has a hole. | | `lookout_leeway_preference_disagreement` | **zero** | The node’s compute-class rank annotation and the sentinel’s own inference disagree. The annotation wins, so this is not a wrong number on a graph; it is a reading that our understanding of the class’s rules is wrong, which is worse. | | `lookout_leeway_transient_subjects` | context | Subject-axes whose judgement was suppressed or relaxed, by the state responsible. **Look here before believing a quiet estate.** A fleet-wide `domain-outage` reading means the silence is suppression, not health. | | `lookout_leeway_domains_unavailable` | context | Domains with no usable node, per axis. Reported as zero when nothing is out, so the series exists before the first outage rather than appearing during one. | Add `lookout_leeway_preference_rank_pending` to that list with an asterisk: it is normally non-zero and brief. GKE writes the rank annotation 33 to 44 seconds after a node registers, and inference has nothing to read until it does. A reading that **stays** up is a class whose rank never resolved. If you push metrics over OTLP, the export SLIs apply here as much as anywhere — see [Observability](/k8s-lookout/operations/observability/#pushing-metrics-over-otlp). A dead collector makes these dashboards stale without making them wrong, which is the specific way they mislead. ## What to alert on [Section titled “What to alert on”](#what-to-alert-on) Two threshold-zero alerts, `counter_mismatch_total` and `preference_disagreement`, both about the sentinel rather than the cluster. Beyond those, alert on findings, not on the gauges: a Tier A finding is already a `critical` and already routed. Graph `lookout_leeway_drift`, `lookout_leeway_subjects_tracked` and `lookout_leeway_alert_state`; page on neither. `lookout_leeway_alert_state` is worth one note. It carries only subjects with an **open episode** — 1 for pending, 2 for firing — so a series disappearing is a subject that recovered, not a subject that stopped being scraped. Rate-of-change on it reads as noise; use it to answer “what is open right now”. ## The dashboard [Section titled “The dashboard”](#the-dashboard) `deploy/dashboards/leeway.json` is a Grafana dashboard over the whole instrument set, built the way this page is ordered. Its first row is deliberately **not** drift — it is the four SLIs above, because a dashboard that shows drift without showing whether drift is being measured correctly is the failure they exist to prevent. Drift, compute-class ranks, and baselines-and-cost follow. The JSON is the artifact. Import it through Grafana’s UI, point Terraform or the Grafana API at it, drop it in a git-synced folder — it carries no provisioning assumptions and prompts for a Prometheus data source on import. For the sidecar, there is a ConfigMap wrapper: ```sh kubectl apply -k "github.com/go-steer/k8s-lookout/deploy/dashboards?ref=v0.26.0" ``` That lands `lookout-leeway-dashboard` in `agent-triage` with the `grafana_dashboard: "1"` label. **The sidecar only picks it up if it is searching that namespace**, and Grafana’s chart defaults `sidecar.dashboards.searchNamespace` to its own release namespace, so more often than not it is not. Wrap the overlay with a `namespace:` of your own rather than editing it in place; the comment at the top of `deploy/dashboards/kustomization.yaml` has the four lines. It is outside the base bundle for the same reason the ServiceMonitor is: `kubectl apply -k deploy/` must not assume a monitoring stack. Unlike the ServiceMonitor, there is no Helm equivalent — a chart can only `.Files.Get` inside its own directory, so shipping it through the chart would mean a second copy of an eight-hundred-line JSON kept in sync by convention, and this file is short enough to apply on its own that the trade is not worth making. Four choices in it are worth knowing about, because they are the ones you would otherwise have to rediscover: * **Mean achieved rank** divides rank-weighted pod-time by pod-time over **tier ranks only** — `rank!~"unknown|unsatisfiable|off-axis"`. A mean over a bucket whose rank is `unsatisfiable` is not a mean of anything, and leaving those in the denominator drags the number toward zero exactly when a class stops being satisfiable. * **Pod-time by rank** is a rate over a counter, not a pod count. Ninety seconds of rank-3 pods during a scale-up and three weeks parked on a spot fallback are the same picture to a gauge. * **Both episode tables are instant queries**, for the reason in the note above: the series only exists while an episode is open. * **The subject panels are `topk(20, …)`**, which keeps them readable on a large estate and means they are a worst-offenders view rather than a census. `lookout_leeway_subjects_tracked` is the census. # Scoping a sentinel > Narrowing what one sentinel watches — --exclude-namespace as a real watch scope, splitting by source, and which sources can be separated without paying for a second cache. The canonical deployment is one sentinel per cluster watching everything, and it is the right default: one informer cache, one topology graph, one credential boundary. This page is for the cases where it is not — where a cluster has namespaces you have no business watching, or where you want the drift counters without also running the event watcher. There are two axes to cut along, and they compose: **which namespaces** a sentinel watches, and **which sources** it runs. ## Namespaces [Section titled “Namespaces”](#namespaces) Two flags look alike and are not. | Flag | What it does | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace=a,b` | Allow-list applied to **output**. Every namespace is still listed, watched, decoded and cached; signals from namespaces outside the list are dropped before they are emitted. | | `--exclude-namespace=x,y` | Deny-list applied to **the watch**. The namespaced informers carry a `metadata.namespace!=` field selector, so `x` and `y` are never listed and never enter the cache. | ### `--namespace` is not a security boundary [Section titled “--namespace is not a security boundary”](#--namespace-is-not-a-security-boundary) Worth saying plainly, because the flag name invites the opposite reading: a sentinel run with `--namespace=payments` holds every other namespace’s pods in memory. Names, labels, images, owner chains and node placement for the whole cluster are resident in its cache. (What is *not* resident is resolved secret values — those are stripped on the way into the cache for every namespace, watched or not.) If the requirement is “this process must not be able to see namespace `x`”, `--namespace` does not meet it and never did. `--exclude-namespace` does, and RBAC does it better still. ### `--exclude-namespace` shrinks the process [Section titled “--exclude-namespace shrinks the process”](#--exclude-namespace-shrinks-the-process) ```plaintext lookout watch --exclude-namespace=kube-system,gmp-system ``` The sentinel logs the selector it derived at startup: ```plaintext watch: --exclude-namespace is scoping the watch — the namespaced informers list and watch with field selector "metadata.namespace!=gmp-system,metadata.namespace!=kube-system", so excluded namespaces never enter the cache; nodes are cluster-scoped and unaffected ``` On a busy cluster the two system namespaces above are frequently a third to a half of all pods, and they are pods nobody is paging on. Excluding them cuts cache size, decode work and watch traffic by roughly their share. Three things to know: * **Nodes are unaffected.** They are cluster-scoped, so a namespace deny list has nothing to remove from them — and the API server *rejects* `metadata.namespace` on a cluster-scoped LIST rather than ignoring it, so the node informer runs on its own unfiltered factory. This costs no extra watch: the node informer is still one stream shared by every reader. * **Correlation only sees what is watched.** Storm correlation and the topology graph are built from the same informers, so a node failure’s blast radius will not include pods in an excluded namespace. That is usually what you want — you excluded them — but it means an excluded namespace cannot appear as collateral damage either. * **An allow-list is not available.** Field selectors have no `OR`, so `metadata.namespace=a` can name exactly one namespace and watching *M* of them needs *M* informer factories — 12 namespaced streams each. That is tracked as [issue #407](https://github.com/go-steer/k8s-lookout/issues/407) and is deliberately not built yet; an exclusion of any length is one selector on one stream, which is why this direction is cheap and the other is not. ### Or use RBAC [Section titled “Or use RBAC”](#or-use-rbac) The strongest version of “do not watch namespace `x`” is not to grant it. A sentinel whose ServiceAccount cannot list pods cluster-wide fails loudly at startup naming the source and the permission (see [Troubleshooting](/k8s-lookout/operations/troubleshooting/)) rather than watching an empty cache. `--exclude-namespace` is the right tool when you hold a cluster-wide grant and want to spend less; RBAC is the right tool when the grant itself is the problem. ## Sources [Section titled “Sources”](#sources) `--sources` takes a comma-separated list, and nothing requires one sentinel to run all of them: ```plaintext # A sentinel that only tracks topology drift. lookout watch --sources=topology-drift # A sentinel that only watches Events. lookout watch --sources=k8s-events ``` An explicit list also changes failure semantics in a way that is useful here: under `--sources=auto` a source whose grants are missing is skipped with a log line, but a **named** source’s missing required grant is fatal. If you deployed a sentinel *for* drift, you want it to refuse to start rather than to run as an expensive no-op. ### Which splits are free, and which cost a cache [Section titled “Which splits are free, and which cost a cache”](#which-splits-are-free-and-which-cost-a-cache) Sources do not each own their informers — they share one factory, so two sources reading Pods cost one pod cache between them. Splitting them into separate deployments **un**-shares that. The question for any proposed split is therefore only: do the two halves read the same objects? | Source | Objects it watches | | -------------------------------------- | ---------------------------------------------- | | `k8s-events` | Events | | `ingress` | Events | | `capacity` | Events, Pods, Nodes | | `object-state` | Pods, Nodes, Deployments, EndpointSlices, PDBs | | `rollout` | Deployments, ReplicaSets, StatefulSets, Pods | | `degradation` | Pods, EndpointSlices | | `topology-drift` | Pods, Nodes, ReplicaSets | | `workload` | Jobs, CronJobs | | `autoscaling` | HorizontalPodAutoscalers | | `gateway` | Gateway API objects (its own factory) | | `expiry`, `saturation` | none — polled | | `quota`, `notifications`, `token-burn` | none — provider APIs | So: * **Free to separate:** `workload`, `autoscaling`, `gateway`, `expiry`, `saturation`, `quota`, `notifications`, `token-burn`. None of them shares an informer with anything else, so moving one into its own deployment costs only the process. * **Expensive to separate:** anything in the Pods/Nodes/Events core — `object-state`, `rollout`, `degradation`, `topology-drift`, `capacity`, `k8s-events`, `ingress`. Pull `topology-drift` into its own sentinel and you now run two pod caches where you ran one, and the pod cache is the single largest thing the process holds. Do it because you want a different blast radius or a different credential, not to save memory — it will not. `quota` and `notifications` are already deployed this way in a fleet: they describe a *project*, not a cluster, so a multi-cluster sentinel runs them once per project rather than once per cluster, and drops them from the per-cluster source list automatically. ### What a split costs you [Section titled “What a split costs you”](#what-a-split-costs-you) Signals are correlated inside one process. Split sources across processes and you lose: * **Cross-source follow-ups.** The dispatcher notices when one source’s signal follows another’s on the same object and counts it on `lookout_cross_source_followups_total`. Two processes never see each other’s signals. * **Storm blast radius.** `--storm` groups signals by common ancestor in the topology graph. A node failure that produces `object-state` and `rollout` signals in two different processes is two unrelated pages, not one incident. * **Recovery clearance across sources.** The §7.4 tracker clears an incident when an observer says the condition is gone; observers come from the sources running in the same process. Deduplication and the occurrence store are per-process too, so each half needs its own `--store` path and its own sink configuration. None of that argues against splitting — it argues for splitting along a line where the two halves would not have correlated anyway. A drift-only or a quota-only sentinel is a clean cut. Splitting `object-state` from `rollout` is not. # Sizing a sentinel > How much memory one sentinel needs, derived from the measured per-object cache cost — the table by cluster size, why the number varies by environment, and the levers when the limit is not enough. Almost all of a sentinel’s memory is one thing: the shared informer cache. It holds every pod and node in the cluster, and every source reads from it rather than listing the API server again. That is what makes eleven sources cost roughly what one costs — and it is also what makes the memory limit a function of cluster size rather than of how many features you turned on. So the sizing question is not “how much does lookout need”, it is “how many pods do you have”. ## The measured constants [Section titled “The measured constants”](#the-measured-constants) These are **retained heap** — what a cached object costs after the ingest transform has dropped the fields nothing reads — measured by holding deep copies and reading `HeapAlloc`, not inferred from serialised size: | | GKE 1.36 | kind | | ---- | ------------ | ------------ | | Pod | **18,630 B** | **10,007 B** | | Node | **5,308 B** | **3,026 B** | Two things about that table matter more than the numbers in it. **Heap is not wire.** A trimmed GKE pod is 10,788 B on the wire and 18,630 B in the cache — 1.7×. Sizing from the serialised size, which is the number that is easy to get, is low by that factor. **They vary \~1.9× by environment.** A managed GKE cluster annotates heavily and runs more sidecars than a kind node does. The table below uses the GKE column, because a default has to hold at the conservative end. If your objects are smaller, you have headroom you can measure rather than assume: `internal/watch/objectsize_test.go` re-runs the measurement against any cluster you point it at. ## The table [Section titled “The table”](#the-table) Live set ≈ `pods × 18.2 KiB + nodes × 5.2 KiB + 100 MiB`, where the constant covers the other eleven informer streams, the occurrence store, the topology graph and the Go runtime. The suggested limit adds GC headroom (\~1.3×) and then sets `GOMEMLIMIT` to \~80% of it. | Pods | Nodes | Live set | `limits.memory` | `GOMEMLIMIT` | | ---------- | --------- | ------------- | ------------------------- | ------------ | | 1,000 | 100 | \~120 MiB | `256Mi` | `200MiB` | | 5,000 | 500 | \~190 MiB | `384Mi` | `300MiB` | | **15,000** | **1,500** | **\~375 MiB** | **`768Mi` (the default)** | **`600MiB`** | | 50,000 | 5,000 | \~1.0 GiB | `2Gi` | `1600MiB` | | 150,000 | 5,000 | \~2.8 GiB | `5Gi` | `4000MiB` | The shipped default is the 15,000-pod row, because that is the top of the range lookout calls typical. It is **derived from the measured per-object cost, not confirmed by a scale run at that size** — the padded kwok harness exists for that and the run is tracked separately. Treat the rows above 15,000 pods as extrapolation from a measured slope, which is what they are. CPU is not in the table because it does not scale with cluster size the way memory does — it scales with *churn*. The shipped `200m` limit covers a cluster with ordinary rollout activity; a cluster doing sustained mass rescheduling needs more. `rate(lookout_events_seen_total[5m])` is the churn measurement, and `rate(process_cpu_seconds_total[5m])` against the limit is whether it is costing you. ## The levers, when the limit is not enough [Section titled “The levers, when the limit is not enough”](#the-levers-when-the-limit-is-not-enough) **Set `GOMEMLIMIT` first, and always.** It is a soft ceiling on the Go heap: past it the collector runs harder rather than letting the heap grow. The failure it prevents is the bad one — without it the kernel OOM-kills the process, which loses the store’s in-flight writes and every established watch, and the sentinel comes back with a cold cache during whatever incident made it busy. With it you get a slow sentinel and a `lookout_watch_event_lag_seconds` you can alert on. The shipped manifests set it; keep it at \~80% of the limit when you change either. **Watch fewer namespaces.** `--exclude-namespace` is a real watch scope, not a display filter — the excluded namespaces never enter the cache, so it cuts memory directly and proportionally. A sentinel that skips a handful of noisy CI namespaces on a 20k-pod cluster can be smaller than the table says. See [Scoping a sentinel](/k8s-lookout/operations/scoping/). **Split by source.** Some sources can run in a second process without paying for a second pod cache; most cannot, and splitting those buys you two copies of the expensive thing. [Scoping a sentinel](/k8s-lookout/operations/scoping/) says which is which. **Raise the request too, near the top of the range.** The shipped `requests.memory` is `128Mi`, which suits the small end. A Burstable pod using far more than its request is evicted early under node memory pressure, so on a cluster near or above the 15,000-pod row, set the request to about the live-set column — otherwise the sentinel is the first thing the kubelet reclaims on a node that is already in trouble. ## Checking it against reality [Section titled “Checking it against reality”](#checking-it-against-reality) `/metrics` carries the standard Go and process collectors, so the table is checkable against the process it describes rather than only against cAdvisor: | | | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `process_resident_memory_bytes` | What the kubelet is comparing to the limit. This is the number the table’s “live set” column predicts. | | `go_memstats_heap_inuse_bytes` | How much of that is the Go heap — which is where the cache lives. | | `go_memstats_next_gc_bytes` | The GC’s next target. Sitting at `GOMEMLIMIT` means the soft ceiling is being enforced, which is the warning that you are one cluster-growth away from needing the next row. | If the heap is much smaller than the resident set, the cache is not what is using the memory and this page is the wrong place to look — start at [Observing lookout](/k8s-lookout/operations/observability/). Multiply your pod and node counts by the constants at the top to get the prediction. A large gap in either direction is worth reporting: the constants are a population parameter, and the only way they stay honest is people measuring them somewhere new. # The occurrence store > What --store records, its TTL and size bounds, copying it off a distroless pod for post-mortems, --at queries, and epochs across restarts. `--store=/var/lib/lookout/lookout.db` gives the sentinel a local, embedded SQLite store (pure Go, no cgo) — put it on the same volume as `--dedup-persist`. Startup confirms it, along with the history feed when storm correlation provides the graph: ```plaintext store: enabled (path=/data/lookout.db, ttl=720h …) graph history: enabled (snapshot every 1m0s + per-delta change log …) graph history: baseline snapshot stored (generation 3, 51 nodes, 62 edges) — --at queries answerable from here on ``` (Startup lines from a live validation drill.) ## What’s in it [Section titled “What’s in it”](#whats-in-it) * **Occurrences** — every post-dedup signal, including info-severity ones that never inject anywhere, each with its routing outcome (`injected | suppressed | storm | storm-member | watchboard | info-stored | resolved`) and session id. This is the audit ledger the drills read back, and the input to the scheduled distiller pass (`--distill-interval`) that turns recurring occurrences into durable facts. * **Graph history** — compressed topology snapshots every `--graph-snapshot-interval` (default 5m) plus the per-delta change log. Written only when storm correlation runs (that is the graph feed). This is what serves `--at` point-in-time queries and `triage changes`’ full delta log. * **Triage-status records** — the diagnosis records written by [`lookout triage status`](/k8s-lookout/reference/triage-status/) and flipped to `resolved` automatically by recovery injects. ## Bounds — telemetry, not a system of record [Section titled “Bounds — telemetry, not a system of record”](#bounds--telemetry-not-a-system-of-record) * `--store-ttl` (default `720h`, 30 days): the prune loop deletes older rows. * `--store-max-mb` (default `512`): oldest occurrences are pruned first when exceeded — loudly (`store_pruned_rows_total{cause="size"}`). * Writes are non-blocking: a full writer buffer or failed batch insert loses records rather than stalling the pipeline, counted in `store_write_drops_total` by cause. Alert on that counter — see [Observing `lookout`](/k8s-lookout/operations/observability/). ## Copying the store off a pod [Section titled “Copying the store off a pod”](#copying-the-store-off-a-pod) The image is distroless — **`kubectl cp` does not work** (no tar in the container). The store must sit on a volume you can reach another way: * **hostPath + node access.** On GKE: `gcloud compute scp :/var/lib/lookout/lookout.db* ./store/ --zone=` (or `gcloud compute ssh -- sudo cat …`). On kind: `docker cp :/var/lib/lookout/… .` * **A PVC** you can mount from a debug pod. Copy all of `lookout.db`, `lookout.db-wal`, and `lookout.db-shm` — the WAL sidecar files carry recent writes. Copying while the sentinel is live is fine: WAL mode absorbs the concurrent reader (a validation drill copied and queried a live store while the sentinel kept writing). ## `--at`: answering questions about the past [Section titled “--at: answering questions about the past”](#--at-answering-questions-about-the-past) Graph-backed commands (`triage radius`, `triage changes`) accept `--at= --store=` and answer from history — no cluster access on the query path. From a live drill, 28m34s after a bad-deploy onset, against the copied store: ```console $ lookout triage radius webapp-55866d5cff-cwgp4 -n drill-a --at=2026-07-26T10:54:55Z --store=lookout.db kind=radius.neighbor … kind_of_object=ReplicaSet name=webapp-55866d5cff direction=upstream relation=Owns hop=1 kind=radius.neighbor … name=webapp-77f8d7558c-7gvwx direction=lateral relation=shared-node hop=2 shared=Node/kl-m3-worker … scanned=69 findings=13 elapsed=6ms source=history at=2026-07-26T10:54:55Z $ lookout triage radius webapp-55866d5cff-cwgp4 -n drill-a # same question, LIVE lookout triage radius: workload Pod/drill-a/webapp-55866d5cff-cwgp4 not found in the topology ``` The at-onset answer contains the broken-revision pod and ReplicaSet the live cluster has already forgotten. The summary line always says which world answered: `source=live`, `source=history at=`, or `source=live-approximation` (the honest degraded mode without a store). `triage changes --at` reads the same delta log — its last entry before onset named the bad rollout in the drill. ## Epochs: what happens across sentinel restarts [Section titled “Epochs: what happens across sentinel restarts”](#epochs-what-happens-across-sentinel-restarts) Every sentinel process writes snapshots and change rows under a fresh **epoch** id, because a process’s graph re-interns node ids — replay is only meaningful within one process’s rows. The semantics for `--at`: * **Inside an epoch’s coverage** → that epoch’s state, snapshot + replayed deltas, exactly as observed. * **In the gap between epochs** (sentinel down, or the new process’s pre-baseline window) → the prior epoch’s last known state. Nothing was observing the cluster in the gap, so the last observed state is everything the store honestly knows. * **Before the first snapshot of the first epoch** → no history; the query says so rather than approximating. Restarts are therefore routine: a store spanning upgrades and evictions keeps answering, and each instant resolves within the process that actually observed it. ## The store as a scan input [Section titled “The store as a scan input”](#the-store-as-a-scan-input) The store also upgrades read-path scans: [`lookout health --store=…`](/k8s-lookout/reference/health/) and `bundle --store=…` merge open triage-status records into findings — a scan run mid-incident carries `triage_status=`, `triage_root_cause=`, the session pointer, and the agent’s severity override instead of re-reporting a fresh unknown — captured end-to-end in a live drill. # Troubleshooting > RBAC probe failures, source-by-source requirements, common startup errors verbatim with fixes, and what the unavailable markers mean. The sentinel’s failure philosophy is that nothing degrades silently: misconfiguration refuses to start with a named cause, missing capability announces itself, and the one failure mode this design exists to prevent is the **silent empty watch** — an informer without list/watch permission would log a warning once and then report nothing forever, which reads as “cluster healthy”. ## The `--sources=auto` startup summary [Section titled “The --sources=auto startup summary”](#the---sourcesauto-startup-summary) With the default `--sources=auto`, startup probes each portable source’s declared needs and prints a summary block — one line per candidate, enabled lines included, so what auto decided is read from the log, never inferred from silence. A worked example from a cluster with the shipped RBAC but no metrics-server: ```plaintext sources: auto — probing the portable set (RBAC per source; metrics.k8s.io for saturation); misses are skipped loudly — pin --sources explicitly to make a miss fatal (§11) source k8s-events: enabled (always on — a sentinel that cannot watch events is misdeployed) source object-state: enabled source rollout: enabled source saturation: disabled (metrics.k8s.io unavailable — install metrics-server) source degradation: enabled source expiry: enabled source capacity: enabled sources: auto resolved → k8s-events,object-state,rollout,degradation,expiry,capacity (quota and token-burn stay explicit-only: project tier and the core-agent cost stack) storm: auto — on (pods/nodes/replicasets graph grants verified; independent of object-state — the graph feed runs its own informers, shared with the sources' when both are on) ``` Line anatomy: the header states the rules; each `source :` line is enabled or `disabled (missing — , or name it in --sources to make this fatal)`; the `resolved →` footer is the effective source list the rest of startup uses; and the final `storm: auto` line is `--storm=auto`’s resolution the same way. Two things are fatal even under auto: `k8s-events` failing its probe (a sentinel that cannot watch events is misdeployed — fix the deployment), and a probe that cannot be *evaluated* at all (see below). ## RBAC probe failures at startup [Section titled “RBAC probe failures at startup”](#rbac-probe-failures-at-startup) With an **explicit** `--sources` list (or `--storm=on`), every named source’s declared RBAC needs are verified against the sentinel’s actual credentials (via SelfSubjectAccessReview — the probe itself needs no RBAC beyond authenticating) and a miss is fatal, naming exactly what to fix — explicit lists never downgrade to a skip: ```plaintext source "object-state" requires permission to "list nodes cluster-wide" (scope: Cluster) and this ServiceAccount does not have it; grant it or disable the source — refusing to run a silently empty watch ``` The requirement is rendered the way you would write it in a (Cluster)Role rule: ` [.] [] `. The fix is one of exactly the two the message offers — apply the missing grant (the shipped `deploy/12`–`15` manifests carry everything every source needs), or drop the source from `--sources`. A probe that cannot be *evaluated* is also fatal (“could not verify” must not degrade into “assumed fine”): ```plaintext source "…": capability probe for "…" failed: … ``` That means the API server rejected or could not answer the access review — a cluster/credentials problem, not a Role problem. ## A grant revoked *after* startup [Section titled “A grant revoked after startup”](#a-grant-revoked-after-startup) The startup probe is a point-in-time answer. If a grant goes away while the sentinel is running — someone narrows the ClusterRole, a `resourceNames`-pinned rule stops matching, a fleet cluster’s RBAC diverges — the informer just retries the refused LIST/WATCH on client-go’s backoff forever. Its sync barrier latched `true` when it first armed and never un-latches, so the source simply goes quiet, and quiet reads downstream as “cluster healthy”. That is the §11 failure mode moved in time rather than eliminated. So every source’s declared access is re-reviewed on an interval (`--access-recheck`, default `2m`; `0` disables it). A denial has to repeat across two consecutive sweeps before it counts, so IAM propagation does not read as a revocation. When it does count: ```plaintext access recheck: source "k8s-events" lost permission to "watch events cluster-wide" (scope: Cluster) while running: this ServiceAccount does not have it; grant it or disable the source ``` * **`lookout_source_denied{source,resource,required}`** goes to 1, and back to 0 if the grant returns — the series answers “is coverage missing right now”, not “was it ever”. This is the one to alert on. * A **`kind=sentinel.access_revoked`** signal is injected: `critical` when the permission was required, `warning` when it was one of the optional dimensions. A log line reaches whoever is tailing logs; the signal reaches the session an operator is already reading. * Losing a **required** permission stops that cluster’s runner down the same terminal path a startup refusal takes — it is marked degraded on [`/readyz?verbose`](/k8s-lookout/operations/observability/#readyzverbose) and `lookout_runner_terminal{reason="access_denied"}` goes to 1. In the single-cluster default that ends the process, so the kubelet restarts it into the loud startup refusal above. * Losing an **optional** one (saturation’s `nodes/proxy`) degrades that dimension and the source keeps running, exactly as at startup. A sweep the API server cannot answer is logged as `access recheck: could not verify: …` and otherwise ignored — “could not verify” is not “denied”, the same rule the startup probe follows. The one case this does not catch is an authorizer that answers the access review “allowed” and then denies the real call (a webhook authorizer, GKE Autopilot’s Warden). A re-review there returns allowed, so there is nothing to report; the informer’s own retry is the only handling that case has. ## What each source needs [Section titled “What each source needs”](#what-each-source-needs) | Source / feature | Requires | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `k8s-events` (default) | `events` get/list/watch; `pods` list/watch for the recovery clearance observer (without it the sentinel runs but recovery tracking is disabled — logged loudly) and `pods` get for inject enrichment. | | `object-state` | `pods`, `nodes`, `deployments.apps`, `endpointslices.discovery.k8s.io`, `poddisruptionbudgets.policy` list/watch. Cluster scope — a namespaced Role cannot satisfy the nodes watch. | | `rollout` | `statefulsets.apps` list/watch (its pods/deployments/replicasets informers ride the shared set). | | `saturation` | `pods.metrics.k8s.io` get/list — a metrics API must exist (on kind, install metrics-server with `--kubelet-insecure-tls`; the probe fails loudly without it, by design). `nodes/proxy` get is OPTIONAL: denied (RBAC, or GKE Autopilot’s platform policy) the source still runs with the PVC dimension disabled, reported at startup and again if the kubelet endpoint fails at runtime. | | `degradation` | `endpointslices.discovery.k8s.io` list/watch. | | `expiry` | `secrets`, `serviceaccounts` list (the secrets `list` is the sentinel’s only read of secret values — scope it with `--expiry-namespaces`, which the probe then verifies exactly); `validatingwebhookconfigurations`/`mutatingwebhookconfigurations` list; cert-manager `certificates` list (discovery-gated — skipped with one loud log line when the CRD is absent). | | `capacity` | Rides the events + pods grants, plus the `kube-system` Role (`deploy/14`/`15`): `get` on the `cluster-autoscaler-status` ConfigMap. The provider scale-decision sub-source additionally needs the `-gke` image with cloud credentials. | | `quota` | The `-gke` image (or a `-tags gke/allproviders` build) plus read-only project credentials (`compute.regions.get`, `monitoring.timeSeries.list`, `logging.logEntries.list`, `cloudquotas.quotaInfos.list`). One instance per GCP project. | | `token-burn` | No Kubernetes RBAC — it polls the core-agent cost stack at `--daemon-url` (or `--token-endpoint`). | | `--storm` | `pods`, `nodes`, `replicasets.apps` list/watch for the topology-graph informers. `--storm=auto` (the default) resolves on/off against these grants with a loud line either way; `--storm=on` makes a miss fatal, like an explicitly named source. Independent of `object-state` — the graph feed runs its own informers. | | Enrichment (`--enrich`) | `pods/log` get, `get` on the workload kinds, and `list` on the incident namespace’s workload/service/configmap/ingress/RBAC kinds for the scoped-list fallback. All in the shipped ClusterRole; a gap here is not fatal — a denied (or `--enrich-lists`-deselected) list is dropped with a `skipped=` note on the bundle head, and any section that fails becomes an `enrichment_error` trailer with `enrichments_total{outcome="partial"}`. Withhold `secrets: list` to keep the watcher SA out of Secret values entirely; the bundle degrades to a documented partial. See [Narrowing the role](/k8s-lookout/getting-started/deploy/#narrowing-the-role--partial-bundles-not-errors). | ## Common startup errors, verbatim [Section titled “Common startup errors, verbatim”](#common-startup-errors-verbatim) | Error | Fix | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--daemon-url is required (unless --dry-run)` | Point it at the core-agent daemon (`http://…:7777`, no trailing slash — `--daemon-url must not end with '/'` is its own error). | | `--token-env is required (unless --dry-run)` | Name the env var holding the bearer token, and make sure the Deployment sources it from the token Secret. | | `--owner is required in per-incident mode (must match a proxy identity in the daemon's users.json)` | Set `--owner`; if session creates then fail with 4xx (`inject_errors_total`), the identity is missing on the daemon side. | | `--target-session is required in shared mode` | `--mode=shared` posts everything to one existing session; name it. | | `--sources: unknown source "…" (known: k8s-events, object-state, rollout, saturation, degradation, expiry, capacity, quota, token-burn; or auto)` | Typo in the source list. | | `--storm must be auto, on, or off (got "…"; true/false are aliases for on/off, and bare --storm is no longer valid — write --storm=on)` | The bool-era `--storm` syntax; the flag is a three-mode string now. | | `source "quota" requires a cloud provider with the quota capability …; build with -tags gke/allproviders and run with cloud credentials, or drop "quota" from --sources` | You enabled the quota source in the default (GCP-free) image. Pin the `-gke` flavor — the refusal is the conformance boundary working as designed. | | `--saturation-window must be > --saturation-interval (the regression needs a window of samples)` | Flags of a disabled source are still validated — a nonsensical value is a config error in every mode. The same pattern covers every numeric flag (`--storm-min must be >= 2 (a storm of one is an incident)`, …). | ## Loud degradations that are *not* errors [Section titled “Loud degradations that are not errors”](#loud-degradations-that-are-not-errors) These startup lines report reduced capability and keep running — expected on the clusters they describe (all captured verbatim from recorded drill runs): ```plaintext capacity: provider scale-decision sub-source (§10.1 source 3) disabled: unavailable reason="no cloud provider configured" — Events + status-ConfigMap sub-sources still fire on scaleup failures, without the structured why capacity: ConfigMap kube-system/cluster-autoscaler-status not found — no cluster autoscaler on this cluster? status sub-source idle until it appears expiry: cert-manager CRD not found — Certificate renewal-state scanning disabled; TLS secrets and webhook CA bundles are still scanned ``` ## The `unavailable` markers [Section titled “The unavailable markers”](#the-unavailable-markers) Absent capability on the read-path is explicit output, never an error and never silence. Three shapes to recognize: * **Provider-gated commands** without a cloud provider emit one finding and exit 0, with the reason repeated on the summary line: ```console kind=cloud.unavailable severity=info reason=CapabilityUnavailable message="cloud quota needs the provider quota capability: no cloud provider configured" capability=quota provider=none scanned=0 findings=1 elapsed=0s unavailable="no cloud provider configured" ``` * **`health` categories** answer `status=unavailable` (e.g. `category=control-plane … message="requires cloud provider metrics; no cloud provider configured"`) rather than pretending healthy. * **`perf probe` packs** whose backing metrics are not enabled on the cluster degrade to an explicit `pack_unavailable` finding. Read them as “cannot answer”, not “healthy” and not “broken”: scripts and agents should branch on the marker, and the exit code stays 0 because the tool did exactly what it could and said so. ## Assorted [Section titled “Assorted”](#assorted) * **`kubectl cp` fails against the sentinel pod** — the image is distroless (no tar). Copying the store off a pod is covered in [The occurrence store](/k8s-lookout/operations/store/). * **Injects failing at runtime** — watch `inject_errors_total` by `http_code`: transport errors mean the `--daemon-url` is unreachable; 401/403 mean the token or the `--owner` proxy identity. * **A crashloop keeps “not re-paging”** after a triage-status downgrade — expected: kubelet’s steady BackOff cadence keeps the incident inside the dedup window, so the override’s visible effect is the per-signal `triage-status: downgraded …` log and the store severity until the loop actually pauses. # The watchboard > How warning-class signals batch into rolling digests, the size-based rotation lifecycle, and how lineage and incident bindings survive rotation. Leading indicators must not each open a page-priority session. In per-incident mode, severity routing sends each signal class its own way: `critical` opens a per-incident session with enrichment, `warning` batches into the shared **watchboard** session as a rolling digest, and `info` is stored only (with `--store`; counted and dropped without). In `--mode=shared` the watchboard is disabled — everything routes to `--target-session`. ## Digest cadence [Section titled “Digest cadence”](#digest-cadence) Warnings buffer until either `--watchboard-batch` of them accumulate (default 5) or the oldest buffered warning reaches `--watchboard-flush` in age (default 60s) — whichever comes first. The board session is created lazily on the first flush. Captured live in a validation drill: ```plaintext 00:58:45 watchboard: buffered objectstate.endpoints_empty weblab/web (severity=warning, buffered=1/5) 00:59:47 watchboard: digest 1 entry(ies) → sid=stub-sess-0009 (generation=1, injects=1/200, mode=per-incident) ``` And the digest inject itself — one schema-stable record per flush, each entry carrying its own fingerprint and object coordinates: ```json {"kind":"watchboard.digest","cluster":"kl-m2","board_generation":1,"sequence":1, "window_start":"2026-07-25T00:58:45.68219189Z","window_end":"2026-07-25T00:59:47.098792925Z", "entries":[{"kind":"objectstate.endpoints_empty","fingerprint":"sha256:638f0f4d…", "reason":"endpoints_empty","namespace":"weblab","kind_of_object":"Service", "name":"web","uid":"052e28a1-…","count":1, …}]} ``` ## Rotation lifecycle [Section titled “Rotation lifecycle”](#rotation-lifecycle) A shared session would otherwise grow without bound, so the board rotates **by size, not by calendar**: after `--watchboard-rotate` digest injects (default 200), the next flush opens a successor session. The cap bounds the agent’s cost of consuming the board no matter how noisy the cluster is, while a quiet cluster keeps one session for months. The mechanics: 1. `POST /sessions` creates the successor (same `--owner` asserted-caller as every sentinel session). 2. The final inject into the old session is the lineage record — `kind=watchboard.rotated` with `successor_session_id`, `injects_count`, `rotated_at`, `cluster`, and `board_generation` — so anyone reading the old session can follow the pointer forward. 3. The pending digest flushes into the successor; counters restart at `board_generation+1`, `sequence=1`. Failure posture: if creating the successor fails, rotation is deferred — the digest flushes into the over-threshold session and rotation retries on the next flush. Warnings are never dropped to enforce a size cap. ## Lineage and finding the board [Section titled “Lineage and finding the board”](#lineage-and-finding-the-board) The daemon’s session API has no name parameter, so watchboard sessions identify themselves in-band: every inject into one is `kind=watchboard.*`, and each digest carries `board_generation` (1-based count of board sessions this sentinel has opened) and `sequence` (digest ordinal within the session). `watchboard.rotated` links predecessor → successor, so the chain is walkable from either end. ## What rotation does — and does not — touch [Section titled “What rotation does — and does not — touch”](#what-rotation-does--and-does-not--touch) * **Incident bindings stay put.** Each flushed warning’s dedup entry is bound to the session its digest landed in. After rotation, followups and `kind=resolved` / `resolved.reverted` outcomes for an incident bound to the old board keep routing there; only new warnings flow to the successor. Old boards drain naturally and never orphan an open fix-verify loop. Bindings persist through `--dedup-persist` like all bindings. * **Storms bypass the board.** A storm can be warning-class (severity is the max of its members), but it always opens its own session — an aggregate incident an agent works is not a digest entry. * **Triage-status overrides feed it.** An incident an agent downgraded via [`triage status`](/k8s-lookout/reference/triage-status/) `--severity-override` routes its next dedup cycle to the watchboard instead of re-paging — a live drill captured the digest entry carrying the downgraded incident’s exact fingerprint. Per-kind routing is tunable with `--severity kind=level` (repeatable) — one recorded drill demoted a default-critical kind to warning (`--severity=objectstate.endpoints_empty=warning`) and another promoted `rollout.stall` to critical for a staging cluster where bad deploys are the hunt. The full decision record is [`docs/watchboard-rotation-design.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/watchboard-rotation-design.md); the wire shapes are pinned byte-exact by tests. # Reference > Generated reference: every read-path command, the watch sentinel's flags, the signal-kind catalog, and the Prometheus metrics. Every page in this section is generated by `dev/tools/gen-site-docs` from the same declarations that produce `--help`, the MCP schemas, and the skill reference stubs (one source of truth, generated outward). A drift test fails CI when a committed page differs from regeneration. ## The sentinel [Section titled “The sentinel”](#the-sentinel) * [`lookout watch`](/k8s-lookout/reference/watch/) — the resident per-cluster sentinel: full flag table. * [Signal kinds](/k8s-lookout/reference/signal-kinds/) — the frozen signal-schema v1 kind catalog. * [Prometheus metrics](/k8s-lookout/reference/metrics/) — the `--metrics-addr` surface. ## Read-path commands [Section titled “Read-path commands”](#read-path-commands) * [Finding kinds](/k8s-lookout/reference/finding-kinds/) — the whole vocabulary these commands emit, in one table. Composed entry points: * [`lookout bundle`](/k8s-lookout/reference/bundle/) — The first call of every incident: one correlated snapshot of a workload — sanitized spec, everything abnormal, broken dependency edges, blast radius, distilled logs — sectioned into a single payload instead of 4–5 separate reads. * [`lookout health`](/k8s-lookout/reference/health/) — “Any issues with this cluster?” in one call: a ten-category scorecard (control-plane, nodes, crash loops, pending, rollouts, storage, add-ons, quotas, certs, webhooks) — every category answers healthy|degraded|unavailable, degraded ones with details. With —store, findings merge the sentinel’s open triage-status records: a scan mid-incident reports the diagnosis and the agent’s severity judgment, not a fresh unknown. * [`lookout scan`](/k8s-lookout/reference/scan/) — Start here when you know something is wrong but not what: one call runs every target-free incident check across the cluster — broken workloads, dead admission webhooks, stuck volumes and PVCs, rejected Gateway routes, config drift — then drills into the dependency edges of whatever it flagged. Needs no target; `--include=audit` adds the posture sweep. ### `lookout audit` [Section titled “lookout audit”](#lookout-audit) best-practice posture: the absence of a safety net around a workload or cluster that is currently healthy — a different claim from the incident groups, which is why it is a different group * [`lookout audit cluster`](/k8s-lookout/reference/audit-cluster/) — Cluster-level security configuration posture, read from the cloud provider: Workload Identity off cluster-wide or bypassed by a node pool, node pools still serving the legacy metadata endpoints, and a control-plane endpoint the internet can reach with nothing narrowing it. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. * [`lookout audit exemptions`](/k8s-lookout/reference/audit-exemptions/) — Audit the exemption file itself: which reviewed exemptions have lapsed (and are therefore no longer annotating anything) and which are about to. The mechanism that keeps an exemption file from becoming a permanent, unread list of things nobody checks any more. * [`lookout audit hardening`](/k8s-lookout/reference/audit-hardening/) — Workload security posture: containers running privileged or holding node-root capabilities, pods sharing the host network/PID/IPC namespaces, hostPath mounts, default-ServiceAccount tokens that something actually uses, and namespaces with no Pod Security Admission enforcement. Judges every pod-template owner in scope — Deployments, StatefulSets, DaemonSets, CronJobs, unowned Jobs and unowned Pods — plus the namespaces around them. Scope with —namespace or -A; scanned counts pod templates examined, the namespaces note counts namespaces. * [`lookout audit netpol`](/k8s-lookout/reference/audit-netpol/) — NetworkPolicy coverage posture: namespaces where nothing restricts ingress or egress at all, and individual workloads that fell through the selectors of the policies covering their neighbours. Coverage means isolation — some policy selects the pod and names the direction — not that the rules it then applies are tight. hostNetwork templates are excluded, since NetworkPolicy cannot constrain them. Scope with —namespace or -A; scanned counts pod templates examined. * [`lookout audit upgrades`](/k8s-lookout/reference/audit-upgrades/) — Upgrade and patch readiness, read from the cloud provider: how far the control plane and its node pools are behind what the provider publishes, and whether anything is set up to close that gap on its own — release channel, node auto-upgrade and auto-repair, a maintenance window, active maintenance exclusions, node images on the removed Docker runtime, and upgrade notifications. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. * [`lookout audit workloads`](/k8s-lookout/reference/audit-workloads/) — Workload reliability posture for workloads that are healthy right now: no PodDisruptionBudget, only one replica, no readiness/liveness probe, no spread across nodes, placement pinned to too few nodes, autoscalers that structurally cannot scale, and CronJobs left suspended long enough to have skipped runs. Answers “what has no safety net”, as against `stab drain`, which answers “what breaks if I drain THIS node now”. Scope with —namespace, -A, or —workload; scanned counts workloads examined. ### `lookout cloud` [Section titled “lookout cloud”](#lookout-cloud) GCP-side reads: stockouts, orphaned resources, IP space, quota * [`lookout cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/) — Pod/Service/node CIDR utilization per subnet, judged: warning at 80%, critical at 95% — IP space is incompressible, an exhausted range fails the next node or pod block outright. Consumption rate/ETA lives in the sentinel’s capacity source. * [`lookout cloud orphans`](/k8s-lookout/reference/cloud-orphans/) — Billing-active cloud leftovers: unattached GCE disks older than —min-age and forwarding rules/LBs routing to zero endpoints — cost and hygiene sweep, not an incident read. * [`lookout cloud quota`](/k8s-lookout/reference/cloud-quota/) — Per-project cloud quota usage vs limit, ranked nearest-to-exhaustion: findings from —quota-warn (default 80%), critical at 95% — quota is incompressible (scale-ups fail at the limit) and increases need lead time. Trend/ETA lives in the quota source. * [`lookout cloud stockout`](/k8s-lookout/reference/cloud-stockout/) — GCE capacity stockouts (ZONE\_RESOURCE\_POOL\_EXHAUSTED) per zone/machine-type over —since (default 24h), with event-derived reroute candidates — the cloud-side why behind pods stuck Pending on failed scale-ups. ### `lookout findings` [Section titled “lookout findings”](#lookout-findings) run-to-run finding state: diff two scans into transitions (new/ongoing/escalated/resolved), ack a subject for a window * [`lookout findings ack`](/k8s-lookout/reference/findings-ack/) — Suppress one finding for a window after an operator has taken it — later diffs report it `suppressed` instead of re-raising it, and it comes back on its own when the window expires; the “I’m on this, stop paging me until lunch” surface. * [`lookout findings diff`](/k8s-lookout/reference/findings-diff/) — Diff a health report against the previous run and report what CHANGED — new, ongoing, escalated, resolved, suppressed — instead of re-listing every open finding; the command that makes a scheduled scan produce a digest an operator will keep reading. ### `lookout net` [Section titled “lookout net”](#lookout-net) active DNS/TCP/HTTP probes from inside the cluster * [`lookout net probe`](/k8s-lookout/reference/net-probe/) — Actively confirm a network hypothesis — resolve DNS names, open TCP connections, GET HTTP(S) URLs — from wherever `lookout` runs (in a pod = the in-cluster view); zero cluster mutation, no pods spawned. ### `lookout perf` [Section titled “lookout perf”](#lookout-perf) control-plane and startup performance via Cloud Monitoring query packs * [`lookout perf probe`](/k8s-lookout/reference/perf-probe/) — Control-plane and startup performance via metrics query packs: —pack=apiserver (p99 latency by verb/resource), apf (queue saturation + 429 rejects), etcd (WAL fsync p99 + DB size), startup (pod-first-ready p95 trend); apf/etcd need GKE control-plane metrics enabled — absence degrades to an explicit pack\_unavailable finding. ### `lookout stab` [Section titled “lookout stab”](#lookout-stab) stability reads: GitOps drift, node-drain blockers * [`lookout stab drain`](/k8s-lookout/reference/stab-drain/) — Before draining a node, list everything that will block the drain (PDBs at disruptionsAllowed=0) or be destroyed by it (bare pods, emptyDir data, single-replica workloads); —node details one node, -A means all nodes here (pods are always examined across all namespaces); scanned counts pods examined after the standard-drain skips (mirror/DaemonSet/completed pods). * [`lookout stab drift`](/k8s-lookout/reference/stab-drift/) — Find spec fields of Deployments/StatefulSets/DaemonSets owned by a manager other than the GitOps controller (managedFields) — out-of-band kubectl edits and rogue co-managers. Reports manager strings (tool names, not people); —identity additionally resolves each drift write to the audited principal via the cloud provider’s audit trail (GKE Cloud Audit Logs), reporting an explicit unavailable on clusters without one. Default scope: all namespaces; scanned counts workload objects examined. ### `lookout state` [Section titled “lookout state”](#lookout-state) dependency + configuration verification: edges, webhooks, workload identity, volumes * [`lookout state edges`](/k8s-lookout/reference/state-edges/) — Verify every dependency edge of one workload — ConfigMap/Secret keys, imagePullSecrets, Service selectors and endpoints, Ingress backends and class, StatefulSet governing Service and volume classes, ServiceAccount/RBAC references, TLS expiry — reporting only the broken ones. —workload also accepts Service/\/\ to enter from the service side, which is the direction the evidence arrives from when a service has no endpoints: it reports that service’s selector, endpoints, ingresses and certificates, and names the workload the selector was probably meant for. * [`lookout state gateway`](/k8s-lookout/reference/state-gateway/) — When traffic through the Gateway API does not arrive — walk GatewayClass → Gateway → listener → HTTPRoute → Service and report every hop that is rejected, unprogrammed, or points at something that is not there. Silent, and cheap, on clusters without the Gateway API installed. * [`lookout state storage`](/k8s-lookout/reference/state-storage/) — When a PersistentVolumeClaim sits Pending and the pod behind it will not schedule — name the reason: a StorageClass that does not exist, no class and no cluster default, a static-only class with nothing pre-provisioned, plus the default-class ambiguity and stranded volumes behind it. * [`lookout state volumes`](/k8s-lookout/reference/state-volumes/) — When pods hang in ContainerCreating with Multi-Attach or FailedAttachVolume events — join VolumeAttachment + PV/PVC + pods to name the exact conflict: RWO claims wanted on two nodes, attachments stuck in error, cross-zone PV locks, orphaned attachments. * [`lookout state webhooks`](/k8s-lookout/reference/state-webhooks/) — When creates/updates hang or fail cluster-wide with “failed calling webhook”, or before relying on a policy engine: audit every admission webhook — dead backends × failurePolicy (Fail + dead backend rejects every matching admission), the namespace/rule blast radius, timeout stall risk, CA-bundle expiry. The full check; health’s webhooks category delegates here. * [`lookout state wi`](/k8s-lookout/reference/state-wi/) — When a GKE pod gets 403s or metadata-server errors calling GCP APIs, verify the Workload Identity chain — KSA annotation (iam.gke.io/gcp-service-account) → roles/iam.workloadIdentityUser binding on the GSA — reporting only the broken links; vanilla clusters report an explicit unavailable. ### `lookout triage` [Section titled “lookout triage”](#lookout-triage) incident reads: everything abnormal, condensed logs/events, blast radius, what changed * [`lookout triage changes`](/k8s-lookout/reference/triage-changes/) — What changed around one workload in the window before onset — rollouts, config/secret updates, rescales, node ops — chronological, scoped to the target’s graph neighborhood; full fidelity from a sentinel store, best-effort live otherwise. * [`lookout triage delta`](/k8s-lookout/reference/triage-delta/) — Every abnormal object in one scan — the first call for “anything wrong in this cluster?”: broken/pending pods, stalled rollouts, workloads blocked from creating pods at all, node pressure/NPD/preemption, gridlocked PDBs, degraded kube-system add-ons, quotas at their limits. * [`lookout triage events`](/k8s-lookout/reference/triage-events/) — Deduped chronological event timeline: kubectl get events, but collapsed by (object, reason family) over a workload’s whole owner-reference tree, with HPA rescale-oscillation (thrash) detection. * [`lookout triage list`](/k8s-lookout/reference/triage-list/) — List what EXISTS in a namespace — kubectl get across every kind at once, one line per object, leading with the \/\/\ target the other read tools take. The first call for a namespace you have not enumerated: the health scans report only what is abnormal and name nothing when a namespace is clean, so they cannot tell you what is in one. An inventory, not a diagnosis — never guess an object’s name, list the namespace. * [`lookout triage logs`](/k8s-lookout/reference/triage-logs/) — kubectl logs, distilled: Drain-clusters raw lines into templates with counts (probe noise stripped, stack traces collapsed to top frames) — reach for this instead of reading logs whole. * [`lookout triage radius`](/k8s-lookout/reference/triage-radius/) — Blast radius of one pod/workload — who is upstream (routes/owns/governs it), lateral (same node, shared config/volume), downstream (it depends on); —at answers it as of incident onset from a sentinel store. * [`lookout triage spec`](/k8s-lookout/reference/triage-spec/) — Read ONE resource’s spec: kubectl describe, but token-dense, secret-safe, and default-elided — healthy conditions are omitted. * [`lookout triage status`](/k8s-lookout/reference/triage-status/) — Write (or read back) the triage-status record for an incident — diagnosis, action taken, and your severity judgment — so health scans stop reporting it as a fresh unknown and the sentinel stops re-paging followups; the incident playbooks’ closing move. * [`lookout triage top`](/k8s-lookout/reference/triage-top/) — Point-in-time CPU/memory saturation vs limits: kubectl top, but judged — usage-vs-limit percent per container with the OOM asymmetry built in (memory ≥95% of limit is critical, CPU caps at warning: it throttles, it does not kill); -A adds node usage vs allocatable. Trends/ETAs live in the sentinel’s saturation source; —history adds window stats via the cloud provider. # lookout audit cluster > Cluster-level security configuration posture, read from the cloud provider: Workload Identity off cluster-wide or bypassed by a node pool, node pools still serving the legacy metadata endpoints, and a control-plane endpoint the internet can reach with nothing narrowing it. Reads the provider's cluster record, not Kubernetes objects, so it takes no --namespace/-A/--workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. Cluster-level security configuration posture, read from the cloud provider: Workload Identity off cluster-wide or bypassed by a node pool, node pools still serving the legacy metadata endpoints, and a control-plane endpoint the internet can reach with nothing narrowing it. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. MCP tool: `k8s_audit_cluster` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit cluster [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `audit.workload_identity_off` | warning | Workload Identity is off cluster-wide, or a node pool bypasses it — pods authenticate to the cloud as the node | | `audit.legacy_metadata` | warning | a node pool still serves the pre-v1 instance-metadata endpoints, which any pod can read | | `audit.public_control_plane` | warning, info | the control-plane endpoint is reachable from the internet; info when authorized networks narrow it | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cluster` | on a node-pool finding: the cluster the pool belongs to, so the record stands alone | | `workload_pool` | the cluster-wide workload identity pool that this node pool’s pods bypass | | `metadata_mode` | how the node pool exposes instance metadata to pods: node-identity means any pod can mint tokens for the node’s service account | | `disable_legacy_endpoints` | the pool’s legacy-metadata setting as the provider records it: `enabled` when someone turned the pre-v1 endpoints back on, `unset` when the pool was never configured either way | | `node_pools` | summary note: node pools examined — the cluster itself is the other unit `scanned` counts | | `endpoint` | the control plane’s internet-facing address | | `authorized_networks` | how many source ranges the allow-list permits | | `authorized_network_cidrs` | those ranges, sorted as the provider returned them and capped at 8 with a +N more tail | | `gcp_public_cidrs` | whether the provider’s own public ranges are admitted in addition to the allow-list | | `capability` | cloud.unavailable: the provider capability this command needed (cluster-config) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit cluster lookout audit cluster --format=json lookout audit cluster --exemptions=exemptions.yaml ``` # lookout audit exemptions > Audit the exemption file itself: which reviewed exemptions have lapsed (and are therefore no longer annotating anything) and which are about to. The mechanism that keeps an exemption file from becoming a permanent, unread list of things nobody checks any more. Audit the exemption file itself: which reviewed exemptions have lapsed (and are therefore no longer annotating anything) and which are about to. The mechanism that keeps an exemption file from becoming a permanent, unread list of things nobody checks any more. MCP tool: `k8s_audit_exemptions` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit exemptions [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ---------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | | `--within` | duration | `336h0m0s` | how far ahead to warn about entries that are still live but expiring soon; 0 reports only entries that have already lapsed | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | -------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | | `audit.exemption_expired` | warning | an exemption entry has lapsed: the findings it used to annotate are being reported unqualified again | | `audit.exemption_expiring` | info | an exemption entry lapses within —within — renew it or let it go deliberately | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `exempt_kind` | the finding kind the entry covers — this is the entry’s `kind:` field, not this finding’s own kind | | `subject` | the entry’s match scope as written: ``, ` in `, or ` on /` | | `expires` | when the entry stops applying, RFC 3339 (a bare `YYYY-MM-DD` in the file resolves to 00:00:00Z that day) | | `expired_for` | how long ago the entry lapsed, rounded to whole days — only on audit.exemption\_expired | | `expires_in` | how long until the entry lapses, rounded to whole days — only on audit.exemption\_expiring | | `owner` | the entry’s `owner:` field, absent if it has none — which is itself worth fixing, since “expired, and nobody knows whose it was” is where these files end up | | `justification` | the entry’s `reason:` field: why the exempted finding was accepted. Distinct from the envelope’s exempt\_reason, which is the justification for THIS finding being exempt | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit exemptions --exemptions=exemptions.yaml lookout audit exemptions --exemptions=exemptions.yaml --within=720h lookout audit exemptions --exemptions=exemptions.yaml --within=0s ``` # lookout audit hardening > Workload security posture: containers running privileged or holding node-root capabilities, pods sharing the host network/PID/IPC namespaces, hostPath mounts, default-ServiceAccount tokens that something actually uses, and namespaces with no Pod Security Admission enforcement. Judges every pod-template owner in scope — Deployments, StatefulSets, DaemonSets, CronJobs, unowned Jobs and unowned Pods — plus the namespaces around them. Scope with --namespace or -A; scanned counts pod templates examined, the namespaces note counts namespaces. Workload security posture: containers running privileged or holding node-root capabilities, pods sharing the host network/PID/IPC namespaces, hostPath mounts, default-ServiceAccount tokens that something actually uses, and namespaces with no Pod Security Admission enforcement. Judges every pod-template owner in scope — Deployments, StatefulSets, DaemonSets, CronJobs, unowned Jobs and unowned Pods — plus the namespaces around them. Scope with —namespace or -A; scanned counts pod templates examined, the namespaces note counts namespaces. MCP tool: `k8s_audit_hardening` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit hardening [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ---------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | `audit.privileged_container` | warning | a container runs privileged or holds a node-root capability (ALL, SYS\_ADMIN): a container escape is a node compromise | | `audit.host_namespace` | warning | the pod shares the node’s network, PID, or IPC namespace | | `audit.hostpath_mount` | warning, info | the pod mounts a host path; warning when it is writable, info when read-only | | `audit.default_sa_automount` | warning | the pod runs as the namespace’s default ServiceAccount with its token automounted, and something in the pod can use it | | `audit.podsecurity_gaps` | warning | the namespace enforces no Pod Security Admission level, so none of the above is prevented | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `containers` | containers implicated by the finding — those running privileged, or holding a node-root capability | | `container_names` | their names, capped at 8 with a +N more tail | | `total_containers` | containers in the pod template, init containers included, so `containers` reads as a fraction | | `capabilities` | the node-root capabilities added by those containers (ALL, SYS\_ADMIN), sorted and deduplicated | | `host_paths` | hostPath volumes the template mounts; a declared but unmounted hostPath volume grants no access and is not counted | | `host_path_names` | the paths on the node, sorted and capped at 8 | | `service_account` | the ServiceAccount the finding is about — always `default`, the one every pod gets when its template names none | | `mounting_workloads` | workloads in the namespace running as the default ServiceAccount without disabling automount at the pod level; the finding does not fire at 0 | | `mounting_workload_names` | their Kind/name, sorted and capped at 8 | | `pss_enforce` | the namespace’s pod-security.kubernetes.io/enforce label, omitted when unset | | `pss_warn` | its /warn label, omitted when unset — set without /enforce means the namespace is in dry-run | | `pss_audit` | its /audit label, omitted when unset — same dry-run meaning | | `workloads` | pod templates this pass judged in the namespace, so an unenforced namespace with nothing in it reads differently from a busy one | | `namespaces` | summary note: namespaces examined — the denominator for every namespace-subject claim, which `scanned` (pod templates) does not cover | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit hardening -A lookout audit hardening --namespace=prod lookout audit hardening -A --exemptions=exemptions.yaml --format=json ``` # lookout audit netpol > NetworkPolicy coverage posture: namespaces where nothing restricts ingress or egress at all, and individual workloads that fell through the selectors of the policies covering their neighbours. Coverage means isolation — some policy selects the pod and names the direction — not that the rules it then applies are tight. hostNetwork templates are excluded, since NetworkPolicy cannot constrain them. Scope with --namespace or -A; scanned counts pod templates examined. NetworkPolicy coverage posture: namespaces where nothing restricts ingress or egress at all, and individual workloads that fell through the selectors of the policies covering their neighbours. Coverage means isolation — some policy selects the pod and names the direction — not that the rules it then applies are tight. hostNetwork templates are excluded, since NetworkPolicy cannot constrain them. Scope with —namespace or -A; scanned counts pod templates examined. MCP tool: `k8s_audit_netpol` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit netpol [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ---------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.netpol_missing` | warning, info | nothing restricts this direction for the subject — a namespace with no policy at all, or a workload the covering policies’ selectors miss; info for the egress direction, where no policy is a defensible default | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policies` | NetworkPolicies in the namespace naming this direction in policyTypes; 0 on a namespace-subject finding, and the number that failed to select the subject on a workload one | | `total_policies` | NetworkPolicies in the namespace in either direction, so an egress-only namespace does not read as an empty one | | `workloads` | pod templates in the namespace this claim covers, excluding hostNetwork ones; the finding does not fire at 0 | | `host_network_workloads` | pod templates excluded because they use the node’s network namespace, where NetworkPolicy does not apply; omitted at 0 | | `covered_workloads` | pod templates in the namespace that ARE selected for this direction — the neighbours the subject fell out of step with | | `pod_labels` | the template’s own labels, which are what the policies’ selectors failed to match, sorted and capped at 8 | | `namespaces` | summary note: namespaces examined — the denominator for the namespace-subject claims, which `scanned` (pod templates) does not cover | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit netpol -A lookout audit netpol --namespace=prod lookout audit netpol -A --exemptions=exemptions.yaml --format=json ``` # lookout audit upgrades > Upgrade and patch readiness, read from the cloud provider: how far the control plane and its node pools are behind what the provider publishes, and whether anything is set up to close that gap on its own — release channel, node auto-upgrade and auto-repair, a maintenance window, active maintenance exclusions, node images on the removed Docker runtime, and upgrade notifications. Reads the provider's cluster record, not Kubernetes objects, so it takes no --namespace/-A/--workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. Upgrade and patch readiness, read from the cloud provider: how far the control plane and its node pools are behind what the provider publishes, and whether anything is set up to close that gap on its own — release channel, node auto-upgrade and auto-repair, a maintenance window, active maintenance exclusions, node images on the removed Docker runtime, and upgrade notifications. Reads the provider’s cluster record, not Kubernetes objects, so it takes no —namespace/-A/—workload; scanned counts the cluster plus its node pools. Without a provider capability it reports an explicit unavailable rather than silence. MCP tool: `k8s_audit_upgrades` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit upgrades [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.version_behind` | warning, info | the control plane or a node pool is behind what the provider publishes, or a node pool has skewed from the control plane; info while the gap is still within the supported skew | | `audit.upgrade_unmanaged` | warning | nothing will close that gap on its own: no release channel, or node auto-upgrade/auto-repair off | | `audit.upgrade_blocked` | warning, info | an active maintenance exclusion, or a node image on the removed Docker runtime, will stop the upgrade when it comes | | `audit.upgrade_unattended` | info | upgrades will happen with nobody watching: no maintenance window, or no upgrade notifications | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cluster` | on a node-pool finding: the cluster the pool belongs to, so the record stands alone | | `version` | the current version of the finding’s subject — the control plane’s, or the node pool’s | | `target_version` | the version the provider would move this cluster to: its channel’s upgrade target where one is published, otherwise the channel’s default | | `control_plane_version` | on a node-pool skew finding: the control-plane version the pool is measured against | | `minor_versions_behind` | how many minor releases separate the two versions | | `channel` | the release channel the cluster is subscribed to, and the one whose published versions the comparison used; `none` when it is subscribed to no channel | | `image_type` | the provider’s name for the node image the pool runs | | `exclusion` | the operator’s name for the maintenance exclusion currently in force | | `scope` | how much of the upgrade stream that exclusion holds back: all-upgrades, minor-upgrades or minor-and-node-upgrades | | `ends` | when the exclusion lifts, or `end-of-support` for one that runs until the cluster’s version leaves support | | `days_remaining` | how much longer the exclusion has left to run | | `node_pools` | summary note: node pools examined — the cluster itself is the other unit `scanned` counts | | `capability` | cloud.unavailable: the provider capability this command needed (cluster-config) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit upgrades lookout audit upgrades --format=json lookout audit upgrades --exemptions=exemptions.yaml ``` # lookout audit workloads > Workload reliability posture for workloads that are healthy right now: no PodDisruptionBudget, only one replica, no readiness/liveness probe, no spread across nodes, placement pinned to too few nodes, autoscalers that structurally cannot scale, and CronJobs left suspended long enough to have skipped runs. Answers "what has no safety net", as against `stab drain`, which answers "what breaks if I drain THIS node now". Scope with --namespace, -A, or --workload; scanned counts workloads examined. Workload reliability posture for workloads that are healthy right now: no PodDisruptionBudget, only one replica, no readiness/liveness probe, no spread across nodes, placement pinned to too few nodes, autoscalers that structurally cannot scale, and CronJobs left suspended long enough to have skipped runs. Answers “what has no safety net”, as against `stab drain`, which answers “what breaks if I drain THIS node now”. Scope with —namespace, -A, or —workload; scanned counts workloads examined. MCP tool: `k8s_audit_workloads` (MCP profile: `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout audit workloads [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------------------ | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--cron-suspended` | duration | `168h0m0s` | how long a CronJob must have been suspended before it reads as forgotten rather than as maintenance in progress; it must also have skipped at least one activation, so the claim scales to the schedule | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `audit.no_pdb` | warning | the workload has no PodDisruptionBudget: a drain can take every replica at once | | `audit.single_replica` | warning | the workload runs a single replica, so any disruption is an outage | | `audit.no_readiness_probe` | warning | a container has no readiness probe, so traffic reaches it before it can serve | | `audit.no_liveness_probe` | info | a container has no liveness probe, so a wedged process is never restarted | | `audit.no_spread` | info | the workload’s replicas are not spread across nodes or zones | | `audit.rigid_scheduling` | warning, info | placement constraints pin the workload to too few nodes to survive losing one | | `audit.hpa_cannot_scale` | warning | the autoscaler structurally cannot scale: min equals max, the target is missing, or a container has no request for its utilization target to divide by | | `audit.suspended_cronjob` | warning | a CronJob has been suspended past —cron-suspended and has skipped activations because of it: whatever it does is not happening, and nothing else reports that | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `replicas` | the workload’s spec.replicas (nil defaults to 1, matching the API server); absent on DaemonSets, whose replica count is the node count | | `namespace_pdbs` | PodDisruptionBudgets in the workload’s namespace — 0 says the namespace has no PDB culture at all, a non-zero value says this workload was missed | | `containers` | containers implicated by the finding: those missing the probe, or missing the request the autoscaler’s utilization target divides by | | `container_names` | their names, capped at 8 with a +N more tail | | `total_containers` | containers in the pod template, so `containers` reads as a fraction | | `min_replicas` | the HPA’s spec.minReplicas (nil defaults to 1, matching the API server) | | `max_replicas` | the HPA’s spec.maxReplicas | | `metric` | the utilization metric the HPA cannot compute, comma-separated if more than one | | `scale_target` | the HPA’s scaleTargetRef as Kind/name | | `eligible_nodes` | nodes satisfying the workload’s REQUIRED placement constraint; an upper bound, since taints and cordons are not subtracted | | `cluster_nodes` | nodes in the cluster, so `eligible_nodes` reads as a fraction | | `constraint` | the label and field keys that narrow placement, sorted and capped at 8 | | `schedule` | the suspended CronJob’s spec.schedule | | `time_zone` | the CronJob’s spec.timeZone, when set | | `suspended_for` | how long spec.suspend has been true, rounded to whole days | | `suspended_since` | when the suspension is estimated to have started, RFC 3339 | | `anchor` | the evidence that estimate came from: managed\_field (the managedFields entry owning spec.suspend), last\_schedule, or creation | | `missed_runs` | activations skipped since then; ≥N when the walk was capped, unknown when the schedule does not parse | | `pdbs` | summary note: PodDisruptionBudgets seen in scope | | `hpas` | summary note: HorizontalPodAutoscalers seen in scope | | `nodes` | summary note: nodes in the cluster — the denominator every placement claim is resolved against | | `workloads` | summary note: workloads examined, broken down as deployments/statefulsets/daemonsets/cronjobs | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout audit workloads -A lookout audit workloads --namespace=prod lookout audit workloads --workload=Deployment/prod/checkout lookout audit workloads --workload=CronJob/prod/nightly-backup lookout audit workloads -A --exemptions=exemptions.yaml --format=json ``` # lookout bundle > The first call of every incident: one correlated snapshot of a workload — sanitized spec, everything abnormal, broken dependency edges, blast radius, distilled logs — sectioned into a single payload instead of 4–5 separate reads. The first call of every incident: one correlated snapshot of a workload — sanitized spec, everything abnormal, broken dependency edges, blast radius, distilled logs — sectioned into a single payload instead of 4–5 separate reads. MCP tool: `k8s_triage_workload` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout bundle [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--incident` | string | — | inject payload JSON (the message a lookout-watch incident session starts with); its object reference resolves to the target workload via the owner chain, or for a Service via its selector — alternative to —workload | | `--depth` | int | `2` | blast-radius traversal depth: graph edges followed per direction in the radius section | | `--max-templates` | int | `15` | cap distilled log template clusters in the logs section (triage logs defaults to 40; the bundle keeps the tighter budget) | | `--cert-warn` | duration | `720h` | report TLS certificates expiring within this window (edges section) | | `--store` | string | — | path to a sentinel’s SQLite store (its —store file); merges open triage-status records so the bundle’s findings carry triage\_\* fields and severity reflects the agent’s override | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | | `--lists` | string | `all` | which cluster resources the List pass reads: ‘all’ (default), a comma-separated allowlist (pods,deployments), or subtractions (all,-secrets) for a least-privilege posture. Denied or deselected lists degrade to a partial bundle with a skipped= note on the head, never an error. | | `--lists-preflight` | bool | — | before listing, SelfSubjectAccessReview each selected resource and drop the denied ones proactively (fewer 403s); falls back to reactive Forbidden-skip if SSAR is not permitted | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bundle.target` | info | the head record: which workload the bundle is about and which sections follow | | `radius.neighbor` | info | one object in the target’s neighborhood, with its direction, relation, and hop distance — an enumeration of impact, not a defect | | `radius.missing` | warning | a neighbor the graph references but never observed, in a kind the snapshot does watch: the reference is dangling | | `spec.resource` | info | the object itself: metadata, owner, and the kind-specific highlights (one per target) | | `spec.container` | info | one container of the target: image, resources, ports, probes, env (one per container) | | `spec.condition` | warning | a status condition of the target that is not in its nominal state | | `pod.crashloop` | critical | a container is crash looping | | `pod.imagepull` | critical | a container cannot pull its image | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | | `pod.restarts` | warning | a container has restarted at least —restarts times | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | | `pod.failed` | warning | the pod reached phase Failed | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | | `job.failed` | warning | a Job’s Failed condition is set | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | | `node.notready` | critical | the node’s Ready condition is not True | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | | `log.template` | critical, warning, info | one distilled template and how many lines collapsed into it; severity is the guessed level — critical at fatal, warning for error-ish, info otherwise | | `log.stacktrace` | critical, warning, info | a template that is a Go panic, Java exception, or Python traceback, with its innermost frames | | `log.overflow` | info | the low-count tail —max-templates dropped, counted rather than discarded silently (no coverage lies) | | `log.probe_noise` | info | health/readiness probe request lines stripped before distillation, counted so the removal is visible | | `log.fetch_error` | warning | a container’s log stream could not be read, so its lines are missing from the distillation | | `edge.missing_ref` | critical | a referenced ConfigMap, Secret, ServiceAccount, TLS secret, IngressClass, StorageClass, or governing Service does not exist | | `edge.missing_key` | critical | the referenced key is absent from an existing ConfigMap/Secret | | `edge.invalid_ref` | warning | the referenced object exists but is the wrong type to serve the reference | | `edge.unclassed` | warning | the Ingress names no class and no IngressClass declares itself the cluster default — no controller will claim it | | `edge.selector_empty` | critical | a Service selector selects zero pods, so the service routes nowhere | | `edge.selector_unready` | critical, warning | the Service selects pods but some are not Ready; critical when none are | | `edge.endpoints_missing` | critical | a selecting Service has no EndpointSlices at all | | `edge.endpoints_orphaned` | warning | an endpoint targetRef names a pod that no longer exists | | `edge.endpoints_unready` | critical, warning | the endpoint ready-count disagrees with the selected pods (stale or lagging slices); critical at zero ready | | `edge.backend_missing` | critical | an Ingress backend service, or the port it names, does not exist | | `edge.cert_expired` | critical | a TLS certificate’s NotAfter is in the past | | `edge.cert_expiring` | warning | a TLS certificate expires within —cert-warn | | `edge.cert_invalid` | warning | tls.crt is missing or unparseable, or the secret is not kubernetes.io/tls | | `edge.rbac_dangling` | warning | a (Cluster)RoleBinding for the workload’s ServiceAccount points at a missing (Cluster)Role | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `section` | which bundle section the finding belongs to: spec\|delta\|edges\|radius\|logs | | `sections` | on the bundle.target head finding: the sections that follow | | `skipped` | on the bundle.target head finding: comma-separated resources the List pass could not read (denied) or was told to omit (—lists) — the bundle is a documented partial, secret-free by default under a least-privilege role | | `relation` | radius neighbor’s relation to the target: upstream (routes/owns/governs it), downstream (it points at), lateral (shares a node/volume/config) | | `hop` | radius neighbor’s BFS depth from the target (1 = direct edge) | | `triage_status` | triage state from the matched record (investigating\|triaged\|actioned\|escalated) — present only with —store on merged findings | | `triage_root_cause` | the incident agent’s root-cause hypothesis, from the matched triage-status record | | `triage_action` | the incident agent’s paper trail (PRs opened, escalations), from the matched triage-status record | | `triage_session` | incident session that wrote the matched triage-status record | | `triage_age` | how long ago the matched triage-status record was last updated | | `labels` | resource labels as sorted k=v pairs | | `owner` | controlling owner as Kind/name | | `phase` | status.phase, only when abnormal for the kind (zero nominal state) | | `node` | node the pod is scheduled on | | `service_account` | pod’s service account | | `volumes` | pod volumes as name:source (source names its referent, never its payload) | | `container` | container name (one spec.container finding per container) | | `init` | “true” when the container is an init container | | `image` | container image reference | | `requests` | resource requests as sorted k=v pairs | | `limits` | resource limits as sorted k=v pairs | | `ports` | container or service ports, compact (\[name:]port\[->target]\[/proto]) | | `liveness` | liveness probe one-liner (kind, target, non-default timings) | | `readiness` | readiness probe one-liner | | `env` | env vars; literal credential values are \[REDACTED], valueFrom entries render as named references | | `env_from` | envFrom sources as kind:name | | `replicas` | desired replica count | | `strategy` | rollout strategy summary (type + non-default knobs) | | `selector` | workload/service selector as sorted k=v pairs | | `type` | Service or Secret type, only when non-default | | `external_name` | ExternalName service target | | `session_affinity` | service session affinity, only when not None | | `keys` | ConfigMap/Secret data KEYS with byte sizes — values are never rendered | | `condition` | abnormal status condition as Type=Status | | `since` | the condition’s lastTransitionTime | | `spec` | kinds without a dedicated renderer: sanitized spec flattened to path=value pairs | | `restarts` | container restart count | | `exit_code` | exit code of the container’s last termination | | `last_state` | reason of the container’s last termination (e.g. OOMKilled) | | `age` | how long the abnormal state has persisted | | `desired` | desired replica/scheduled count from spec | | `ready` | ready count from status | | `updated` | updated-to-current-revision count from status | | `available` | available count from status | | `failed` | failed pod count of a Job | | `schedule` | a CronJob’s spec.schedule | | `expected` | the activation a CronJob should have run and did not | | `missed_runs` | activations missed since the anchor; ≥N when the walk was capped | | `anchor` | what the missed count was measured from: last\_schedule or creation | | `time_zone` | a CronJob’s spec.timeZone, when set | | `last_schedule` | a CronJob’s status.lastScheduleTime, or never | | `active_jobs` | Jobs a CronJob still has running | | `taint` | taint key indicating reclaim/drain | | `pods` | pods affected (behind a cordoned node or a PDB) | | `healthy` | currently healthy pods behind a PDB | | `required` | pods the PDB requires healthy | | `addon` | system add-on role: dns, proxy, cni, csi, metrics, connectivity | | `resource` | ResourceQuota resource name at or near its limit | | `used` | quota usage from status | | `hard` | quota hard limit from status | | `pct` | quota usage as percent of the hard limit | | `template` | log template; <\*> marks positions that varied across merged lines | | `count` | lines merged into this cluster (on log.probe\_noise: probe lines stripped) | | `level` | guessed log level (fatal\|error\|warn\|info\|debug) from token/field match | | `first_seen` | RFC3339 timestamp of the oldest merged line (from log timestamps when parseable) | | `last_seen` | RFC3339 timestamp of the newest merged line | | `lang` | stack-trace runtime on log.stacktrace findings: go\|java\|python | | `frames` | top stack frames on log.stacktrace findings, innermost first, ’ < ’ separated | | `sample` | one representative raw line, truncated and sanitized | | `omitted_templates` | clusters dropped by —max-templates (log.overflow only) | | `omitted_lines` | lines inside the dropped clusters (log.overflow only) | | `workload` | the target the edges were traced from as \/\/\, stamped on every finding — a workload, or the Service itself when entered from the service side | | `likely_workload` | on a Service-entry edge.selector\_empty: the workload in that namespace whose pod labels best fit the broken selector, i.e. the one it was probably meant to select. Absent when two workloads fit equally well, because then naming one would be a guess | | `volume` | pod volume, or StatefulSet volumeClaimTemplate, whose reference is broken | | `key` | the referenced key that is missing from the ConfigMap/Secret | | `selected` | pods the Service selector currently selects | | `endpoints` | total endpoints across the Service’s EndpointSlices | | `slices` | how many EndpointSlices back the Service | | `service` | the Service a slice, Ingress backend, or StatefulSet serviceName refers to | | `pod` | pod named by an orphaned endpoint targetRef | | `subject` | TLS certificate subject (CN when set); never key material | | `not_after` | TLS certificate NotAfter, RFC 3339 | | `days_left` | whole days until NotAfter (negative = expired) | | `via` | how the broken reference is reached from the workload: mount, ingress, or imagePullSecret | | `ingress` | Ingress referencing the TLS secret, or the unserved Ingress itself | | `host` | Ingress rule host of the broken backend (empty for the default backend) | | `path` | Ingress rule path of the broken backend | | `port` | Service port (name or number) the Ingress backend asks for | | `role_ref` | dangling roleRef as \/\ | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout bundle --workload=Deployment/prod/api lookout bundle --workload=StatefulSet/db/postgres --since=30m --format=json lookout bundle --incident='{"namespace":"prod","kind_of_object":"Pod","name":"api-6d5f8c-x2v9k"}' ``` # lookout cloud ipspace > Pod/Service/node CIDR utilization per subnet, judged: warning at 80%, critical at 95% — IP space is incompressible, an exhausted range fails the next node or pod block outright. Consumption rate/ETA lives in the sentinel's capacity source. Pod/Service/node CIDR utilization per subnet, judged: warning at 80%, critical at 95% — IP space is incompressible, an exhausted range fails the next node or pod block outright. Consumption rate/ETA lives in the sentinel’s capacity source. MCP tool: `k8s_cloud_ipspace` ## Usage [Section titled “Usage”](#usage) ```sh lookout cloud ipspace [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------- | ---- | ------- | ---------------------------------------------------------------------------------------------------------------- | | `--all` | bool | — | exploratory dump: emit every range regardless of utilization (info severity below 80%), sorted by pct descending | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ipspace.range` | critical, warning, info | a pod/service/node range is at 80% of its CIDR or worse; critical from 95%, info for a range the cloud APIs cannot rate and for an —all row below the line | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `cidr` | the range’s CIDR block | | `purpose` | what the range allocates: pods, services, or nodes | | `used` | allocated addresses (for GKE pod ranges: addresses of the per-node blocks already carved out — the granularity at which the range actually exhausts) | | `capacity` | usable addresses in the range | | `pct` | used as a percent of capacity, one decimal | | `capability` | cloud.unavailable: the provider capability this command needed (ipspace) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout cloud ipspace lookout cloud ipspace --all lookout cloud ipspace --format=json ``` # lookout cloud orphans > Billing-active cloud leftovers: unattached GCE disks older than --min-age and forwarding rules/LBs routing to zero endpoints — cost and hygiene sweep, not an incident read. Billing-active cloud leftovers: unattached GCE disks older than —min-age and forwarding rules/LBs routing to zero endpoints — cost and hygiene sweep, not an incident read. MCP tool: `k8s_cloud_orphans` ## Usage [Section titled “Usage”](#usage) ```sh lookout cloud orphans [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `--only` | string | `disks,lbs` | resource classes to sweep, comma-separated: disks, lbs | | `--min-age` | duration | `24h0m0s` | report a disk only when unattached at least this long (age from last detach, else creation); disks with no datable age are always reported | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `orphan.disk` | warning | a GCE disk has been unattached for at least —min-age and is still billing | | `orphan.lb` | warning | a forwarding rule or load balancer routes to zero endpoints and is still billing | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------ | | `zone` | orphan.disk: the disk’s zone | | `size_gb` | orphan.disk: provisioned size in GB (billed whether used or not) | | `disk_type` | orphan.disk: disk type short name (pd-ssd bills \~4x pd-standard idle) | | `unused_since` | orphan.disk: last detach (or creation, if never attached), RFC3339; omitted when the provider cannot date it | | `unused_for` | orphan.disk: how long the disk has been unattached; “unknown” when undatable | | `region` | orphan.lb: the forwarding rule’s region (“global” for global rules) | | `why` | orphan.lb: the provider’s orphan judgment (e.g. which backend resolved empty) | | `capability` | cloud.unavailable: the provider capability this command needed (orphans) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout cloud orphans lookout cloud orphans --only=disks --min-age=72h lookout cloud orphans --only=lbs --format=json ``` # lookout cloud quota > Per-project cloud quota usage vs limit, ranked nearest-to-exhaustion: findings from --quota-warn (default 80%), critical at 95% — quota is incompressible (scale-ups fail at the limit) and increases need lead time. Trend/ETA lives in the quota source. Per-project cloud quota usage vs limit, ranked nearest-to-exhaustion: findings from —quota-warn (default 80%), critical at 95% — quota is incompressible (scale-ups fail at the limit) and increases need lead time. Trend/ETA lives in the quota source. MCP tool: `k8s_cloud_quota` ## Usage [Section titled “Usage”](#usage) ```sh lookout cloud quota [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------------------- | | `--quota-warn` | int | `80` | report a quota only at or above this percent of its limit (critical is fixed at 95%) | | `--all` | bool | — | exploratory dump: emit every ratable quota regardless of —quota-warn (info severity below it), sorted by pct descending | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `quota.pressure` | critical, warning, info | a cloud quota is at or above —quota-warn percent of its limit; critical from 95%, info for an —all row below the line | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------- | ---------------------------------------------------------------------- | | `scope` | the quota’s scope: a region name, or “global” | | `usage` | current usage in the quota’s own unit | | `limit` | the quota limit | | `unit` | the quota’s unit, when the provider names one | | `pct` | usage as a percent of the limit, one decimal | | `capability` | cloud.unavailable: the provider capability this command needed (quota) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout cloud quota lookout cloud quota --quota-warn=60 lookout cloud quota --all --format=json ``` # lookout cloud stockout > GCE capacity stockouts (ZONE_RESOURCE_POOL_EXHAUSTED) per zone/machine-type over --since (default 24h), with event-derived reroute candidates — the cloud-side why behind pods stuck Pending on failed scale-ups. GCE capacity stockouts (ZONE\_RESOURCE\_POOL\_EXHAUSTED) per zone/machine-type over —since (default 24h), with event-derived reroute candidates — the cloud-side why behind pods stuck Pending on failed scale-ups. MCP tool: `k8s_cloud_stockout` ## Usage [Section titled “Usage”](#usage) ```sh lookout cloud stockout [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `stockout.zone` | warning | the cloud had no capacity for a machine type in this zone during the window — the reason a scale-up failed and pods stayed Pending | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `machine_type` | the exhausted machine type (omitted when the log record does not name one) | | `events` | stockout events for this zone/machine-type pair in the window | | `first_seen` | earliest event in the window (RFC3339) | | `last_seen` | latest event in the window (RFC3339) | | `reroute` | same-region zones active in the window with no stockout for this machine type, comma-separated; omitted when the window offers no clean candidate | | `window` | summary-line note: the lookback the events cover | | `capability` | cloud.unavailable: the provider capability this command needed (stockout) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout cloud stockout lookout cloud stockout --since=6h lookout cloud stockout --format=json ``` # Finding kinds > Every kind= a read-path check can emit, its claim, and the commands that produce it — generated from the commands' own ledgers. Every `kind=` the read path can emit (150 in all), rendered from the same `Kinds` declarations that produce each command’s `--help`, MCP tool schema, and reference page. A check cannot emit a kind that is not here: the contract tests reject an undeclared kind, and a source sweep rejects one no test happens to exercise. The severity column is every level the kind can carry, worst first — one kind often spans two, because the same defect is graver in some shapes than others. A kind’s absence from a run means the check looked and found nothing (zero nominal state); it never means the check was skipped, which is reported explicitly. These are read-path FINDING kinds. The sentinel’s wire format has its own frozen vocabulary — see [Signal kinds](/k8s-lookout/reference/signal-kinds/). | Kind | Severity | Claim | Emitted by | | ----------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `audit.default_sa_automount` | warning | the pod runs as the namespace’s default ServiceAccount with its token automounted, and something in the pod can use it | [`audit hardening`](/k8s-lookout/reference/audit-hardening/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.exemption_expired` | warning | an exemption entry has lapsed: the findings it used to annotate are being reported unqualified again | [`audit exemptions`](/k8s-lookout/reference/audit-exemptions/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.exemption_expiring` | info | an exemption entry lapses within —within — renew it or let it go deliberately | [`audit exemptions`](/k8s-lookout/reference/audit-exemptions/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.host_namespace` | warning | the pod shares the node’s network, PID, or IPC namespace | [`audit hardening`](/k8s-lookout/reference/audit-hardening/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.hostpath_mount` | warning, info | the pod mounts a host path; warning when it is writable, info when read-only | [`audit hardening`](/k8s-lookout/reference/audit-hardening/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.hpa_cannot_scale` | warning | the autoscaler structurally cannot scale: min equals max, the target is missing, or a container has no request for its utilization target to divide by | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.legacy_metadata` | warning | a node pool still serves the pre-v1 instance-metadata endpoints, which any pod can read | [`audit cluster`](/k8s-lookout/reference/audit-cluster/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.netpol_missing` | warning, info | nothing restricts this direction for the subject — a namespace with no policy at all, or a workload the covering policies’ selectors miss; info for the egress direction, where no policy is a defensible default | [`audit netpol`](/k8s-lookout/reference/audit-netpol/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.no_liveness_probe` | info | a container has no liveness probe, so a wedged process is never restarted | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.no_pdb` | warning | the workload has no PodDisruptionBudget: a drain can take every replica at once | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.no_readiness_probe` | warning | a container has no readiness probe, so traffic reaches it before it can serve | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.no_spread` | info | the workload’s replicas are not spread across nodes or zones | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.podsecurity_gaps` | warning | the namespace enforces no Pod Security Admission level, so none of the above is prevented | [`audit hardening`](/k8s-lookout/reference/audit-hardening/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.privileged_container` | warning | a container runs privileged or holds a node-root capability (ALL, SYS\_ADMIN): a container escape is a node compromise | [`audit hardening`](/k8s-lookout/reference/audit-hardening/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.public_control_plane` | warning, info | the control-plane endpoint is reachable from the internet; info when authorized networks narrow it | [`audit cluster`](/k8s-lookout/reference/audit-cluster/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.rigid_scheduling` | warning, info | placement constraints pin the workload to too few nodes to survive losing one | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.single_replica` | warning | the workload runs a single replica, so any disruption is an outage | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.suspended_cronjob` | warning | a CronJob has been suspended past —cron-suspended and has skipped activations because of it: whatever it does is not happening, and nothing else reports that | [`audit workloads`](/k8s-lookout/reference/audit-workloads/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.upgrade_blocked` | warning, info | an active maintenance exclusion, or a node image on the removed Docker runtime, will stop the upgrade when it comes | [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.upgrade_unattended` | info | upgrades will happen with nobody watching: no maintenance window, or no upgrade notifications | [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.upgrade_unmanaged` | warning | nothing will close that gap on its own: no release channel, or node auto-upgrade/auto-repair off | [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.version_behind` | warning, info | the control plane or a node pool is behind what the provider publishes, or a node pool has skewed from the control plane; info while the gap is still within the supported skew | [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/), [`scan`](/k8s-lookout/reference/scan/) | | `audit.workload_identity_off` | warning | Workload Identity is off cluster-wide, or a node pool bypasses it — pods authenticate to the cloud as the node | [`audit cluster`](/k8s-lookout/reference/audit-cluster/), [`scan`](/k8s-lookout/reference/scan/) | | `bundle.target` | info | the head record: which workload the bundle is about and which sections follow | [`bundle`](/k8s-lookout/reference/bundle/) | | `cert.expired` | critical | a TLS secret’s certificate has expired | [`health`](/k8s-lookout/reference/health/) | | `cert.expiring` | warning | a TLS secret’s certificate expires within —cert-warn | [`health`](/k8s-lookout/reference/health/) | | `cert.invalid` | warning | a TLS secret’s tls.crt does not contain a parseable X.509 certificate | [`health`](/k8s-lookout/reference/health/) | | `change.config` | info | a ConfigMap in the neighborhood changed | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.label` | info | only labels changed on a neighborhood object — enough to move it in or out of a selector | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.node` | info | a Node in the neighborhood changed | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.rollout` | info | a workload’s pod template changed — a new image, container or mount, or a controller churn event | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.scale` | info | a workload’s replica count changed | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.secret` | info | a Secret in the neighborhood changed (names and shortened hashes only, never values —) | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `change.topology` | info | a neighborhood object appeared, disappeared, or changed in a way none of the other classes name | [`triage changes`](/k8s-lookout/reference/triage-changes/) | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | [`audit cluster`](/k8s-lookout/reference/audit-cluster/), [`audit upgrades`](/k8s-lookout/reference/audit-upgrades/), [`cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/), [`cloud orphans`](/k8s-lookout/reference/cloud-orphans/), [`cloud quota`](/k8s-lookout/reference/cloud-quota/), [`cloud stockout`](/k8s-lookout/reference/cloud-stockout/), [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/), [`state wi`](/k8s-lookout/reference/state-wi/), [`triage top`](/k8s-lookout/reference/triage-top/) | | `crd.unavailable` | info | the API group this check reads is not served by the cluster, so nothing was examined (no coverage lies) | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `drain.bare_pod` | warning | a pod on this node has no owner, so eviction deletes it permanently and nothing recreates it | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `drain.local_storage` | warning | a pod on this node has emptyDir volumes: the drain needs —delete-emptydir-data and the data is lost | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `drain.node` | critical, warning | the -A roll-up: this node is not cleanly drainable, with the blocker classes counted; critical when a PDB gridlock is among them | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `drain.pdb_gridlock` | critical | a PodDisruptionBudget covering pods on this node allows zero disruptions: the eviction API refuses and the drain hangs | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `drain.singleton` | warning | a pod on this node is the only replica of its controller — evicting it is an outage | [`stab drain`](/k8s-lookout/reference/stab-drain/) | | `drift.manual_edit` | critical, warning | a manager other than the GitOps controller owns spec fields on this object; critical when one of them is high blast radius (image, replicas, env) | [`scan`](/k8s-lookout/reference/scan/), [`stab drift`](/k8s-lookout/reference/stab-drift/) | | `edge.backend_missing` | critical | an Ingress backend service, or the port it names, does not exist | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.cert_expired` | critical | a TLS certificate’s NotAfter is in the past | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.cert_expiring` | warning | a TLS certificate expires within —cert-warn | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.cert_invalid` | warning | tls.crt is missing or unparseable, or the secret is not kubernetes.io/tls | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.endpoints_missing` | critical | a selecting Service has no EndpointSlices at all | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.endpoints_orphaned` | warning | an endpoint targetRef names a pod that no longer exists | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.endpoints_unready` | critical, warning | the endpoint ready-count disagrees with the selected pods (stale or lagging slices); critical at zero ready | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.invalid_ref` | warning | the referenced object exists but is the wrong type to serve the reference | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.missing_key` | critical | the referenced key is absent from an existing ConfigMap/Secret | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.missing_ref` | critical | a referenced ConfigMap, Secret, ServiceAccount, TLS secret, IngressClass, StorageClass, or governing Service does not exist | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.rbac_dangling` | warning | a (Cluster)RoleBinding for the workload’s ServiceAccount points at a missing (Cluster)Role | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.selector_empty` | critical | a Service selector selects zero pods, so the service routes nowhere | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.selector_unready` | critical, warning | the Service selects pods but some are not Ready; critical when none are | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `edge.unclassed` | warning | the Ingress names no class and no IngressClass declares itself the cluster default — no controller will claim it | [`bundle`](/k8s-lookout/reference/bundle/), [`scan`](/k8s-lookout/reference/scan/), [`state edges`](/k8s-lookout/reference/state-edges/) | | `event.hpa_thrash` | warning | an HPA changed scale direction at least —hpa-flips times inside —hpa-window: the autoscaler is fighting itself | [`triage events`](/k8s-lookout/reference/triage-events/) | | `event.normal` | info | one collapsed timeline entry for a Normal-type event family — context for the warnings around it, not a problem on its own | [`triage events`](/k8s-lookout/reference/triage-events/) | | `event.warning` | warning | one collapsed timeline entry for a Warning-type event family on a subject | [`triage events`](/k8s-lookout/reference/triage-events/) | | `findings.ack` | info | the receipt for the ack this call took or cleared — what was acked, by whom, and until when | [`findings ack`](/k8s-lookout/reference/findings-ack/) | | `findings.transition` | critical, warning, info | a finding subject changed state since the previous run (new\|ongoing\|escalated\|resolved\|suppressed); the severity is the underlying finding’s current one, not a judgment about the transition | [`findings diff`](/k8s-lookout/reference/findings-diff/) | | `gateway.class_not_accepted` | critical | the Gateway’s GatewayClass is not Accepted by its controller | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `gateway.listener_invalid` | warning | one listener of an otherwise working Gateway is not resolved or not programmed | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `gateway.missing_class` | critical | the Gateway names a GatewayClass that does not exist — nothing will program it | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `gateway.not_accepted` | critical | the Gateway itself is not Accepted | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `gateway.not_programmed` | critical | the Gateway is Accepted but not Programmed: no data plane is carrying its traffic | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `health.category` | critical, warning, info | one scorecard line: how this category answered — healthy, degraded, or unavailable. The scorecard always answers, so healthy is explicit rather than silent; the line carries the worst severity found inside the category | [`health`](/k8s-lookout/reference/health/) | | `inventory.object` | info | one object in scope, rendered as kubectl’s default columns for its kind — an aggregated `kubectl get`, so every row is emitted, healthy or not | [`triage list`](/k8s-lookout/reference/triage-list/) | | `ipspace.range` | critical, warning, info | a pod/service/node range is at 80% of its CIDR or worse; critical from 95%, info for a range the cloud APIs cannot rate and for an —all row below the line | [`cloud ipspace`](/k8s-lookout/reference/cloud-ipspace/), [`scan`](/k8s-lookout/reference/scan/) | | `job.failed` | warning | a Job’s Failed condition is set | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `log.fetch_error` | warning | a container’s log stream could not be read, so its lines are missing from the distillation | [`bundle`](/k8s-lookout/reference/bundle/), [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `log.overflow` | info | the low-count tail —max-templates dropped, counted rather than discarded silently (no coverage lies) | [`bundle`](/k8s-lookout/reference/bundle/), [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `log.probe_noise` | info | health/readiness probe request lines stripped before distillation, counted so the removal is visible | [`bundle`](/k8s-lookout/reference/bundle/), [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `log.stacktrace` | critical, warning, info | a template that is a Go panic, Java exception, or Python traceback, with its innermost frames | [`bundle`](/k8s-lookout/reference/bundle/), [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `log.template` | critical, warning, info | one distilled template and how many lines collapsed into it; severity is the guessed level — critical at fatal, warning for error-ish, info otherwise | [`bundle`](/k8s-lookout/reference/bundle/), [`triage logs`](/k8s-lookout/reference/triage-logs/) | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `node.notready` | critical | the node’s Ready condition is not True | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `orphan.disk` | warning | a GCE disk has been unattached for at least —min-age and is still billing | [`cloud orphans`](/k8s-lookout/reference/cloud-orphans/), [`scan`](/k8s-lookout/reference/scan/) | | `orphan.lb` | warning | a forwarding rule or load balancer routes to zero endpoints and is still billing | [`cloud orphans`](/k8s-lookout/reference/cloud-orphans/), [`scan`](/k8s-lookout/reference/scan/) | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `perf.apf_rejects` | critical, warning | APF is shedding load: the apiserver is returning 429s at a priority level | [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.apf_saturation` | critical, warning | an API Priority and Fairness level is holding a sustained queue — warning from 10 queued, critical from 100 | [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.apiserver_p99` | critical, warning | apiserver request latency p99 crossed the pack threshold for a verb/resource — warning from 1s, critical from 4s | [`health`](/k8s-lookout/reference/health/), [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.etcd_db_size` | critical, warning | the etcd database is approaching its quota — warning from 4 GiB, critical from 5.5 GiB | [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.etcd_fsync` | critical, warning | etcd WAL fsync p99 crossed the pack threshold — warning from 10ms, critical from 100ms | [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.pack_unavailable` | warning | a metric the requested pack needs is not in the metrics workspace, so part of the pack could not run; the rest still did (no coverage lies) | [`health`](/k8s-lookout/reference/health/), [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `perf.startup_p95` | critical, warning | pod first-ready p95 crossed the pack threshold — warning from 60s, critical from 300s | [`perf probe`](/k8s-lookout/reference/perf-probe/), [`scan`](/k8s-lookout/reference/scan/) | | `pod.crashloop` | critical | a container is crash looping | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.failed` | warning | the pod reached phase Failed | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.imagepull` | critical | a container cannot pull its image | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.restarts` | warning | a container has restarted at least —restarts times | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `probe.dns` | critical, warning, info | the result of one DNS resolution: info when it resolved, warning on timeout, critical otherwise — a probe result is always emitted, success included, because the answer to “can this be reached” is the point of the command | [`net probe`](/k8s-lookout/reference/net-probe/) | | `probe.http` | critical, warning, info | the result of one HTTP GET (redirects reported, not followed): info on success, warning on timeout or 4xx, critical otherwise | [`net probe`](/k8s-lookout/reference/net-probe/) | | `probe.tcp` | critical, warning, info | the result of one TCP connect: info when it connected, warning on timeout, critical otherwise | [`net probe`](/k8s-lookout/reference/net-probe/) | | `pvc.lost` | critical | a PersistentVolumeClaim’s bound volume is lost | [`health`](/k8s-lookout/reference/health/) | | `pvc.pending` | warning | a PersistentVolumeClaim is not bound; pods mounting it cannot start | [`health`](/k8s-lookout/reference/health/) | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `quota.pressure` | critical, warning, info | a cloud quota is at or above —quota-warn percent of its limit; critical from 95%, info for an —all row below the line | [`cloud quota`](/k8s-lookout/reference/cloud-quota/), [`scan`](/k8s-lookout/reference/scan/) | | `radius.missing` | warning | a neighbor the graph references but never observed, in a kind the snapshot does watch: the reference is dangling | [`bundle`](/k8s-lookout/reference/bundle/), [`triage radius`](/k8s-lookout/reference/triage-radius/) | | `radius.neighbor` | info | one object in the target’s neighborhood, with its direction, relation, and hop distance — an enumeration of impact, not a defect | [`bundle`](/k8s-lookout/reference/bundle/), [`triage radius`](/k8s-lookout/reference/triage-radius/) | | `route.backend_port` | critical | the route’s backendRef Service exists but does not expose the named port | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `route.missing_backend` | critical | the route’s backendRef Service does not exist | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `route.missing_parent` | critical | the route’s parentRef names a Gateway that does not exist | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `route.not_accepted` | critical | the Gateway refused the route’s attachment (listener, hostname, or namespace policy) | [`scan`](/k8s-lookout/reference/scan/), [`state gateway`](/k8s-lookout/reference/state-gateway/) | | `scan.check_failed` | warning | a stage errored; the scan continued without it, so this run saw less than a whole cluster — unless EVERY stage failed and none read anything, which is a runtime error (exit 1) rather than a scan | [`scan`](/k8s-lookout/reference/scan/) | | `scan.check_skipped` | info | a stage declined this invocation because a zero-argument scan cannot supply something it needs — the coverage claim is smaller than it looks | [`scan`](/k8s-lookout/reference/scan/) | | `scan.incomplete` | warning | the —timeout expired with stages still to run; not\_run names them | [`scan`](/k8s-lookout/reference/scan/) | | `spec.condition` | warning | a status condition of the target that is not in its nominal state | [`bundle`](/k8s-lookout/reference/bundle/), [`triage spec`](/k8s-lookout/reference/triage-spec/) | | `spec.container` | info | one container of the target: image, resources, ports, probes, env (one per container) | [`bundle`](/k8s-lookout/reference/bundle/), [`triage spec`](/k8s-lookout/reference/triage-spec/) | | `spec.resource` | info | the object itself: metadata, owner, and the kind-specific highlights (one per target) | [`bundle`](/k8s-lookout/reference/bundle/), [`triage spec`](/k8s-lookout/reference/triage-spec/) | | `stockout.zone` | warning | the cloud had no capacity for a machine type in this zone during the window — the reason a scale-up failed and pods stayed Pending | [`cloud stockout`](/k8s-lookout/reference/cloud-stockout/), [`scan`](/k8s-lookout/reference/scan/) | | `storage.missing_class` | critical | the claim names a StorageClass that does not exist — it will stay Pending forever | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `storage.multiple_defaults` | warning | more than one StorageClass is annotated as the cluster default; which one wins is not defined | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `storage.no_default_class` | critical | the claim names no class and the cluster has no default StorageClass | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `storage.no_provisioner` | warning | the claim’s class is static-only (kubernetes.io/no-provisioner) and no matching PV is available | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `storage.pv_failed` | warning | a PersistentVolume is Failed: its reclaim did not complete, so the backing disk stays allocated and the volume cannot be reused | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `storage.pv_released` | info | a PersistentVolume is Released — retained on purpose, but its capacity is unusable until spec.claimRef is cleared | [`scan`](/k8s-lookout/reference/scan/), [`state storage`](/k8s-lookout/reference/state-storage/) | | `top.node` | critical, warning, info | a node’s allocatable is close to committed — critical near the limit, info for an —all row below the threshold | [`triage top`](/k8s-lookout/reference/triage-top/) | | `top.saturation` | critical, warning, info | a container’s usage is close to its limit — critical near the limit, info for an —all row below the threshold | [`triage top`](/k8s-lookout/reference/triage-top/) | | `top.unlimited` | info | how many containers in scope set no cpu/memory limit, and are therefore invisible to saturation analysis | [`triage top`](/k8s-lookout/reference/triage-top/) | | `top.unlimited_container` | info | one container that sets no cpu/memory limit (—show-unlimited) | [`triage top`](/k8s-lookout/reference/triage-top/) | | `top.unrequested` | info | how many containers in scope set no cpu/memory request, so the scheduler bin-packs them as zero | [`triage top`](/k8s-lookout/reference/triage-top/) | | `top.unrequested_container` | info | one container that sets no cpu/memory request (—show-unrequested) | [`triage top`](/k8s-lookout/reference/triage-top/) | | `triage.status` | info | the triage record for an incident subject as it now stands — state, root-cause hypothesis, action, and who wrote it; a receipt, not a defect | [`triage status`](/k8s-lookout/reference/triage-status/) | | `volume.attach_error` | critical, warning | the attach or detach is failing; critical once it has been failing long enough to be stuck rather than slow | [`scan`](/k8s-lookout/reference/scan/), [`state volumes`](/k8s-lookout/reference/state-volumes/) | | `volume.multi_attach` | critical | an RWO claim is wanted by pods on more than one node — the second pod never starts | [`scan`](/k8s-lookout/reference/scan/), [`state volumes`](/k8s-lookout/reference/state-volumes/) | | `volume.orphaned_attachment` | info | a VolumeAttachment survives its PV or its node | [`scan`](/k8s-lookout/reference/scan/), [`state volumes`](/k8s-lookout/reference/state-volumes/) | | `volume.zone_conflict` | critical | the PV is locked to a zone the pod’s node is not in | [`scan`](/k8s-lookout/reference/scan/), [`state volumes`](/k8s-lookout/reference/state-volumes/) | | `webhook.ca_expired` | critical | the webhook’s caBundle has expired: the API server cannot verify it | [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `webhook.ca_expiring` | warning | the webhook’s caBundle expires within —cert-warn | [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `webhook.dead_backend` | warning | the webhook’s service backend is missing, has no ready endpoints, or does not serve the named port | [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `webhook.failing_closed` | critical | the webhook has no working backend and failurePolicy=Fail: every gated write is rejected cluster-wide | [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `webhook.slow_risk` | info | the webhook’s timeout is long enough to slow every gated write if the backend degrades | [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`state webhooks`](/k8s-lookout/reference/state-webhooks/) | | `wi.gsa_missing` | critical | the annotated Google service account does not exist — every GCP call from these pods fails | [`scan`](/k8s-lookout/reference/scan/), [`state wi`](/k8s-lookout/reference/state-wi/) | | `wi.unannotated_use` | info | a pod sets GOOGLE\_APPLICATION\_CREDENTIALS but its ServiceAccount carries no Workload Identity annotation | [`scan`](/k8s-lookout/reference/scan/), [`state wi`](/k8s-lookout/reference/state-wi/) | | `wi.unbound` | critical | the KSA annotates a GSA but the roles/iam.workloadIdentityUser binding is missing or malformed | [`scan`](/k8s-lookout/reference/scan/), [`state wi`](/k8s-lookout/reference/state-wi/) | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | [`bundle`](/k8s-lookout/reference/bundle/), [`health`](/k8s-lookout/reference/health/), [`scan`](/k8s-lookout/reference/scan/), [`triage delta`](/k8s-lookout/reference/triage-delta/) | # lookout findings ack > Suppress one finding for a window after an operator has taken it — later diffs report it `suppressed` instead of re-raising it, and it comes back on its own when the window expires; the "I'm on this, stop paging me until lunch" surface. Suppress one finding for a window after an operator has taken it — later diffs report it `suppressed` instead of re-raising it, and it comes back on its own when the window expires; the “I’m on this, stop paging me until lunch” surface. MCP tool: `k8s_findings_ack` ## Usage [Section titled “Usage”](#usage) ```sh lookout findings ack [flags] ``` `` — the subject key from a `findings diff` record’s subject\_key field: \/\/\/\/\. Must name a currently-open subject — a resolved subject has no row to ack ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--store` | string | — | path to the sentinel’s SQLite store (its —store file). Required: finding state lives in the sentinel’s —store SQLite file; a diff with nowhere to persist would report everything new on every run | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | | `--for` | duration | `4h0m0s` | how long to suppress the subject. The window is absolute from now and always expires; to end one early use —clear | | `--by` | string | — | who took the ack, recorded verbatim on the row and echoed in later `suppressed` records. Lookout does not authenticate this: the caller (mast) owns identity and the audit trail, `lookout` owns the state | | `--clear` | bool | — | end the ack window now instead of opening one; the subject goes back to being classified normally on the next diff | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | -------------- | -------- | ------------------------------------------------------------------------------------------- | | `findings.ack` | info | the receipt for the ack this call took or cleared — what was acked, by whom, and until when | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `subject_key` | the acked subject’s key, as stored | | `ack_until` | when the window expires, RFC 3339; absent after —clear | | `ack_by` | who took the ack, as given by —by | | `first_seen` | when the acked subject was first observed, RFC 3339 — the “broken since” timestamp, so an operator can see what they are taking | | `last_seen` | when the acked subject was last observed, RFC 3339 | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout findings ack prod-east/prod/Pod/payment-backend/CrashLoopBackOff --store=/var/lib/lookout/lookout.db --for=4h --by=gari lookout findings ack prod-east/prod/Pod/payment-backend/CrashLoopBackOff --store=/var/lib/lookout/lookout.db --clear ``` # lookout findings diff > Diff a health report against the previous run and report what CHANGED — new, ongoing, escalated, resolved, suppressed — instead of re-listing every open finding; the command that makes a scheduled scan produce a digest an operator will keep reading. Diff a health report against the previous run and report what CHANGED — new, ongoing, escalated, resolved, suppressed — instead of re-listing every open finding; the command that makes a scheduled scan produce a digest an operator will keep reading. MCP tool: `k8s_findings_diff` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout findings diff [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--report` | string | `-` | the finding report to classify: `-` reads stdin (the usual `lookout health \| lookout findings diff --report -`), or a file path. Either wire format is accepted, detected per line, so the upstream command does not need —format=json | | `--store` | string | — | path to the sentinel’s SQLite store (its —store file), where the previous run’s state lives. Required: finding state lives in the sentinel’s —store SQLite file; a diff with nowhere to persist would report everything new on every run | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | | `--cluster` | string | — | cluster label to bind these findings to; becomes the first segment of every subject key. Give the same value on every run for a cluster — changing it makes every subject look new. This labels rows INSIDE the store; —store-cluster picks the store FILE. Left empty with —store-cluster set, it defaults to that name | | `--transitions` | string | — | emit only these transition classes, comma-separated: new\|ongoing\|escalated\|resolved\|suppressed (empty = all). `--transitions=new,escalated,resolved` is the digest view: everything that changed, nothing that didn’t | | `--dry-run` | bool | — | classify and print, but do not advance the stored state. Use to preview a report without consuming it — a normal run is not repeatable, because after it the second run’s findings are all `ongoing` | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `findings.transition` | critical, warning, info | a finding subject changed state since the previous run (new\|ongoing\|escalated\|resolved\|suppressed); the severity is the underlying finding’s current one, not a judgment about the transition | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `transition` | how this subject changed since the previous run: new\|ongoing\|escalated\|resolved\|suppressed | | `subject_key` | the normalized instance-grain key this diff tracks: \/\/\/\/\. Distinct from the envelope’s class-level fingerprint; pass it to `lookout findings ack` | | `prev_severity` | the severity recorded at the previous run; absent on `new`. Compare with the envelope’s severity to see a de-escalation, which stays classified `ongoing` | | `first_seen` | when this subject was first observed, RFC 3339 — carried across runs, so it is the “broken since” timestamp, not this run’s clock | | `last_seen` | when this subject was last observed, RFC 3339 | | `ack_until` | expiry of the operator ack window on a `suppressed` subject, RFC 3339 | | `ack_by` | who took the ack, as forwarded by the caller | | `skipped_no_subject` | summary line only, present when non-zero: report records that named no object and were therefore not diffed — `health.category` scorecard rows, `scan.check_skipped`, and the other narration kinds. A diff is over subjects; those lines are not subjects, and diffing them would collapse them all into one empty key | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout health --store=/var/lib/lookout/lookout.db | lookout findings diff --report=- --store=/var/lib/lookout/lookout.db --cluster=prod-east lookout health | lookout findings diff --report=- --store=/var/lib/lookout/lookout.db --cluster=prod-east --transitions=new,escalated,resolved lookout findings diff --report=/tmp/scan.logfmt --store=/var/lib/lookout/lookout.db --cluster=prod-east --dry-run ``` # lookout health > "Any issues with this cluster?" in one call: a ten-category scorecard (control-plane, nodes, crash loops, pending, rollouts, storage, add-ons, quotas, certs, webhooks) — every category answers healthy|degraded|unavailable, degraded ones with details. With --store, findings merge the sentinel's open triage-status records: a scan mid-incident reports the diagnosis and the agent's severity judgment, not a fresh unknown. “Any issues with this cluster?” in one call: a ten-category scorecard (control-plane, nodes, crash loops, pending, rollouts, storage, add-ons, quotas, certs, webhooks) — every category answers healthy|degraded|unavailable, degraded ones with details. With —store, findings merge the sentinel’s open triage-status records: a scan mid-incident reports the diagnosis and the agent’s severity judgment, not a fresh unknown. MCP tool: `k8s_cluster_health` ## Usage [Section titled “Usage”](#usage) ```sh lookout health [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--top` | int | `3` | how many findings to name inline on a degraded category’s scorecard line | | `--cert-warn` | duration | `720h` | report TLS certificates expiring within this window (certs category) | | `--store` | string | — | path to a sentinel’s SQLite store (its —store file); merges open triage-status records so findings carry triage\_\* fields and severity reflects the agent’s override | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `health.category` | critical, warning, info | one scorecard line: how this category answered — healthy, degraded, or unavailable. The scorecard always answers, so healthy is explicit rather than silent; the line carries the worst severity found inside the category | | `pvc.pending` | warning | a PersistentVolumeClaim is not bound; pods mounting it cannot start | | `pvc.lost` | critical | a PersistentVolumeClaim’s bound volume is lost | | `cert.expired` | critical | a TLS secret’s certificate has expired | | `cert.expiring` | warning | a TLS secret’s certificate expires within —cert-warn | | `cert.invalid` | warning | a TLS secret’s tls.crt does not contain a parseable X.509 certificate | | `pod.crashloop` | critical | a container is crash looping | | `pod.imagepull` | critical | a container cannot pull its image | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | | `pod.restarts` | warning | a container has restarted at least —restarts times | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | | `pod.failed` | warning | the pod reached phase Failed | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | | `job.failed` | warning | a Job’s Failed condition is set | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | | `node.notready` | critical | the node’s Ready condition is not True | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | | `webhook.failing_closed` | critical | the webhook has no working backend and failurePolicy=Fail: every gated write is rejected cluster-wide | | `webhook.dead_backend` | warning | the webhook’s service backend is missing, has no ready endpoints, or does not serve the named port | | `webhook.slow_risk` | info | the webhook’s timeout is long enough to slow every gated write if the backend degrades | | `webhook.ca_expired` | critical | the webhook’s caBundle has expired: the API server cannot verify it | | `webhook.ca_expiring` | warning | the webhook’s caBundle expires within —cert-warn | | `perf.apiserver_p99` | critical, warning | apiserver request latency p99 crossed the pack threshold for a verb/resource — warning from 1s, critical from 4s | | `perf.pack_unavailable` | warning | a metric the requested pack needs is not in the metrics workspace, so part of the pack could not run; the rest still did (no coverage lies) | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `category` | scorecard category the finding belongs to (on health.category: which category this line scores) | | `status` | category status: healthy\|degraded\|unavailable (the scorecard always answers — healthy is explicit) | | `total` | findings in a degraded category | | `top` | worst findings of a degraded category inline, as kind\[ namespace/name]; capped by —top | | `subject` | TLS certificate subject (CN when set); never key material | | `not_after` | TLS certificate NotAfter, RFC 3339 | | `days_left` | whole days until NotAfter (negative = expired) | | `phase` | PersistentVolumeClaim phase on storage findings (Pending or Lost) | | `webhook` | admission webhook as \/\ | | `service` | service backend a webhook points at, as \/\ | | `backend` | why a webhook backend is dead: service missing, no ready endpoints, or port \

not on service | | `gates` | namespaces a webhook gates, from namespaceSelector: all namespaces, or \/\ namespaces with up to 5 names | | `rules` | compact operations/resources summary of a webhook’s rules, e.g. “CREATE,UPDATE pods,deployments.apps” | | `object_selector` | a webhook’s objectSelector, when one is set | | `timeout` | webhook timeoutSeconds as \s (nil defaults to the API’s 10s) | | `triage_status` | triage state from the matched record (investigating\|triaged\|actioned\|escalated) — present only with —store on merged findings | | `triage_root_cause` | the incident agent’s root-cause hypothesis, from the matched triage-status record | | `triage_action` | the incident agent’s paper trail (PRs opened, escalations), from the matched triage-status record | | `triage_session` | incident session that wrote the matched triage-status record | | `triage_age` | how long ago the matched triage-status record was last updated | | `container` | container the finding is about (init containers prefixed init:) | | `image` | image reference that failed to pull | | `restarts` | container restart count | | `exit_code` | exit code of the container’s last termination | | `last_state` | reason of the container’s last termination (e.g. OOMKilled) | | `age` | how long the abnormal state has persisted | | `desired` | desired replica/scheduled count from spec | | `ready` | ready count from status | | `updated` | updated-to-current-revision count from status | | `available` | available count from status | | `failed` | failed pod count of a Job | | `schedule` | a CronJob’s spec.schedule | | `expected` | the activation a CronJob should have run and did not | | `missed_runs` | activations missed since the anchor; ≥N when the walk was capped | | `anchor` | what the missed count was measured from: last\_schedule or creation | | `time_zone` | a CronJob’s spec.timeZone, when set | | `last_schedule` | a CronJob’s status.lastScheduleTime, or never | | `active_jobs` | Jobs a CronJob still has running | | `condition` | node condition type that is abnormal | | `taint` | taint key indicating reclaim/drain | | `pods` | pods affected (behind a cordoned node or a PDB) | | `healthy` | currently healthy pods behind a PDB | | `required` | pods the PDB requires healthy | | `addon` | system add-on role: dns, proxy, cni, csi, metrics, connectivity | | `resource` | ResourceQuota resource name at or near its limit | | `used` | quota usage from status | | `hard` | quota hard limit from status | | `pct` | quota usage as percent of the hard limit | | `pack` | the pack this finding belongs to; also the summary-line note naming the pack that ran | | `metric` | the backend-neutral metric the query measured (pack\_unavailable: the absent metric) | | `verb` | apiserver request verb for this series (apiserver pack) | | `observed` | the worst (maximum) aligned value in the window, in the query’s unit — the breach basis | | `latest` | the newest aligned value in the window | | `threshold` | the crossed threshold: the critical one when severity=critical, else the warning one | | `window` | the lookback the series cover (—since, or the pack default); also a summary-line note | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout health lookout health --format=json --top=5 lookout health --namespace=prod ``` # Prometheus metrics > Every metric the sentinel serves on --metrics-addr, derived from the registered collectors. `lookout watch --metrics-addr=host:port` serves Prometheus metrics on `/metrics` (plus `/healthz` and `/readyz`). Every metric carries the `lookout_` prefix. Generation note (the documented choice): metric names and help strings are derived from the live collectors (`internal/watch.MetricsInventory`); the type and label columns are stamped per collector in that inventory because the Prometheus client does not expose them before first observation. A presence-check test (`TestMetricsInventoryComplete`) fails when a collector is added without an inventory row. The `lookout_leeway_*` block is the exception: those instruments are declared on the OpenTelemetry metric API and bridged into the same registry, so they have no collector to describe. Their rows are written out in `pkg/sources/topologydrift.MetricDocs` and pinned there against the real exporter, names, types and labels included. Rows marked **opt-in** are absent from a default scrape until a flag turns them on; see the [`lookout watch` flag table](/k8s-lookout/reference/watch/). | Metric | Type | Labels | Meaning | | ------------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lookout_events_seen_total` | counter | `reason`, `namespace` | Total k8s events observed by the informer, before filter. | | `lookout_events_injected_total` | counter | `reason`, `namespace` | Total events that survived filter + dedup and were POSTed to the daemon. | | `lookout_events_deduped_total` | counter | `reason`, `namespace` | Total events suppressed by the rolling-window dedup cache. | | `lookout_events_filtered_total` | counter | `gate` | Total signals rejected by the engine filter before dedup, by the rule that rejected them (reason\_not\_allowed\|namespace\_excluded\|namespace\_not\_allowed\|unhealthy\_debounce\|crashloop\_debounce\|imagepull\_transient\_debounce). The leading-edge debounces deliberately swallow events; without this counter a gate tuned too tight is indistinguishable from a broken watcher. | | `lookout_inject_errors_total` | counter | `reason`, `http_code` | Total payload deliveries (or incident opens) against the configured sink that returned a non-2xx response or transport error. Counts sink operations regardless of —sink. | | `lookout_inject_shrinks_total` | counter | `shed` | Total payloads shrunk to fit —inject-max-bytes before delivery (issue #198), by what was shed (enrichment\|message\|member\_fingerprints\|watchboard\_entries). Identity is never dropped; a counted incident still routed. A rising enrichment count means —enrich-cap is set too high for the sink’s inject ceiling; member\_fingerprints means a storm outgrew the ceiling and its member list was cut to the earliest arrivals (#336); watchboard\_entries means —watchboard-batch is too large for it and the oldest warnings in the digest were dropped (#337). | | `lookout_session_creates_total` | counter | `outcome` | Total incident-open attempts against the configured sink (core-agent: POST /sessions; webhook: POST /incidents), labeled by outcome. | | `lookout_active_incidents` | gauge | — | Current number of incidents in the sidecar’s dedup cache. | | `lookout_recoveries_observed_total` | counter | `resolution` | Total kind=resolved outcome records emitted, by resolution (recovered\|object\_deleted). | | `lookout_recoveries_reverted_total` | counter | — | Total kind=resolved.reverted records emitted: symptom recurred within the revert window after a resolve. | | `lookout_recovery_tracking` | gauge | — | Current number of bound incidents the recovery tracker is watching for clearance. | | `lookout_recovery_drops_total` | counter | `cause` | Total resolved signals dropped instead of injected, by cause (unknown\_session: binding lost, e.g. restart without —dedup-persist). | | `lookout_storms_formed_total` | counter | — | Total kind=storm incidents opened by blast-radius correlation. | | `lookout_storms_resolved_total` | counter | — | Total storms resolved because every member incident cleared. | | `lookout_storms_active` | gauge | — | Currently open (unresolved) storms. | | `lookout_storm_members_total` | counter | `kind` | Total incidents folded into storms, by how they joined (suppressed: per-incident session never opened; superseded: pre-storm session pointed at the storm; attached: late arrival). | | `lookout_storm_updates_total` | counter | — | Total kind=storm.update size refreshes injected into storm sessions (membership grew past a reporting threshold: doubling or +10, max one per minute). | | `lookout_watchboard_entries_total` | counter | `kind` | Total warning-class signals buffered onto the shared watchboard digest, by signal kind. | | `lookout_watchboard_digests_total` | counter | — | Total kind=watchboard.digest injects flushed to the watchboard session. | | `lookout_watchboard_rotations_total` | counter | — | Total size-based watchboard session rotations: a fresh session opened after —watchboard-rotate digest injects. | | `lookout_watchboard_buffered` | gauge | — | Warning-class signals currently buffered awaiting the next watchboard digest flush. | | `lookout_watchboard_reattached_total` | counter | `kind` | Total buffered warnings delivered as a kind=family.member followup into an existing per-incident session sharing their blast-radius ancestor, instead of a digest entry (issue #220), by signal kind. | | `lookout_info_dropped_total` | counter | `kind` | Total info-severity signals routed to the stored-only class (no inject anywhere), by signal kind. With —store set they are persisted; without it they are dropped after counting. | | `lookout_findings_total` | counter | `kind`, `severity` | Total distinct findings the sentinel detected, by signal kind and severity — counted once per fresh dedup window, before severity routing, so stored-only and watchboard-batched findings count alongside injected ones. Always carries the cluster label. Namespace is deliberately absent (cardinality); rate() over this is the cluster’s health trend. | | `lookout_store_records_total` | counter | `route` | Total occurrences committed to the store, by routing outcome (injected\|suppressed\|storm\|storm-member\|watchboard\|info-stored\|resolved). | | `lookout_store_write_drops_total` | counter | `cause` | Total occurrence records LOST by the store’s write path, by cause (buffer\_full: the non-blocking writer buffer overflowed; write\_error: a batch insert failed). The store is telemetry, not a system of record — drops are loud, never blocking. | | `lookout_store_pruned_rows_total` | counter | `cause` | Total occurrence rows deleted by the prune loop, by cause (ttl: older than —store-ttl; size: oldest-first eviction after —store-max-mb was exceeded). | | `lookout_enrichments_total` | counter | `outcome` | Total enrichment runs, by outcome (ok: every stage succeeded; partial: some stage failed, the rest attached; failed: no section computed — the inject still fires, carrying enrichment\_error trailers; skipped: nothing to build, the incident object is not a workload and names none, so the inject fires with no bundle at all rather than one describing the resolver). | | `lookout_enrichment_bytes` | histogram | — | Size of the attached enrichment bundle in bytes, after the —enrich-cap prefix cut (the telemetry that will inform the fixed-vs-model-aware cap revisit). | | `lookout_enrichment_truncated_total` | counter | — | Total enrichment bundles the —enrich-cap byte budget truncated at a section boundary (dropped sections become overflow trailers naming the follow-up command). | | `lookout_enrichment_failures_total` | counter | `stage` | Total enrichment stage failures, by stage (resolve\|spec\|delta\|edges\|radius\|logs). Failures never block the inject; they surface as enrichment\_error trailers in the attached bundle. | | `lookout_memory_facts_total` | counter | `class` | Total distilled facts written (upserts included) by the scheduled distiller pass, by fact class. | | `lookout_distill_errors_total` | counter | — | Total failed distiller passes. A failed pass loses freshness only — the next pass re-derives every fact from the occurrence window. | | `lookout_triage_overrides_total` | counter | `action` | Total severity-routing decisions refined by an open triage-status record, by action (downgraded: agent’s severity\_override lowered the class; upgraded: it raised it; escalated: status=escalated pinned critical). | | `lookout_triage_resolved_flips_total` | counter | — | Total triage-status records flipped to resolved by recovery injects (the automatic lifecycle — resolved records join the corpus). | | `lookout_triage_regressed_total` | counter | — | Total kind=triage.regressed evidence followups: a downgraded incident’s dedup-window count reached —triage-regress-factor times its count at downgrade time. Evidence only, never a re-page. | | `lookout_cross_source_followups_total` | counter | `source` | Total dedup-window duplicates injected as followups because their source family differs from the incident’s opening source (leading/reactive joins made session-visible), by joining source family. | | `lookout_sink_info` | gauge | `sink` | The configured agent sink (—sink), value fixed at 1 on the active label (core-agent\|webhook). ADDITIVE metric: the sink is process-level config, so it rides this info gauge instead of a new label on the operation counters — existing scrapes keep their exact series identities. | | `lookout_runner_up` | gauge | — | 1 while this cluster’s watch loop is running, 0 otherwise. Always carries the cluster label; in a multi-cluster process (issue #208) one series per watched cluster reports that runner’s liveness independently. | | `lookout_runner_restarts_total` | counter | — | Total in-process restarts of this cluster’s runner by the supervisor after it exited while the process stayed up (multi-cluster fate isolation, issue #208). Stays zero in the single-cluster default, where a runner exit ends the process and the kubelet owns restart. | | `lookout_runner_terminal` | gauge | `reason` | 1 when the supervisor has GIVEN UP on this cluster: the runner exited for a reason no retry can fix (access\_denied — the authorizer refused a required permission), so it is no longer being watched and no longer being restarted (issue #383). ADDITIVE metric rather than a label on lookout\_runner\_up, which keeps its exact series identity. The alert to write: a series at 1 means a cluster in the fleet is dark until someone changes a grant. Stays absent in the single-cluster default, where such an exit ends the process instead. | | `lookout_source_denied` | gauge | `source`, `resource`, `required` | 1 when a permission this source held at STARTUP is denied now, confirmed over consecutive SelfSubjectAccessReview sweeps (—access-recheck, issue #385); back to 0 when the grant returns. required=true means the source cannot run at all and this cluster’s runner is stopping for it; required=false is one degraded dimension on a source that keeps going. The alert to write: any series at 1 means the sentinel has lost coverage it used to have — the silence from that source no longer means the cluster is healthy. | | `lookout_cluster_resolve_errors_total` | counter | `cluster`, `cause` | Total clusters this process was told to watch and did not, by cluster and cause (issues #388, #410). credentials: the cluster could not be resolved into a client. duplicate\_name: two clusters in the fleet share this name, which is the only handle the sentinel has on a cluster, so neither is watched. The cluster is SKIPPED, not fatal, so the rest of the fleet still runs — which means a non-zero value is a coverage gap: nothing is watching that cluster and its silence means nothing. Counted at startup, so it moves on process restart and on nothing else. | | `lookout_leeway_subjects_tracked` | gauge | `subject_kind` | Subjects with a tracked distribution, by kind. | | `lookout_leeway_node_groups_discovered` | gauge | — | Distinct node groups resolved from the node-group label precedence list, before the tracking bound is applied. Read it against lookout\_leeway\_subjects\_tracked with subject\_kind=NodeGroup: the two agree on a healthy cluster, and a large number here with none tracked is leeway refusing a precedence list that resolved to something per-node. Raise —topology-max-node-groups only once you believe the count. | | `lookout_leeway_domains_unavailable` | gauge | `topology_key` | Topology domains with no usable node, by axis (leeway). Reported per configured axis and zero when nothing is out, so the series exists before the first outage. This is the live reading; lookout\_leeway\_alert\_state with subject\_kind=Domain is the same fact after its dwell, and that is what leeway.domain\_unavailable is emitted from. | | `lookout_leeway_domain_ready_nodes` | gauge | `topology_key`, `domain` | Usable nodes per topology domain. | | `lookout_leeway_domain_objects` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key`, `domain`, `state` | **Opt-in.** Objects counted per subject, topology domain and scheduling state. By default `state` is collapsed to two values: `active` for an object holding the domain’s capacity (running, or terminating and not yet gone) and `waiting` for one that is not (pending or unschedulable). Pass —topology-per-domain-collapse-states=false for the four raw states, which share no label value with the two, so a query written for one mode returns nothing at all under the other rather than an undercount. | | `lookout_leeway_domain_series_withheld` | gauge | `reason` | Subjects whose per-domain breakdown is absent or incomplete, by the cardinality control responsible. Read this before concluding a subject has no objects in a domain: the absence of a domain\_objects row means either that or that leeway declined to export it, and this is which. `gate` is below the drift floor and not alerting, which is the intended standing state and is normally most of the estate; `namespace` is the allow or deny list; `key_cap` counts subjects that kept some axes and lost the rest, and is the one to watch, because it is the reading that says a dashboard is missing an axis rather than a subject. Counted in subjects for all three, so they are summable. | | `lookout_leeway_domain_expected` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key`, `domain` | **Opt-in.** Objects apportioned to each topology domain, the expectation domain\_objects is scored against. | | `lookout_leeway_observed_skew` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key` | S, the difference between the fullest and emptiest eligible domain (leeway). | | `lookout_leeway_excess_skew` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key` | E, observed skew beyond what the arithmetic and the declared bound allow (leeway). Zero is the normal reading: a subject that cannot be spread any more evenly than it already is scores zero here however lopsided S looks, which is the whole reason drift is not alerted on S. | | `lookout_leeway_drift` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key` | ρ, the fraction of a subject’s objects that would have to move to meet its expectation (leeway). | | `lookout_leeway_max_domain_share` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key` | The share of a subject’s objects held by its fullest domain (leeway). | | `lookout_leeway_relocation_distance` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key` | R, the number of objects that would have to move to meet the expectation (leeway). | | `lookout_leeway_intent_info` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key`, `mode`, `source`, `confidence`, `weighting`, `max_skew` | Placement intent inferred for a subject on one topology axis, as labels on a constant 1 (leeway). Only subjects that expressed an intent are present: a workload with no spread constraint, anti-affinity or affinity has no row here, which is what makes the series count a property of the estate’s declarations rather than of its size. A learned baseline is deliberately absent for the same reason, even though it scores and routes like any other intent: it is not something anybody declared, and it is eventually present for every subject. See lookout\_leeway\_baselines for those. `source` is what the intent was read from and `confidence` how much that source is worth — `assumed` means `k8s-lookout` guessed a cluster default it could not read, and every finding derived from it rests on that guess. | | `lookout_leeway_alert_state` | gauge | `namespace`, `subject`, `subject_kind`, `topology_key`, `tier`, `phase` | Where one subject-axis sits in the dwell machine: 1 pending, 2 firing. Only subjects with an open episode are present — a subject that is not drifting has no row rather than a zero, which keeps the series bounded by how much trouble a cluster is in rather than by how large it is. A resolving subject (clear, but inside the resolve dwell) still reads 2, because its finding is still outstanding. | | `lookout_leeway_transient_subjects` | gauge | `topology_key`, `transient` | Subject-axes whose judgement suppressed or relaxed, by the transient state responsible. This is the series to look at before believing a quiet estate: a fleet-wide `domain-outage` row is leeway declining to page four hundred workloads about one dead zone, and a `cluster-warmup` row that never clears is a sentinel that never synced. Only the axes under a transient are present, so zero rows is the healthy reading. | | `lookout_leeway_last_event_timestamp_seconds` | gauge | `resource` | Unix time of the last informer event leeway processed, per resource. | | `lookout_leeway_evaluation_duration_seconds` | histogram | `subject_kind` | Time spent evaluating one coalesced subject. | | `lookout_leeway_counter_mismatch_total` | counter | `subject_kind` | Subjects whose incremental distribution disagreed with a rebuild from the pod cache and were repaired in place, by kind (leeway). The alert to write: threshold zero. The two numbers are two computations of the same thing, so any non-zero rate is a BUG IN K8S-LOOKOUT and not a cluster condition — every finding derived from the drifted counters until it is fixed is wrong in the same direction. The repair keeps the next hour’s numbers usable; it is not a fix. | | `lookout_leeway_baselines` | gauge | `state` | Subject-axes with a learned baseline, by state: `learning` is still inside a maturity gate, `mature` is old enough and sampled enough to be scored against, and `frozen` is being held still because its subject is firing or under a transient. The three overlap — a frozen baseline is also learning or mature — so they do not sum to the total. This is the series to read before turning Tier C baseline signals on: `mature` is how many subjects would start being judged against what they normally do rather than against an even split, and a `mature` that never climbs means something is resetting the baselines — check the reset outcome on baseline\_samples\_total. | | `lookout_leeway_baseline_samples_total` | counter | `outcome` | Baseline samples, by what the estimator did with each (leeway). A healthy estate is almost all `applied`. A sustained `reset` rate is the failure mode worth alerting on: the domain set is the invalidation fingerprint, so something churning it — a zone label appearing and disappearing on nodes — restarts every affected baseline’s history and keeps it permanently immature, silently. `empty` is subjects with nothing to learn from (scaled to zero, or every pod Pending) and `held` is a frozen set, and neither is a problem. | | `lookout_leeway_preference_pod_time_seconds_total` | counter | `provider`, `axis`, `spec_hash`, `rank` | Pod-seconds accumulated at each preference rank of a compute class (leeway). This is the series the whole source exists for, and it is time-weighted on purpose: a ninety-second burst of rank-3 pods during a scale-up and three weeks parked on a spot fallback look identical to a gauge. `rank` is the derived preference TIER, not the raw ccc\_priority\_index — on a class that sets priorityScore those are different numbers and can even run in opposite directions. Three ranks are not numbers at all: `unknown` is a node whose rank is not yet resolved, `unsatisfiable` one GKE could not fit to any rule, and `off-axis` one provisioned outside the priority list entirely. `spec_hash` changes when the class is edited, which starts a new series rather than averaging two different definitions of rank 1 together. | | `lookout_leeway_preference_rank_weighted_time_seconds_total` | counter | `provider`, `axis`, `spec_hash` | Sum of rank times pod-seconds, over tier ranks only (leeway). Divide by the pod\_time summed over the same ranks to get mean achieved rank: 0.0 is an estate getting its first choice, and a number that climbs is capacity quietly draining out from under it. The unknown, unsatisfiable and off-axis buckets are excluded from both halves, because a mean over a bucket whose rank is `unsatisfiable` is not a mean of anything. | | `lookout_leeway_preference_pods` | gauge | `provider`, `axis`, `rank` | Pods currently occupying each preference rank. The supporting gauge to pod\_time, not a substitute for it. | | `lookout_leeway_preference_nodes` | gauge | `provider`, `axis`, `rank`, `rule_index`, `rule`, `source` | Nodes currently resolved to each preference rank, by where the rank came from. `source=annotation` is GKE’s own ccc\_priority\_index, `inferred` is `k8s-lookout` matching the node’s attributes against the class’s priority rules, and `none` is neither answering. `rule_index` is the raw list position and `rule` renders it for humans — both identity, never ordering. | | `lookout_leeway_preference_transitions_total` | counter | `provider`, `axis`, `from_rank`, `to_rank`, `lateral` | Nodes moving between placements on one axis (leeway). Node-level, not pod-level: spike S1 established that a compute-class fallback provisions a NEW node and the ReplicaSet creates a new pod on it, so no pod ever changes rank. `lateral=true` is a move between two rules sharing a tier — capacity churn within a preference level, counted because it shows where the estate is thrashing, and excluded from every degradation signal because an equal-score alternative is not a demotion. A from\_rank of `unknown` is the node’s annotation arriving, which happens once in the first minute of every node’s life and is not a fallback. | | `lookout_leeway_preference_unmatched` | gauge | `provider`, `axis` | Nodes no priority rule matched. The alert to write: threshold zero. Every one of these is a node whose rank rests on GKE’s undocumented annotation with nothing checking it, and a sustained non-zero reading means the matcher has fallen behind the rules people are actually writing. Counted even when the annotation answered, because the coverage gap that matters is the one the working primary path is hiding. | | `lookout_leeway_preference_ambiguous` | gauge | `provider`, `axis` | Nodes more than one priority rule matched. Two rules admitting the same node is legal and usually harmless — GKE takes the first — but it means the inferred rank is a guess between them, so the cross-check on these nodes is worth less than it looks. | | `lookout_leeway_preference_disagreement` | gauge | `provider`, `axis` | Nodes where the annotation and the inferred rank disagree. The other threshold-zero alert, and the more serious of the two: the annotation wins, so a non-zero reading is not a wrong rank — it is evidence that the matcher’s model of the rules is wrong, and therefore that unmatched and ambiguous cannot be trusted either. | | `lookout_leeway_preference_out_of_range` | gauge | `provider`, `axis` | Nodes whose annotation names a priority index the class no longer has. This is a class that was edited under a running node: GKE stamped index 3 and somebody has since deleted a rule. The node keeps running; its rank is simply unknowable, and it reads as rank `unknown` until it is replaced. | | `lookout_leeway_preference_axis_invalid` | gauge | `provider`, `axis` | Nodes on a class whose priorities are partially scored. A class where some rules set priorityScore and others do not has no well-defined order — list position and score would rank it differently — so `k8s-lookout` declines to score it at all rather than pick one. Fix the class: score every rule or none. | | `lookout_leeway_preference_no_rule_matching` | gauge | `provider`, `axis` | Nodes GKE itself could not fit to any priority rule (ccc\_no\_rule\_matching). GKE’s own verdict, not ours, and a different statement from `unmatched`: this is the provisioner saying the node it made satisfies nothing the class asked for. Reads as rank `unsatisfiable`. | | `lookout_leeway_preference_off_axis` | gauge | `provider`, `axis` | Nodes GKE provisioned outside the priority list (ccc\_scale\_up\_anyway). The class’s whenUnsatisfiable let the autoscaler ignore the priorities rather than leave pods Pending, so these nodes are not at any rank — they are off the axis. Reads as rank `off-axis`, and their time is still reported under pod\_time, because a week spent off-axis is itself the finding. | | `lookout_leeway_preference_rank_pending` | gauge | `provider`, `axis` | Nodes carrying a compute class with no rank resolved yet. Normal and brief: GKE writes ccc\_priority\_index 33 to 44 seconds after a node registers, and inference has nothing to say about a node whose labels have not all landed either. A reading that does not decay to zero is the one to look at. | | `lookout_leeway_preference_unsupported_rules` | gauge | `provider`, `axis`, `field` | Priority rules the matcher could not evaluate, by the field responsible. The matcher fails closed: a rule naming a field pkg/leeway does not model is excluded from inference rather than matched on the fields it does understand, because a matcher that ignores what it cannot read attributes nodes to the wrong rule and corrupts the cross-check into agreement with nothing. A new field appearing here is a feature request with the field name already filled in. | | `lookout_leeway_preference_unreadable_class_nodes` | gauge | `provider`, `class` | Nodes labelled with a compute class this source has no decoded spec for. Either the class object has not synced, which is brief, or it failed to decode, which is not — see decode\_errors\_total. These nodes are ranked against nothing and their pods accrue no pod-seconds, so a sustained reading means the rank shares are being computed over less than the whole estate. | | `lookout_leeway_preference_decode_errors_total` | counter | `provider`, `class` | ComputeClass objects this source refused to read, by name. A refusal is deliberate: a spec whose priorities are not a list, or whose rules are not objects, would produce a plausible-looking axis that is wrong. The row survives the object’s deletion, because an error counter that vanishes with the broken object hides the edit that broke it. | | `lookout_leeway_preference_tracker_underflows_total` | counter | — | Unbalanced pod departures in the rank accounting. The alert to write: threshold zero. This counts a pod leaving a rank that had nobody at it, which is a BUG IN K8S-LOOKOUT and not a cluster condition — every rank share is skewed by an unknown amount until it is fixed. The count is clamped rather than allowed to run negative, so the pod-second counters stay monotonic; that keeps them readable, it does not make them right. | | `lookout_leeway_preference_axis_info` | gauge | `provider`, `axis`, `spec_hash`, `ordering`, `rules`, `tiers`, `scale_up`, `active_migration` | One preference axis, as labels on a constant 1. `ordering` is how the tiers were derived — `list-position` is bare list order, `priority-score` is the field of that name (higher is more preferred, the OPPOSITE direction to list position), and `invalid` is a partially-scored class. `rules` is how many priorities the class declares and `tiers` how many distinct preference levels they collapse to; `tiers` of 1 means every node on the class is rank 0 by construction and nothing here can degrade. `scale_up` is whenUnsatisfiable as DECLARED — `unset` is not the same evidence as GKE’s documented default, and gates a finding on the difference. | | `lookout_leeway_preference_alert_state` | gauge | `provider`, `axis`, `rank_rule`, `tier`, `phase` | Where one rule on one axis sits in the dwell machine: 1 pending, 2 firing. Episodes are per RULE, not per axis: a class can be running almost entirely on its last rank and also have a tier nobody has touched in a month, and resolving the first must not close the second. Only rules with an open episode are present — a healthy class has no rows rather than zeroes. A resolving episode still reads 2, because its finding is still outstanding. | | `lookout_leeway_preference_wedged_pods` | gauge | `provider`, `class` | Unscheduled pods that named a compute class by nodeSelector and did not get one. The only symptom a wedged class has: on a DoNotScaleUp class no priority can be satisfied and the autoscaler will not provision outside the list, so the pods stay Pending and nothing in the rank distribution moves at all. Non-zero on a scale-up-anyway class is a different story — those pods are waiting on something else, which is why the finding is gated on whenUnsatisfiable being DECLARED and this gauge is not. | | `lookout_otlp_exports_total` | counter | `outcome` | **Opt-in.** Total OTLP metric export attempts, by outcome (ok: the collector accepted the batch; failed: it did not, and the batch was dropped). A rising failed rate means the OTLP backend is stale; the scrape endpoint is unaffected. | | `lookout_otlp_points_exported_total` | counter | — | **Opt-in.** Total metric data points the OTLP exporter delivered to the collector. | | `lookout_otlp_points_dropped_total` | counter | — | **Opt-in.** Total metric data points discarded because their OTLP export failed or ran out of time. There is no retry queue by design, so a dead collector costs samples rather than memory; temporality is cumulative, so what is lost is the sample and not the counter value. | | `lookout_otlp_export_last_success_timestamp_seconds` | gauge | — | **Opt-in.** Unix timestamp of the last OTLP metric export the collector accepted; zero until the first one. Alert on its age rather than on the failure counter alone, which stays flat when the export path stops running at all. | | `lookout_otlp_export_inflight` | gauge | — | **Opt-in.** 1 while an OTLP metric export is in flight, 0 otherwise. A reading stuck at 1 across scrapes is an export wedged against its deadline. | # lookout net probe > Actively confirm a network hypothesis — resolve DNS names, open TCP connections, GET HTTP(S) URLs — from wherever lookout runs (in a pod = the in-cluster view); zero cluster mutation, no pods spawned. Actively confirm a network hypothesis — resolve DNS names, open TCP connections, GET HTTP(S) URLs — from wherever `lookout` runs (in a pod = the in-cluster view); zero cluster mutation, no pods spawned. MCP tool: `k8s_net_probe` ## Usage [Section titled “Usage”](#usage) ```sh lookout net probe [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | `--dns` | string | — | comma-separated names to resolve (e.g. api.prod.svc.cluster.local,db.example.com) | | `--tcp` | string | — | comma-separated host:port endpoints to connect to (e.g. api.prod.svc:8080,10.0.0.5:5432) | | `--http` | string | — | comma-separated http(s) URLs to GET; redirects are reported (3xx), not followed, and response bodies are never read into findings | | `--probe-timeout` | duration | `5s` | per-probe timeout; raise —timeout too when probing many slow targets (it caps the whole invocation) | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `probe.dns` | critical, warning, info | the result of one DNS resolution: info when it resolved, warning on timeout, critical otherwise — a probe result is always emitted, success included, because the answer to “can this be reached” is the point of the command | | `probe.tcp` | critical, warning, info | the result of one TCP connect: info when it connected, warning on timeout, critical otherwise | | `probe.http` | critical, warning, info | the result of one HTTP GET (redirects reported, not followed): info on success, warning on timeout or 4xx, critical otherwise | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------ | | `ips` | probe.dns: resolved addresses, sorted, comma-separated | | `latency` | how long the probe took: DNS resolution / TCP connect / full HTTP exchange | | `status` | probe.http: HTTP status code of the (unfollowed) response | | `content_length` | probe.http: Content-Length the server declared (body is discarded unread; omitted when unknown) | | `error_class` | failed probes: nxdomain\|timeout\|refused\|unreachable\|reset\|cert\|http\_4xx\|http\_5xx\|error | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout net probe --dns=api.prod.svc.cluster.local lookout net probe --tcp=db.prod.svc:5432 --probe-timeout=2s lookout net probe --http=https://api.prod.svc/healthz --format=json lookout net probe --dns=api.prod.svc --tcp=api.prod.svc:8080 --http=http://api.prod.svc:8080/readyz ``` # lookout perf probe > Control-plane and startup performance via metrics query packs: --pack=apiserver (p99 latency by verb/resource), apf (queue saturation + 429 rejects), etcd (WAL fsync p99 + DB size), startup (pod-first-ready p95 trend); apf/etcd need GKE control-plane metrics enabled — absence degrades to an explicit pack_unavailable finding. Control-plane and startup performance via metrics query packs: —pack=apiserver (p99 latency by verb/resource), apf (queue saturation + 429 rejects), etcd (WAL fsync p99 + DB size), startup (pod-first-ready p95 trend); apf/etcd need GKE control-plane metrics enabled — absence degrades to an explicit pack\_unavailable finding. MCP tool: `k8s_perf_probe` ## Usage [Section titled “Usage”](#usage) ```sh lookout perf probe [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------- | ------ | ------- | ----------------------------------------------------------------- | | `--pack` | string | — | which query pack to run (required): apiserver\|apf\|etcd\|startup | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `perf.apiserver_p99` | critical, warning | apiserver request latency p99 crossed the pack threshold for a verb/resource — warning from 1s, critical from 4s | | `perf.apf_saturation` | critical, warning | an API Priority and Fairness level is holding a sustained queue — warning from 10 queued, critical from 100 | | `perf.apf_rejects` | critical, warning | APF is shedding load: the apiserver is returning 429s at a priority level | | `perf.etcd_fsync` | critical, warning | etcd WAL fsync p99 crossed the pack threshold — warning from 10ms, critical from 100ms | | `perf.etcd_db_size` | critical, warning | the etcd database is approaching its quota — warning from 4 GiB, critical from 5.5 GiB | | `perf.startup_p95` | critical, warning | pod first-ready p95 crossed the pack threshold — warning from 60s, critical from 300s | | `perf.pack_unavailable` | warning | a metric the requested pack needs is not in the metrics workspace, so part of the pack could not run; the rest still did (no coverage lies) | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------- | | `pack` | the pack this finding belongs to; also the summary-line note naming the pack that ran | | `metric` | the backend-neutral metric the query measured (pack\_unavailable: the absent metric) | | `verb` | apiserver request verb for this series (apiserver pack) | | `resource` | apiserver request resource for this series (apiserver pack) | | `priority_level` | APF priority level for this series (apf pack) | | `code` | the HTTP status code the query matched (apf pack: 429) | | `observed` | the worst (maximum) aligned value in the window, in the query’s unit — the breach basis | | `latest` | the newest aligned value in the window | | `threshold` | the crossed threshold: the critical one when severity=critical, else the warning one | | `window` | the lookback the series cover (—since, or the pack default); also a summary-line note | | `trend` | startup pack: second-half vs first-half mean delta of the window, e.g. “+34%” — the p95 trend direction | | `capability` | cloud.unavailable: the provider capability this command needed (metrics) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the metrics backend could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout perf probe --pack=apiserver lookout perf probe --pack=apf lookout perf probe --pack=etcd --since=6h lookout perf probe --pack=startup lookout perf probe --pack=apiserver --format=json ``` # lookout scan > Start here when you know something is wrong but not what: one call runs every target-free incident check across the cluster — broken workloads, dead admission webhooks, stuck volumes and PVCs, rejected Gateway routes, config drift — then drills into the dependency edges of whatever it flagged. Needs no target; `--include=audit` adds the posture sweep. Start here when you know something is wrong but not what: one call runs every target-free incident check across the cluster — broken workloads, dead admission webhooks, stuck volumes and PVCs, rejected Gateway routes, config drift — then drills into the dependency edges of whatever it flagged. Needs no target; `--include=audit` adds the posture sweep. MCP tool: `k8s_scan` (MCP profile: `triage`, `audit`) ## Usage [Section titled “Usage”](#usage) ```sh lookout scan [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--include` | string | — | additionally run these opt-in groups: audit,cloud,perf. Comma-separated, ‘all’ for every one, ’-’ to subtract (all,-cloud). Left out by default because they answer a different question (audit = posture, no incident) or need a provider build (cloud, perf) | | `--max-drilldown` | int | `20` | cap the stage-2 dependency-edge drill-down at this many workloads, worst severity first (0 disables it); the number dropped is reported as truncated= in the summary | | `--cert-warn` | duration | `720h` | report TLS certificates expiring within this window (drill-down stage; same meaning as `state edges --cert-warn`) | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `1m0s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scan.check_skipped` | info | a stage declined this invocation because a zero-argument scan cannot supply something it needs — the coverage claim is smaller than it looks | | `scan.check_failed` | warning | a stage errored; the scan continued without it, so this run saw less than a whole cluster — unless EVERY stage failed and none read anything, which is a runtime error (exit 1) rather than a scan | | `scan.incomplete` | warning | the —timeout expired with stages still to run; not\_run names them | | `pod.crashloop` | critical | a container is crash looping | | `pod.imagepull` | critical | a container cannot pull its image | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | | `pod.restarts` | warning | a container has restarted at least —restarts times | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | | `pod.failed` | warning | the pod reached phase Failed | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | | `job.failed` | warning | a Job’s Failed condition is set | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | | `node.notready` | critical | the node’s Ready condition is not True | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | | `webhook.failing_closed` | critical | the webhook has no working backend and failurePolicy=Fail: every gated write is rejected cluster-wide | | `webhook.dead_backend` | warning | the webhook’s service backend is missing, has no ready endpoints, or does not serve the named port | | `webhook.slow_risk` | info | the webhook’s timeout is long enough to slow every gated write if the backend degrades | | `webhook.ca_expired` | critical | the webhook’s caBundle has expired: the API server cannot verify it | | `webhook.ca_expiring` | warning | the webhook’s caBundle expires within —cert-warn | | `volume.multi_attach` | critical | an RWO claim is wanted by pods on more than one node — the second pod never starts | | `volume.zone_conflict` | critical | the PV is locked to a zone the pod’s node is not in | | `volume.attach_error` | critical, warning | the attach or detach is failing; critical once it has been failing long enough to be stuck rather than slow | | `volume.orphaned_attachment` | info | a VolumeAttachment survives its PV or its node | | `storage.missing_class` | critical | the claim names a StorageClass that does not exist — it will stay Pending forever | | `storage.no_default_class` | critical | the claim names no class and the cluster has no default StorageClass | | `storage.no_provisioner` | warning | the claim’s class is static-only (kubernetes.io/no-provisioner) and no matching PV is available | | `storage.multiple_defaults` | warning | more than one StorageClass is annotated as the cluster default; which one wins is not defined | | `storage.pv_failed` | warning | a PersistentVolume is Failed: its reclaim did not complete, so the backing disk stays allocated and the volume cannot be reused | | `storage.pv_released` | info | a PersistentVolume is Released — retained on purpose, but its capacity is unusable until spec.claimRef is cleared | | `gateway.missing_class` | critical | the Gateway names a GatewayClass that does not exist — nothing will program it | | `gateway.class_not_accepted` | critical | the Gateway’s GatewayClass is not Accepted by its controller | | `gateway.not_accepted` | critical | the Gateway itself is not Accepted | | `gateway.not_programmed` | critical | the Gateway is Accepted but not Programmed: no data plane is carrying its traffic | | `gateway.listener_invalid` | warning | one listener of an otherwise working Gateway is not resolved or not programmed | | `route.missing_parent` | critical | the route’s parentRef names a Gateway that does not exist | | `route.not_accepted` | critical | the Gateway refused the route’s attachment (listener, hostname, or namespace policy) | | `route.missing_backend` | critical | the route’s backendRef Service does not exist | | `route.backend_port` | critical | the route’s backendRef Service exists but does not expose the named port | | `crd.unavailable` | info | the API group this check reads is not served by the cluster, so nothing was examined (no coverage lies) | | `wi.gsa_missing` | critical | the annotated Google service account does not exist — every GCP call from these pods fails | | `wi.unbound` | critical | the KSA annotates a GSA but the roles/iam.workloadIdentityUser binding is missing or malformed | | `wi.unannotated_use` | info | a pod sets GOOGLE\_APPLICATION\_CREDENTIALS but its ServiceAccount carries no Workload Identity annotation | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | | `drift.manual_edit` | critical, warning | a manager other than the GitOps controller owns spec fields on this object; critical when one of them is high blast radius (image, replicas, env) | | `audit.workload_identity_off` | warning | Workload Identity is off cluster-wide, or a node pool bypasses it — pods authenticate to the cloud as the node | | `audit.legacy_metadata` | warning | a node pool still serves the pre-v1 instance-metadata endpoints, which any pod can read | | `audit.public_control_plane` | warning, info | the control-plane endpoint is reachable from the internet; info when authorized networks narrow it | | `audit.exemption_expired` | warning | an exemption entry has lapsed: the findings it used to annotate are being reported unqualified again | | `audit.exemption_expiring` | info | an exemption entry lapses within —within — renew it or let it go deliberately | | `audit.privileged_container` | warning | a container runs privileged or holds a node-root capability (ALL, SYS\_ADMIN): a container escape is a node compromise | | `audit.host_namespace` | warning | the pod shares the node’s network, PID, or IPC namespace | | `audit.hostpath_mount` | warning, info | the pod mounts a host path; warning when it is writable, info when read-only | | `audit.default_sa_automount` | warning | the pod runs as the namespace’s default ServiceAccount with its token automounted, and something in the pod can use it | | `audit.podsecurity_gaps` | warning | the namespace enforces no Pod Security Admission level, so none of the above is prevented | | `audit.netpol_missing` | warning, info | nothing restricts this direction for the subject — a namespace with no policy at all, or a workload the covering policies’ selectors miss; info for the egress direction, where no policy is a defensible default | | `audit.version_behind` | warning, info | the control plane or a node pool is behind what the provider publishes, or a node pool has skewed from the control plane; info while the gap is still within the supported skew | | `audit.upgrade_unmanaged` | warning | nothing will close that gap on its own: no release channel, or node auto-upgrade/auto-repair off | | `audit.upgrade_blocked` | warning, info | an active maintenance exclusion, or a node image on the removed Docker runtime, will stop the upgrade when it comes | | `audit.upgrade_unattended` | info | upgrades will happen with nobody watching: no maintenance window, or no upgrade notifications | | `audit.no_pdb` | warning | the workload has no PodDisruptionBudget: a drain can take every replica at once | | `audit.single_replica` | warning | the workload runs a single replica, so any disruption is an outage | | `audit.no_readiness_probe` | warning | a container has no readiness probe, so traffic reaches it before it can serve | | `audit.no_liveness_probe` | info | a container has no liveness probe, so a wedged process is never restarted | | `audit.no_spread` | info | the workload’s replicas are not spread across nodes or zones | | `audit.rigid_scheduling` | warning, info | placement constraints pin the workload to too few nodes to survive losing one | | `audit.hpa_cannot_scale` | warning | the autoscaler structurally cannot scale: min equals max, the target is missing, or a container has no request for its utilization target to divide by | | `audit.suspended_cronjob` | warning | a CronJob has been suspended past —cron-suspended and has skipped activations because of it: whatever it does is not happening, and nothing else reports that | | `ipspace.range` | critical, warning, info | a pod/service/node range is at 80% of its CIDR or worse; critical from 95%, info for a range the cloud APIs cannot rate and for an —all row below the line | | `orphan.disk` | warning | a GCE disk has been unattached for at least —min-age and is still billing | | `orphan.lb` | warning | a forwarding rule or load balancer routes to zero endpoints and is still billing | | `quota.pressure` | critical, warning, info | a cloud quota is at or above —quota-warn percent of its limit; critical from 95%, info for an —all row below the line | | `stockout.zone` | warning | the cloud had no capacity for a machine type in this zone during the window — the reason a scale-up failed and pods stayed Pending | | `perf.apiserver_p99` | critical, warning | apiserver request latency p99 crossed the pack threshold for a verb/resource — warning from 1s, critical from 4s | | `perf.apf_saturation` | critical, warning | an API Priority and Fairness level is holding a sustained queue — warning from 10 queued, critical from 100 | | `perf.apf_rejects` | critical, warning | APF is shedding load: the apiserver is returning 429s at a priority level | | `perf.etcd_fsync` | critical, warning | etcd WAL fsync p99 crossed the pack threshold — warning from 10ms, critical from 100ms | | `perf.etcd_db_size` | critical, warning | the etcd database is approaching its quota — warning from 4 GiB, critical from 5.5 GiB | | `perf.startup_p95` | critical, warning | pod first-ready p95 crossed the pack threshold — warning from 60s, critical from 300s | | `perf.pack_unavailable` | warning | a metric the requested pack needs is not in the metrics workspace, so part of the pack could not run; the rest still did (no coverage lies) | | `edge.missing_ref` | critical | a referenced ConfigMap, Secret, ServiceAccount, TLS secret, IngressClass, StorageClass, or governing Service does not exist | | `edge.missing_key` | critical | the referenced key is absent from an existing ConfigMap/Secret | | `edge.invalid_ref` | warning | the referenced object exists but is the wrong type to serve the reference | | `edge.unclassed` | warning | the Ingress names no class and no IngressClass declares itself the cluster default — no controller will claim it | | `edge.selector_empty` | critical | a Service selector selects zero pods, so the service routes nowhere | | `edge.selector_unready` | critical, warning | the Service selects pods but some are not Ready; critical when none are | | `edge.endpoints_missing` | critical | a selecting Service has no EndpointSlices at all | | `edge.endpoints_orphaned` | warning | an endpoint targetRef names a pod that no longer exists | | `edge.endpoints_unready` | critical, warning | the endpoint ready-count disagrees with the selected pods (stale or lagging slices); critical at zero ready | | `edge.backend_missing` | critical | an Ingress backend service, or the port it names, does not exist | | `edge.cert_expired` | critical | a TLS certificate’s NotAfter is in the past | | `edge.cert_expiring` | warning | a TLS certificate expires within —cert-warn | | `edge.cert_invalid` | warning | tls.crt is missing or unparseable, or the secret is not kubernetes.io/tls | | `edge.rbac_dangling` | warning | a (Cluster)RoleBinding for the workload’s ServiceAccount points at a missing (Cluster)Role | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `check` | which registered command produced this finding — also the command to run for the full detail behind it | | `not_run` | on scan.incomplete: the checks the timeout left unrun, comma-separated | | `checks` | summary-line note: how many checks this scan actually ran | | `skipped` | summary-line note: opt-in groups this scan did NOT run (switch one on with —include=\) — stated so a quiet scan is never mistaken for a complete one | | `drilldown` | summary-line note: workloads the stage-2 dependency-edge drill-down covered | | `truncated` | summary-line note: drill-down candidates dropped by —max-drilldown | | `container` | container the finding is about (init containers prefixed init:) | | `image` | image reference that failed to pull | | `restarts` | container restart count | | `exit_code` | exit code of the container’s last termination | | `last_state` | reason of the container’s last termination (e.g. OOMKilled) | | `age` | how long the abnormal state has persisted | | `desired` | desired replica/scheduled count from spec | | `ready` | ready count from status | | `updated` | updated-to-current-revision count from status | | `available` | available count from status | | `failed` | failed pod count of a Job | | `schedule` | a CronJob’s spec.schedule | | `expected` | the activation a CronJob should have run and did not | | `missed_runs` | activations missed since the anchor; ≥N when the walk was capped | | `anchor` | what the missed count was measured from: last\_schedule or creation | | `time_zone` | a CronJob’s spec.timeZone, when set | | `last_schedule` | a CronJob’s status.lastScheduleTime, or never | | `active_jobs` | Jobs a CronJob still has running | | `condition` | node condition type that is abnormal | | `taint` | taint key indicating reclaim/drain | | `pods` | pods affected (behind a cordoned node or a PDB) | | `healthy` | currently healthy pods behind a PDB | | `required` | pods the PDB requires healthy | | `addon` | system add-on role: dns, proxy, cni, csi, metrics, connectivity | | `resource` | ResourceQuota resource name at or near its limit | | `used` | quota usage from status | | `hard` | quota hard limit from status | | `pct` | quota usage as percent of the hard limit | | `webhook` | admission webhook as \/\ | | `service` | service backend the webhook points at, as \/\ | | `backend` | why the backend is dead: service missing, no ready endpoints, or port \

not on service | | `gates` | namespaces the webhook gates, from namespaceSelector: all namespaces, or \/\ namespaces with up to 5 names | | `rules` | compact operations/resources summary of the webhook’s rules, e.g. “CREATE,UPDATE pods,deployments.apps” | | `object_selector` | the webhook’s objectSelector, when one is set | | `timeout` | webhook timeoutSeconds as \s (nil defaults to the API’s 10s) | | `subject` | CA-bundle certificate subject (CN when set); never key material | | `not_after` | CA-bundle certificate NotAfter, RFC 3339 | | `days_left` | whole days until NotAfter (negative = expired) | | `nodes` | distinct nodes those pods are scheduled on, sorted | | `access_modes` | the claim’s declared access modes | | `pv` | PersistentVolume behind the claim or attachment | | `pvc` | PersistentVolumeClaim the pod mounts (same namespace as the pod) | | `node` | node the attachment targets or the pod is scheduled on | | `attacher` | CSI driver responsible for the attachment (spec.attacher) | | `error` | the attach/detach error message, truncated to 200 chars | | `attached` | the attachment’s status.attached at scan time | | `pv_zones` | zones the PV’s node affinity allows, sorted | | `node_zone` | zone label of the node the pod is scheduled on | | `orphan` | which referenced side is gone: “pv missing”, “node missing”, or both | | `storage_class` | StorageClass the claim names, or the class the finding is about | | `classes` | StorageClasses the cluster does have, sorted (empty when there are none) | | `defaults` | StorageClasses annotated as the cluster default, sorted | | `provisioner` | the class’s spec.provisioner | | `phase` | the claim’s or volume’s status.phase at scan time | | `requested` | storage the claim requests (spec.resources.requests.storage) | | `capacity` | the volume’s spec.capacity.storage | | `reclaim_policy` | the volume’s spec.persistentVolumeReclaimPolicy | | `claim` | the claim the volume was bound to, as namespace/name | | `binding_mode` | the class’s volumeBindingMode (Immediate when unset) | | `gateway_class` | GatewayClass the Gateway names | | `controller` | the GatewayClass’s spec.controllerName — which implementation owns it | | `gateway` | Gateway the route attaches to, as namespace/name | | `listener` | listener name within the Gateway | | `port` | listener port, or the backendRef port the Service does not expose | | `protocol` | listener protocol | | `service_ports` | ports the backend Service does expose, sorted | | `api_group` | crd.unavailable: the API group-version this command needed | | `resources` | crd.unavailable: the resources it would have read | | `unavailable` | summary-line note: why the group could not be read (absent CRDs, or discovery denied) | | `gsa` | the cloud identity (GSA email) the ServiceAccount’s annotation claims | | `problem` | machine-matchable problem code from the provider (e.g. no-workload-identity-binding) | | `env` | the credential-file env var found (GOOGLE\_APPLICATION\_CREDENTIALS) | | `capability` | cloud.unavailable: the provider capability this command needed (workload-identity) | | `provider` | cloud.unavailable: the provider that was asked | | `manager` | on findings: the foreign manager string from managedFields (a tool name like kubectl-edit — never a user identity; see —identity); on the summary line: the resolved GitOps manager | | `detection` | summary note: how the GitOps manager was resolved — declared (—manager), majority (auto-detected recognized GitOps controller owning >50% of the spec leaf fields in scope), or none (no manager resolved; nothing emitted) | | `detection_reason` | summary note on detection=none, naming why: no-spec-fields-in-scope (nothing in scope owns a spec field), no-majority-manager (a leading candidate exists but owns 50% or less), or not-a-gitops-manager (the majority owner is not a recognized GitOps controller — e.g. kubeadm or a kubectl manager on a cluster with no GitOps at all) | | `candidate` | summary note on detection=none: the leading manager that fell short (of the majority, or of being a recognized GitOps controller) — pass it to —manager if it is in fact the GitOps controller | | `share` | summary note: the resolved manager’s (or, on detection=none, the candidate’s) percentage of every spec leaf field owned across the scanned objects, rounded. A declared manager with a low share means most findings are other legitimate owners | | `unmanaged` | summary note, omitted at zero: scanned objects the resolved GitOps manager owns no spec field on. Nothing is reported for them — an object the manager never applied cannot have drifted from it — so a high count next to zero findings means the manager’s scope is narrower than the scan’s | | `operation` | managedFields operation of the foreign manager’s last write: Apply or Update | | `tool` | client tool recognized from the manager string (kubectl for kubectl-edit/kubectl-patch/kubectl-\*) | | `fields` | compact spec paths the foreign manager owns (e.g. spec.template.spec.containers\[app].image), capped at 8 with a +N more tail | | `field_count` | total spec leaf fields the foreign manager owns on this object (uncapped) | | `principal` | —identity: the audited principal of the write nearest the drift time (GKE: principalEmail), or the explicit sentinel none-in-audit-window / no-write-time-anchor when the trail cannot answer | | `principal_agent` | —identity: the caller-supplied client string of that write (a kubectl or controller user-agent), when the trail records one; caller-controlled text, display-only | | `other_principals` | —identity: other distinct principals that wrote the object inside the audit window, capped at 8 with a +N more tail | | `identity` | summary note when —identity could not be served: the unavailable marker naming why (no provider / audit capability absent) | | `cluster` | on a node-pool finding: the cluster the pool belongs to, so the record stands alone | | `workload_pool` | the cluster-wide workload identity pool that this node pool’s pods bypass | | `metadata_mode` | how the node pool exposes instance metadata to pods: node-identity means any pod can mint tokens for the node’s service account | | `disable_legacy_endpoints` | the pool’s legacy-metadata setting as the provider records it: `enabled` when someone turned the pre-v1 endpoints back on, `unset` when the pool was never configured either way | | `node_pools` | summary note: node pools examined — the cluster itself is the other unit `scanned` counts | | `endpoint` | the control plane’s internet-facing address | | `authorized_networks` | how many source ranges the allow-list permits | | `authorized_network_cidrs` | those ranges, sorted as the provider returned them and capped at 8 with a +N more tail | | `gcp_public_cidrs` | whether the provider’s own public ranges are admitted in addition to the allow-list | | `exempt_kind` | the finding kind the entry covers — this is the entry’s `kind:` field, not this finding’s own kind | | `expires` | when the entry stops applying, RFC 3339 (a bare `YYYY-MM-DD` in the file resolves to 00:00:00Z that day) | | `expired_for` | how long ago the entry lapsed, rounded to whole days — only on audit.exemption\_expired | | `expires_in` | how long until the entry lapses, rounded to whole days — only on audit.exemption\_expiring | | `owner` | the entry’s `owner:` field, absent if it has none — which is itself worth fixing, since “expired, and nobody knows whose it was” is where these files end up | | `justification` | the entry’s `reason:` field: why the exempted finding was accepted. Distinct from the envelope’s exempt\_reason, which is the justification for THIS finding being exempt | | `containers` | containers implicated by the finding — those running privileged, or holding a node-root capability | | `container_names` | their names, capped at 8 with a +N more tail | | `total_containers` | containers in the pod template, init containers included, so `containers` reads as a fraction | | `capabilities` | the node-root capabilities added by those containers (ALL, SYS\_ADMIN), sorted and deduplicated | | `host_paths` | hostPath volumes the template mounts; a declared but unmounted hostPath volume grants no access and is not counted | | `host_path_names` | the paths on the node, sorted and capped at 8 | | `service_account` | the ServiceAccount the finding is about — always `default`, the one every pod gets when its template names none | | `mounting_workloads` | workloads in the namespace running as the default ServiceAccount without disabling automount at the pod level; the finding does not fire at 0 | | `mounting_workload_names` | their Kind/name, sorted and capped at 8 | | `pss_enforce` | the namespace’s pod-security.kubernetes.io/enforce label, omitted when unset | | `pss_warn` | its /warn label, omitted when unset — set without /enforce means the namespace is in dry-run | | `pss_audit` | its /audit label, omitted when unset — same dry-run meaning | | `workloads` | pod templates this pass judged in the namespace, so an unenforced namespace with nothing in it reads differently from a busy one | | `namespaces` | summary note: namespaces examined — the denominator for every namespace-subject claim, which `scanned` (pod templates) does not cover | | `policies` | NetworkPolicies in the namespace naming this direction in policyTypes; 0 on a namespace-subject finding, and the number that failed to select the subject on a workload one | | `total_policies` | NetworkPolicies in the namespace in either direction, so an egress-only namespace does not read as an empty one | | `host_network_workloads` | pod templates excluded because they use the node’s network namespace, where NetworkPolicy does not apply; omitted at 0 | | `covered_workloads` | pod templates in the namespace that ARE selected for this direction — the neighbours the subject fell out of step with | | `pod_labels` | the template’s own labels, which are what the policies’ selectors failed to match, sorted and capped at 8 | | `version` | the current version of the finding’s subject — the control plane’s, or the node pool’s | | `target_version` | the version the provider would move this cluster to: its channel’s upgrade target where one is published, otherwise the channel’s default | | `control_plane_version` | on a node-pool skew finding: the control-plane version the pool is measured against | | `minor_versions_behind` | how many minor releases separate the two versions | | `channel` | the release channel the cluster is subscribed to, and the one whose published versions the comparison used; `none` when it is subscribed to no channel | | `image_type` | the provider’s name for the node image the pool runs | | `exclusion` | the operator’s name for the maintenance exclusion currently in force | | `scope` | how much of the upgrade stream that exclusion holds back: all-upgrades, minor-upgrades or minor-and-node-upgrades | | `ends` | when the exclusion lifts, or `end-of-support` for one that runs until the cluster’s version leaves support | | `days_remaining` | how much longer the exclusion has left to run | | `replicas` | the workload’s spec.replicas (nil defaults to 1, matching the API server); absent on DaemonSets, whose replica count is the node count | | `namespace_pdbs` | PodDisruptionBudgets in the workload’s namespace — 0 says the namespace has no PDB culture at all, a non-zero value says this workload was missed | | `min_replicas` | the HPA’s spec.minReplicas (nil defaults to 1, matching the API server) | | `max_replicas` | the HPA’s spec.maxReplicas | | `metric` | the utilization metric the HPA cannot compute, comma-separated if more than one | | `scale_target` | the HPA’s scaleTargetRef as Kind/name | | `eligible_nodes` | nodes satisfying the workload’s REQUIRED placement constraint; an upper bound, since taints and cordons are not subtracted | | `cluster_nodes` | nodes in the cluster, so `eligible_nodes` reads as a fraction | | `constraint` | the label and field keys that narrow placement, sorted and capped at 8 | | `suspended_for` | how long spec.suspend has been true, rounded to whole days | | `suspended_since` | when the suspension is estimated to have started, RFC 3339 | | `pdbs` | summary note: PodDisruptionBudgets seen in scope | | `hpas` | summary note: HorizontalPodAutoscalers seen in scope | | `cidr` | the range’s CIDR block | | `purpose` | what the range allocates: pods, services, or nodes | | `zone` | orphan.disk: the disk’s zone | | `size_gb` | orphan.disk: provisioned size in GB (billed whether used or not) | | `disk_type` | orphan.disk: disk type short name (pd-ssd bills \~4x pd-standard idle) | | `unused_since` | orphan.disk: last detach (or creation, if never attached), RFC3339; omitted when the provider cannot date it | | `unused_for` | orphan.disk: how long the disk has been unattached; “unknown” when undatable | | `region` | orphan.lb: the forwarding rule’s region (“global” for global rules) | | `why` | orphan.lb: the provider’s orphan judgment (e.g. which backend resolved empty) | | `usage` | current usage in the quota’s own unit | | `limit` | the quota limit | | `unit` | the quota’s unit, when the provider names one | | `machine_type` | the exhausted machine type (omitted when the log record does not name one) | | `events` | stockout events for this zone/machine-type pair in the window | | `first_seen` | earliest event in the window (RFC3339) | | `last_seen` | latest event in the window (RFC3339) | | `reroute` | same-region zones active in the window with no stockout for this machine type, comma-separated; omitted when the window offers no clean candidate | | `window` | summary-line note: the lookback the events cover | | `pack` | the pack this finding belongs to; also the summary-line note naming the pack that ran | | `verb` | apiserver request verb for this series (apiserver pack) | | `priority_level` | APF priority level for this series (apf pack) | | `code` | the HTTP status code the query matched (apf pack: 429) | | `observed` | the worst (maximum) aligned value in the window, in the query’s unit — the breach basis | | `latest` | the newest aligned value in the window | | `threshold` | the crossed threshold: the critical one when severity=critical, else the warning one | | `trend` | startup pack: second-half vs first-half mean delta of the window, e.g. “+34%” — the p95 trend direction | | `workload` | the target the edges were traced from as \/\/\, stamped on every finding — a workload, or the Service itself when entered from the service side | | `likely_workload` | on a Service-entry edge.selector\_empty: the workload in that namespace whose pod labels best fit the broken selector, i.e. the one it was probably meant to select. Absent when two workloads fit equally well, because then naming one would be a guess | | `volume` | pod volume, or StatefulSet volumeClaimTemplate, whose reference is broken | | `key` | the referenced key that is missing from the ConfigMap/Secret | | `selector` | the Service label selector under scrutiny | | `selected` | pods the Service selector currently selects | | `endpoints` | total endpoints across the Service’s EndpointSlices | | `slices` | how many EndpointSlices back the Service | | `pod` | pod named by an orphaned endpoint targetRef | | `via` | how the broken reference is reached from the workload: mount, ingress, or imagePullSecret | | `ingress` | Ingress referencing the TLS secret, or the unserved Ingress itself | | `host` | Ingress rule host of the broken backend (empty for the default backend) | | `path` | Ingress rule path of the broken backend | | `role_ref` | dangling roleRef as \/\ | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout scan lookout scan --namespace=prod lookout scan --include=audit --format=json lookout scan --max-drilldown=0 ``` # Signal kinds > The signal-schema v1 kind catalog — every kind the sentinel can put on the wire, from the frozen ledger. The signal-schema v1 kind inventory (57 kinds), rendered from the SAME exported ledger (`pkg/inject/schema`) the freeze tests in `pkg/inject` pin (docs/signal-schema-v1.md). The schema is FROZEN: removing or renaming a kind or a frozen field is a v2 negotiation with fleet consumers, never a routine change; additions are v1-additive and extend the ledger, the field pins, and the schema doc in the same change. Every payload carries a stable incident-class `fingerprint` plus `cluster`/`project`/`zone` join dimensions — fleet rollup is a join, not a parsing project. The one exception is the frozen reactive pair (`k8s-event`, `k8s-event-followup`), whose wire shape stays byte-identical for playbook back-compat and never gains the identity fields. ## Cross-cutting kinds [Section titled “Cross-cutting kinds”](#cross-cutting-kinds) Emitted by the dispatcher itself (outcome records, storms, watchboard, triage evidence) plus the frozen reactive pair. | Kind | Wire struct | Role | | ------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `k8s-event` | `Payload` | Frozen reactive kind: the opening inject of a per-incident session; its wire shape is byte-identical for playbook back-compat. | | `k8s-event-followup` | `Payload` | Frozen reactive kind: a dedup-window recurrence injected into the already-open incident session. | | `resolved` | `ResolvedPayload` | Outcome record: the symptom stayed clear for —recovery-stable-for; carries resolution=recovered\|object\_deleted. | | `resolved.reverted` | `ResolvedPayload` | Outcome record: the symptom recurred within the revert window after a resolve. | | `storm` | `StormPayload` | Aggregate incident: opened when —storm-min incidents share a blast-radius key within —storm-window. | | `storm.member` | `StormMemberPayload` | Membership record injected into the storm session for each folded incident. | | `storm.member_superseded` | `StormMemberPayload` | Supersede pointer left in a pre-storm incident session that the storm absorbed. | | `storm.update` | `StormUpdatePayload` | Storm size refresh (latest wins): membership grew past a reporting threshold. | | `watchboard.digest` | `WatchboardDigestPayload` | Warning-class batch flushed to the shared watchboard session (—watchboard-batch / —watchboard-flush). | | `watchboard.rotated` | `WatchboardRotatedPayload` | Size-based rotation pointer naming the successor watchboard session after —watchboard-rotate digests. | | `triage.regressed` | `TriageRegressedPayload` | Regression evidence: a downgraded incident’s recurrence count reached —triage-regress-factor times its count at downgrade — evidence only, never a re-page. | | `family.member` | `FamilyMemberPayload` | Cross-source join: a signal from a different source family attached to this session’s incident (leading↔reactive) — at most one per source family per incident per dedup window; storm members never fan these out. | | `sentinel.access_revoked` | `Payload` | Coverage loss: a permission the sentinel held at startup is denied now, confirmed over consecutive SSAR sweeps — from here that source sees nothing, and its silence means less than it did. | ## Source-namespaced kinds [Section titled “Source-namespaced kinds”](#source-namespaced-kinds) All ride `inject.Payload` with the full identity stamped (`source`, `severity`, `fingerprint`, `project`, `zone`). The source column is the `--sources` name that emits the kind. | Kind | Source | Role | | -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `objectstate.node_notready` | `object-state` | A Node’s Ready condition transitioned True→False/Unknown — workloads on that node are next. | | `objectstate.node_flapping` | `object-state` | A Node’s Ready condition flapped repeatedly within the flap window. | | `objectstate.progress_deadline` | `object-state` | A Deployment rollout made no progress with unready replicas — fired BEFORE the control plane’s ProgressDeadlineExceeded event. | | `objectstate.endpoints_empty` | `object-state` | A Service’s ready-endpoint count transitioned >0 → 0. | | `objectstate.pdb_gridlocked` | `object-state` | A PodDisruptionBudget’s disruptionsAllowed transitioned >0 → 0 while pods behind it exist — drains will stall. | | `objectstate.restart_burst` | `object-state` | A pod’s summed container restart count grew past the burst threshold — the leading edge of a crash loop, ahead of BackOff events. | | `objectstate.node_pressure` | `object-state` | A Node’s kubelet pressure condition (MemoryPressure/DiskPressure/PIDPressure) went False→True; escalates to critical when sustained or paired with eviction activity on the node. | | `objectstate.eviction_burst` | `object-state` | N pod evictions on one node within the burst window, folded into ONE node-scoped signal — the storm-off fallback for the per-pod Evicted event family. | | `rollout.stall` | `rollout` | A new revision made zero ready-count progress for —rollout-observe while the old revision stayed healthy. | | `workload.job_failed` | `workload` | A Job’s Failed condition went True (BackoffLimitExceeded, DeadlineExceeded, …) — batch failure with no crashlooping pod behind it. | | `workload.cron_missed` | `workload` | An unsuspended CronJob passed a scheduled activation without lastScheduleTime advancing; consecutive misses escalate to critical. | | `autoscaling.hpa_pinned` | `autoscaling` | An HPA sat at maxReplicas with its metric still over target past the sustain window — the autoscaler is out of headroom (escalates to critical when sustained longer). | | `autoscaling.hpa_metrics_dead` | `autoscaling` | An HPA’s metrics pipeline is broken (ScalingActive=False with a FailedGet\* reason, sustained) — autoscaling is silently dead. | | `saturation.forecast` | `saturation` | A linear-regression forecast says a resource dimension exhausts within —saturation-warn (critical below 15m). | | `degradation.capacity` | `degradation` | A Service’s ready-endpoint ratio declined stepwise across —degradation-window — capacity eroding before the outage. | | `degradation.probe_flap` | `degradation` | A pod’s readiness gate flipped repeatedly without ever sustaining failure long enough for the reactive Unhealthy path. | | `expiry.warning` | `expiry` | An expiry countdown (certificate/token) crossed a threshold: warning at —expiry-warn, critical at the design-fixed 72h. | | `capacity.pending` | `capacity` | A NotTriggerScaleUp event: the autoscaler declined a pending pod, with per-nodegroup rejection reasons. | | `capacity.scaleup` | `capacity` | A TriggeredScaleUp event: the autoscaler asked the cloud for nodes (info; stored context for later gaps). | | `capacity.scaledown` | `capacity` | The ScaleDown event family (info; warning for ScaleDownFailed). | | `capacity.scaleup_gap` | `capacity` | A nodegroup’s cloudProviderTarget exceeded its ready count beyond the sustain window — asked for a node, didn’t get one. | | `capacity.stockout` | `capacity` | A provider scale decision names a stockout: the zone/machine-type has no capacity. Remedy-disjoint from quota. | | `capacity.quota_blocked` | `capacity` | A provider scale decision names quota exhaustion: file a quota increase. Remedy-disjoint from stockout. | | `capacity.ip_exhausted` | `capacity` | A provider scale decision names IP exhaustion: new nodes/pods cannot get addresses. | | `capacity.pending-aged` | `capacity` | A pod stayed Pending+Unschedulable past —pending-age (critical past the design-fixed 15m). | | `capacity.cluster_forecast` | `capacity` | A scheduling domain’s pod-requests/node-allocatable ratio is on a linear trend to reach 1.0 — cluster full in \~N hours, before the first pod goes Pending. | | `ingress.sync_failed` | `ingress` | An ingress-gce Warning Sync event on an Ingress: GCLB programming is failing while the Ingress object looks fine. | | `ingress.translate_failed` | `ingress` | An ingress-gce Warning Translate event on an Ingress: the spec could not be translated into GCLB resources. | | `ingress.neg_failed` | `ingress` | A NEG-controller failure on a Service (sync/attach/detach/retry): endpoints are not reaching the load balancer. | | `gateway.programming_failed` | `gateway` | A Gateway (top-level or listener) held Programmed=False past the grace window: the load balancer/data plane is not being programmed. The Gateway-API analog of ingress.sync\_failed. | | `gateway.route_rejected` | `gateway` | A Gateway/listener or HTTPRoute parent held Accepted=False/ResolvedRefs=False past the grace window: the route config never became routable. The analog of ingress.translate\_failed. | | `quota.forecast` | `quota` | A GCP quota’s usage slope projects exhaustion (warning ETA<7d or usage>=90%; critical ETA<48h or >=98%), with a quota-increase draft attached. | | `notification.upgrade` | `notifications` | The provider announced a control-plane or node-pool upgrade starting — store-recorded evidence for incident-window correlation. | | `notification.upgrade_available` | `notifications` | The provider offered a new version for auto-upgrade. | | `notification.security_bulletin` | `notifications` | A provider security bulletin affects this cluster — batched to the watchboard. | | `token.burn` | `token-burn` | An agent session’s token rate ran at —burn-multiple times the cross-session baseline, or projects budget exhaustion within —burn-eta. | | `leeway.domain_unavailable` | `topology-drift` | A topology domain has no node anything can be scheduled onto — the subject is the domain and not a workload, so a dead zone is one signal rather than one per workload that drifted because of it. | | `leeway.contract_violated` | `topology-drift` | A declared topology contract (a DoNotSchedule spread constraint or a required anti-affinity) is being violated, sustained past the dwell window. | | `leeway.placement_drift` | `topology-drift` | A workload’s objects deviated from the placement its intent implies — declared, inferred, or, where nobody expressed one, an even apportionment over the domains it can reach (drift ρ over threshold), sustained past the dwell window. | | `leeway.baseline_breach` | `topology-drift` | A workload that declared nothing left the placement it has held all along — a domain’s share fell outside the band around its own learned normal, sustained past the dwell window. | | `leeway.rank_wedged` | `compute-class` | Pods are Pending against a compute class that told the autoscaler not to provision outside its priority list, so no capacity of any rank will arrive without a change to the class. | | `leeway.rank_degraded` | `compute-class` | A compute class is running below the priority it prefers — too little pod-time at rank 0, or too much at the least-preferred tier, sustained past the dwell window. The pods stay Running, which is why nothing else reports it. | | `leeway.rank_no_migration` | `compute-class` | A compute class that declared it would migrate workloads back to preferred capacity has not done so since that capacity became available again. | | `leeway.rank_tier_unused` | `compute-class` | A whole preference tier has never been occupied over the observation window — a dead rung on the priority ladder, or reserved capacity being paid for and never drawn on. Info, not a page: an unused tier is frequently the intended configuration. | Field-level detail (ordered json field lists, omitempty rules, the fingerprint recipe) lives in [`docs/signal-schema-v1.md`](https://github.com/go-steer/k8s-lookout/blob/main/docs/signal-schema-v1.md). # lookout stab drain > Before draining a node, list everything that will block the drain (PDBs at disruptionsAllowed=0) or be destroyed by it (bare pods, emptyDir data, single-replica workloads); --node details one node, -A means all nodes here (pods are always examined across all namespaces); scanned counts pods examined after the standard-drain skips (mirror/DaemonSet/completed pods). Before draining a node, list everything that will block the drain (PDBs at disruptionsAllowed=0) or be destroyed by it (bare pods, emptyDir data, single-replica workloads); —node details one node, -A means all nodes here (pods are always examined across all namespaces); scanned counts pods examined after the standard-drain skips (mirror/DaemonSet/completed pods). MCP tool: `k8s_drain_blockers` ## Usage [Section titled “Usage”](#usage) ```sh lookout stab drain [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `--node` | string | — | analyze one node in detail: every blocker on it becomes its own finding. Exactly one of —node or -A (all-nodes summary) is required. | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | --------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `drain.pdb_gridlock` | critical | a PodDisruptionBudget covering pods on this node allows zero disruptions: the eviction API refuses and the drain hangs | | `drain.bare_pod` | warning | a pod on this node has no owner, so eviction deletes it permanently and nothing recreates it | | `drain.local_storage` | warning | a pod on this node has emptyDir volumes: the drain needs —delete-emptydir-data and the data is lost | | `drain.singleton` | warning | a pod on this node is the only replica of its controller — evicting it is an outage | | `drain.node` | critical, warning | the -A roll-up: this node is not cleanly drainable, with the blocker classes counted; critical when a PDB gridlock is among them | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | --------------------- | ----------------------------------------------------------------------------------- | | `node` | the node the blocker sits on (stamped on every —node-mode finding) | | `pods` | pods on the node covered by the gridlocked PDB | | `pod_names` | names of the covered pods, capped at 8 with a +N more tail | | `disruptions_allowed` | PDB status.disruptionsAllowed (always 0 in a gridlock finding) | | `current_healthy` | PDB status.currentHealthy | | `desired_healthy` | PDB status.desiredHealthy | | `volumes` | emptyDir volume names on the pod; memory-backed ones marked (medium=Memory) | | `workload` | the single-replica controller as \/\/\ | | `replicas` | the controller’s spec.replicas (always 1 in a singleton finding) | | `blockers` | total drain blockers on the node (also a —node-mode summary note) | | `pdb_gridlock` | gridlocked-PDB blocker count on the node (-A per-node finding; zero counts omitted) | | `bare_pods` | bare-pod blocker count on the node (-A per-node finding; zero counts omitted) | | `local_storage` | emptyDir blocker count on the node (-A per-node finding; zero counts omitted) | | `singletons` | single-replica blocker count on the node (-A per-node finding; zero counts omitted) | | `drainable` | summary note (—node mode): yes when the node has no blockers, else no | | `nodes` | summary note (-A mode): nodes examined | | `blocked` | summary note (-A mode): nodes with at least one blocker | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout stab drain --node=gke-prod-pool-a-x1z2 lookout stab drain -A lookout stab drain --node=gke-prod-pool-a-x1z2 --format=json ``` # lookout stab drift > Find spec fields of Deployments/StatefulSets/DaemonSets owned by a manager other than the GitOps controller (managedFields) — out-of-band kubectl edits and rogue co-managers. Reports manager strings (tool names, not people); --identity additionally resolves each drift write to the audited principal via the cloud provider's audit trail (GKE Cloud Audit Logs), reporting an explicit unavailable on clusters without one. Default scope: all namespaces; scanned counts workload objects examined. Find spec fields of Deployments/StatefulSets/DaemonSets owned by a manager other than the GitOps controller (managedFields) — out-of-band kubectl edits and rogue co-managers. Reports manager strings (tool names, not people); —identity additionally resolves each drift write to the audited principal via the cloud provider’s audit trail (GKE Cloud Audit Logs), reporting an explicit unavailable on clusters without one. Default scope: all namespaces; scanned counts workload objects examined. MCP tool: `k8s_gitops_drift` ## Usage [Section titled “Usage”](#usage) ```sh lookout stab drift [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------------ | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--manager` | string | — | the declared GitOps manager (e.g. argocd-controller); empty auto-detects it as the manager owning a strict majority (>50%) of the spec leaf fields summed across the scanned objects AND recognized as a GitOps controller (Argo CD, Flux, Helm, Config Sync, Fleet, kapp, Terraform, Pulumi). No manager clears both bars — the usual shape of a cluster with no GitOps controller at all — and the scan resolves to detection=none and emits nothing rather than measuring drift against a guess; the summary then names the leading candidate (ties to the lexicographically smallest) and its share, to pass back here if it is in fact the GitOps controller. A declared manager skips both bars: the operator knows their cluster | | `--identity` | bool | — | resolve each finding’s last drift write to the audited principal (who ran it) via the cloud provider’s audit trail; requires a provider with the audit capability (GKE: Cloud Audit Logs admin-activity read), otherwise the summary line reports an explicit unavailable | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `drift.manual_edit` | critical, warning | a manager other than the GitOps controller owns spec fields on this object; critical when one of them is high blast radius (image, replicas, env) | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `manager` | on findings: the foreign manager string from managedFields (a tool name like kubectl-edit — never a user identity; see —identity); on the summary line: the resolved GitOps manager | | `detection` | summary note: how the GitOps manager was resolved — declared (—manager), majority (auto-detected recognized GitOps controller owning >50% of the spec leaf fields in scope), or none (no manager resolved; nothing emitted) | | `detection_reason` | summary note on detection=none, naming why: no-spec-fields-in-scope (nothing in scope owns a spec field), no-majority-manager (a leading candidate exists but owns 50% or less), or not-a-gitops-manager (the majority owner is not a recognized GitOps controller — e.g. kubeadm or a kubectl manager on a cluster with no GitOps at all) | | `candidate` | summary note on detection=none: the leading manager that fell short (of the majority, or of being a recognized GitOps controller) — pass it to —manager if it is in fact the GitOps controller | | `share` | summary note: the resolved manager’s (or, on detection=none, the candidate’s) percentage of every spec leaf field owned across the scanned objects, rounded. A declared manager with a low share means most findings are other legitimate owners | | `unmanaged` | summary note, omitted at zero: scanned objects the resolved GitOps manager owns no spec field on. Nothing is reported for them — an object the manager never applied cannot have drifted from it — so a high count next to zero findings means the manager’s scope is narrower than the scan’s | | `operation` | managedFields operation of the foreign manager’s last write: Apply or Update | | `tool` | client tool recognized from the manager string (kubectl for kubectl-edit/kubectl-patch/kubectl-\*) | | `fields` | compact spec paths the foreign manager owns (e.g. spec.template.spec.containers\[app].image), capped at 8 with a +N more tail | | `field_count` | total spec leaf fields the foreign manager owns on this object (uncapped) | | `age` | how long ago the foreign manager last wrote (managedFields time); omitted when the API server recorded no time | | `principal` | —identity: the audited principal of the write nearest the drift time (GKE: principalEmail), or the explicit sentinel none-in-audit-window / no-write-time-anchor when the trail cannot answer | | `principal_agent` | —identity: the caller-supplied client string of that write (a kubectl or controller user-agent), when the trail records one; caller-controlled text, display-only | | `other_principals` | —identity: other distinct principals that wrote the object inside the audit window, capped at 8 with a +N more tail | | `identity` | summary note when —identity could not be served: the unavailable marker naming why (no provider / audit capability absent) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout stab drift lookout stab drift --namespace=prod --manager=argocd-controller lookout stab drift --workload=Deployment/prod/api --identity lookout stab drift --workload=Deployment/prod/api --format=json ``` # lookout state edges > Verify every dependency edge of one workload — ConfigMap/Secret keys, imagePullSecrets, Service selectors and endpoints, Ingress backends and class, StatefulSet governing Service and volume classes, ServiceAccount/RBAC references, TLS expiry — reporting only the broken ones. --workload also accepts Service// to enter from the service side, which is the direction the evidence arrives from when a service has no endpoints: it reports that service's selector, endpoints, ingresses and certificates, and names the workload the selector was probably meant for. Verify every dependency edge of one workload — ConfigMap/Secret keys, imagePullSecrets, Service selectors and endpoints, Ingress backends and class, StatefulSet governing Service and volume classes, ServiceAccount/RBAC references, TLS expiry — reporting only the broken ones. —workload also accepts Service/\/\ to enter from the service side, which is the direction the evidence arrives from when a service has no endpoints: it reports that service’s selector, endpoints, ingresses and certificates, and names the workload the selector was probably meant for. MCP tool: `k8s_state_edges` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout state edges [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------------- | -------- | ------- | --------------------------------------------------- | | `--cert-warn` | duration | `720h` | report TLS certificates expiring within this window | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | | `edge.missing_ref` | critical | a referenced ConfigMap, Secret, ServiceAccount, TLS secret, IngressClass, StorageClass, or governing Service does not exist | | `edge.missing_key` | critical | the referenced key is absent from an existing ConfigMap/Secret | | `edge.invalid_ref` | warning | the referenced object exists but is the wrong type to serve the reference | | `edge.unclassed` | warning | the Ingress names no class and no IngressClass declares itself the cluster default — no controller will claim it | | `edge.selector_empty` | critical | a Service selector selects zero pods, so the service routes nowhere | | `edge.selector_unready` | critical, warning | the Service selects pods but some are not Ready; critical when none are | | `edge.endpoints_missing` | critical | a selecting Service has no EndpointSlices at all | | `edge.endpoints_orphaned` | warning | an endpoint targetRef names a pod that no longer exists | | `edge.endpoints_unready` | critical, warning | the endpoint ready-count disagrees with the selected pods (stale or lagging slices); critical at zero ready | | `edge.backend_missing` | critical | an Ingress backend service, or the port it names, does not exist | | `edge.cert_expired` | critical | a TLS certificate’s NotAfter is in the past | | `edge.cert_expiring` | warning | a TLS certificate expires within —cert-warn | | `edge.cert_invalid` | warning | tls.crt is missing or unparseable, or the secret is not kubernetes.io/tls | | `edge.rbac_dangling` | warning | a (Cluster)RoleBinding for the workload’s ServiceAccount points at a missing (Cluster)Role | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `workload` | the target the edges were traced from as \/\/\, stamped on every finding — a workload, or the Service itself when entered from the service side | | `likely_workload` | on a Service-entry edge.selector\_empty: the workload in that namespace whose pod labels best fit the broken selector, i.e. the one it was probably meant to select. Absent when two workloads fit equally well, because then naming one would be a guess | | `pods` | how many of the workload’s pods carry the broken reference | | `container` | container declaring the broken env/envFrom reference | | `env` | environment variable whose valueFrom reference is broken | | `volume` | pod volume, or StatefulSet volumeClaimTemplate, whose reference is broken | | `key` | the referenced key that is missing from the ConfigMap/Secret | | `selector` | the Service label selector under scrutiny | | `selected` | pods the Service selector currently selects | | `ready` | ready count (selected pods or serving endpoints, per finding kind) | | `endpoints` | total endpoints across the Service’s EndpointSlices | | `slices` | how many EndpointSlices back the Service | | `service` | the Service a slice, Ingress backend, or StatefulSet serviceName refers to | | `pod` | pod named by an orphaned endpoint targetRef | | `subject` | TLS certificate subject (CN when set); never key material | | `not_after` | TLS certificate NotAfter, RFC 3339 | | `days_left` | whole days until NotAfter (negative = expired) | | `via` | how the broken reference is reached from the workload: mount, ingress, or imagePullSecret | | `ingress` | Ingress referencing the TLS secret, or the unserved Ingress itself | | `host` | Ingress rule host of the broken backend (empty for the default backend) | | `path` | Ingress rule path of the broken backend | | `port` | Service port (name or number) the Ingress backend asks for | | `service_account` | ServiceAccount the RBAC finding is about, or the one contributing an imagePullSecret | | `role_ref` | dangling roleRef as \/\ | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state edges --workload=Deployment/prod/api lookout state edges --workload=Pod/prod/api-6d5f8c-x2v9k --format=json lookout state edges --workload=StatefulSet/db/postgres --cert-warn=336h lookout state edges --workload=Service/prod/api ``` # lookout state gateway > When traffic through the Gateway API does not arrive — walk GatewayClass → Gateway → listener → HTTPRoute → Service and report every hop that is rejected, unprogrammed, or points at something that is not there. Silent, and cheap, on clusters without the Gateway API installed. When traffic through the Gateway API does not arrive — walk GatewayClass → Gateway → listener → HTTPRoute → Service and report every hop that is rejected, unprogrammed, or points at something that is not there. Silent, and cheap, on clusters without the Gateway API installed. MCP tool: `k8s_gateway_routes` ## Usage [Section titled “Usage”](#usage) ```sh lookout state gateway [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | `gateway.missing_class` | critical | the Gateway names a GatewayClass that does not exist — nothing will program it | | `gateway.class_not_accepted` | critical | the Gateway’s GatewayClass is not Accepted by its controller | | `gateway.not_accepted` | critical | the Gateway itself is not Accepted | | `gateway.not_programmed` | critical | the Gateway is Accepted but not Programmed: no data plane is carrying its traffic | | `gateway.listener_invalid` | warning | one listener of an otherwise working Gateway is not resolved or not programmed | | `route.missing_parent` | critical | the route’s parentRef names a Gateway that does not exist | | `route.not_accepted` | critical | the Gateway refused the route’s attachment (listener, hostname, or namespace policy) | | `route.missing_backend` | critical | the route’s backendRef Service does not exist | | `route.backend_port` | critical | the route’s backendRef Service exists but does not expose the named port | | `crd.unavailable` | info | the API group this check reads is not served by the cluster, so nothing was examined (no coverage lies) | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | --------------- | ------------------------------------------------------------------------------------- | | `gateway_class` | GatewayClass the Gateway names | | `controller` | the GatewayClass’s spec.controllerName — which implementation owns it | | `gateway` | Gateway the route attaches to, as namespace/name | | `listener` | listener name within the Gateway | | `port` | listener port, or the backendRef port the Service does not expose | | `protocol` | listener protocol | | `condition` | the status condition that is not True | | `service` | backend Service the route names, as namespace/name | | `service_ports` | ports the backend Service does expose, sorted | | `classes` | GatewayClasses the cluster does have, sorted | | `api_group` | crd.unavailable: the API group-version this command needed | | `resources` | crd.unavailable: the resources it would have read | | `unavailable` | summary-line note: why the group could not be read (absent CRDs, or discovery denied) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state gateway lookout state gateway --namespace=prod lookout state gateway --format=json ``` # lookout state storage > When a PersistentVolumeClaim sits Pending and the pod behind it will not schedule — name the reason: a StorageClass that does not exist, no class and no cluster default, a static-only class with nothing pre-provisioned, plus the default-class ambiguity and stranded volumes behind it. When a PersistentVolumeClaim sits Pending and the pod behind it will not schedule — name the reason: a StorageClass that does not exist, no class and no cluster default, a static-only class with nothing pre-provisioned, plus the default-class ambiguity and stranded volumes behind it. MCP tool: `k8s_storage_binding` ## Usage [Section titled “Usage”](#usage) ```sh lookout state storage [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | `storage.missing_class` | critical | the claim names a StorageClass that does not exist — it will stay Pending forever | | `storage.no_default_class` | critical | the claim names no class and the cluster has no default StorageClass | | `storage.no_provisioner` | warning | the claim’s class is static-only (kubernetes.io/no-provisioner) and no matching PV is available | | `storage.multiple_defaults` | warning | more than one StorageClass is annotated as the cluster default; which one wins is not defined | | `storage.pv_failed` | warning | a PersistentVolume is Failed: its reclaim did not complete, so the backing disk stays allocated and the volume cannot be reused | | `storage.pv_released` | info | a PersistentVolume is Released — retained on purpose, but its capacity is unusable until spec.claimRef is cleared | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ---------------- | ------------------------------------------------------------------------ | | `storage_class` | StorageClass the claim names, or the class the finding is about | | `classes` | StorageClasses the cluster does have, sorted (empty when there are none) | | `defaults` | StorageClasses annotated as the cluster default, sorted | | `provisioner` | the class’s spec.provisioner | | `phase` | the claim’s or volume’s status.phase at scan time | | `requested` | storage the claim requests (spec.resources.requests.storage) | | `capacity` | the volume’s spec.capacity.storage | | `reclaim_policy` | the volume’s spec.persistentVolumeReclaimPolicy | | `claim` | the claim the volume was bound to, as namespace/name | | `binding_mode` | the class’s volumeBindingMode (Immediate when unset) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state storage lookout state storage --namespace=prod lookout state storage --format=json ``` # lookout state volumes > When pods hang in ContainerCreating with Multi-Attach or FailedAttachVolume events — join VolumeAttachment + PV/PVC + pods to name the exact conflict: RWO claims wanted on two nodes, attachments stuck in error, cross-zone PV locks, orphaned attachments. When pods hang in ContainerCreating with Multi-Attach or FailedAttachVolume events — join VolumeAttachment + PV/PVC + pods to name the exact conflict: RWO claims wanted on two nodes, attachments stuck in error, cross-zone PV locks, orphaned attachments. MCP tool: `k8s_volume_conflicts` ## Usage [Section titled “Usage”](#usage) ```sh lookout state volumes [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ---------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- | | `volume.multi_attach` | critical | an RWO claim is wanted by pods on more than one node — the second pod never starts | | `volume.zone_conflict` | critical | the PV is locked to a zone the pod’s node is not in | | `volume.attach_error` | critical, warning | the attach or detach is failing; critical once it has been failing long enough to be stuck rather than slow | | `volume.orphaned_attachment` | info | a VolumeAttachment survives its PV or its node | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | -------------- | ----------------------------------------------------------------------------------- | | `pods` | scheduled pods referencing the conflicted claim, sorted (list capped, then +K more) | | `nodes` | distinct nodes those pods are scheduled on, sorted | | `access_modes` | the claim’s declared access modes | | `pv` | PersistentVolume behind the claim or attachment | | `pvc` | PersistentVolumeClaim the pod mounts (same namespace as the pod) | | `node` | node the attachment targets or the pod is scheduled on | | `attacher` | CSI driver responsible for the attachment (spec.attacher) | | `age` | how long the attach/detach error has persisted, truncated to seconds | | `error` | the attach/detach error message, truncated to 200 chars | | `attached` | the attachment’s status.attached at scan time | | `pv_zones` | zones the PV’s node affinity allows, sorted | | `node_zone` | zone label of the node the pod is scheduled on | | `orphan` | which referenced side is gone: “pv missing”, “node missing”, or both | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state volumes lookout state volumes --namespace=prod lookout state volumes --format=json ``` # lookout state webhooks > When creates/updates hang or fail cluster-wide with "failed calling webhook", or before relying on a policy engine: audit every admission webhook — dead backends × failurePolicy (Fail + dead backend rejects every matching admission), the namespace/rule blast radius, timeout stall risk, CA-bundle expiry. The full check; health's webhooks category delegates here. When creates/updates hang or fail cluster-wide with “failed calling webhook”, or before relying on a policy engine: audit every admission webhook — dead backends × failurePolicy (Fail + dead backend rejects every matching admission), the namespace/rule blast radius, timeout stall risk, CA-bundle expiry. The full check; health’s webhooks category delegates here. MCP tool: `k8s_admission_webhooks` ## Usage [Section titled “Usage”](#usage) ```sh lookout state webhooks [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ------------- | -------- | ------- | ----------------------------------------------------- | | `--cert-warn` | duration | `720h` | report webhook CA bundles expiring within this window | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------- | | `webhook.failing_closed` | critical | the webhook has no working backend and failurePolicy=Fail: every gated write is rejected cluster-wide | | `webhook.dead_backend` | warning | the webhook’s service backend is missing, has no ready endpoints, or does not serve the named port | | `webhook.slow_risk` | info | the webhook’s timeout is long enough to slow every gated write if the backend degrades | | `webhook.ca_expired` | critical | the webhook’s caBundle has expired: the API server cannot verify it | | `webhook.ca_expiring` | warning | the webhook’s caBundle expires within —cert-warn | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | | `webhook` | admission webhook as \/\ | | `service` | service backend the webhook points at, as \/\ | | `backend` | why the backend is dead: service missing, no ready endpoints, or port \

not on service | | `gates` | namespaces the webhook gates, from namespaceSelector: all namespaces, or \/\ namespaces with up to 5 names | | `rules` | compact operations/resources summary of the webhook’s rules, e.g. “CREATE,UPDATE pods,deployments.apps” | | `object_selector` | the webhook’s objectSelector, when one is set | | `timeout` | webhook timeoutSeconds as \s (nil defaults to the API’s 10s) | | `subject` | CA-bundle certificate subject (CN when set); never key material | | `not_after` | CA-bundle certificate NotAfter, RFC 3339 | | `days_left` | whole days until NotAfter (negative = expired) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state webhooks lookout state webhooks --format=json --cert-warn=336h ``` # lookout state wi > When a GKE pod gets 403s or metadata-server errors calling GCP APIs, verify the Workload Identity chain — KSA annotation (iam.gke.io/gcp-service-account) → roles/iam.workloadIdentityUser binding on the GSA — reporting only the broken links; vanilla clusters report an explicit unavailable. When a GKE pod gets 403s or metadata-server errors calling GCP APIs, verify the Workload Identity chain — KSA annotation (iam.gke.io/gcp-service-account) → roles/iam.workloadIdentityUser binding on the GSA — reporting only the broken links; vanilla clusters report an explicit unavailable. MCP tool: `k8s_workload_identity` ## Usage [Section titled “Usage”](#usage) ```sh lookout state wi [flags] ``` ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `wi.gsa_missing` | critical | the annotated Google service account does not exist — every GCP call from these pods fails | | `wi.unbound` | critical | the KSA annotates a GSA but the roles/iam.workloadIdentityUser binding is missing or malformed | | `wi.unannotated_use` | info | a pod sets GOOGLE\_APPLICATION\_CREDENTIALS but its ServiceAccount carries no Workload Identity annotation | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------- | ------------------------------------------------------------------------------------ | | `gsa` | the cloud identity (GSA email) the ServiceAccount’s annotation claims | | `pods` | how many in-scope pods run as the affected ServiceAccount | | `problem` | machine-matchable problem code from the provider (e.g. no-workload-identity-binding) | | `container` | container carrying the GOOGLE\_APPLICATION\_CREDENTIALS env var | | `env` | the credential-file env var found (GOOGLE\_APPLICATION\_CREDENTIALS) | | `capability` | cloud.unavailable: the provider capability this command needed (workload-identity) | | `provider` | cloud.unavailable: the provider that was asked | | `unavailable` | summary-line note: why the cloud read could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout state wi lookout state wi --namespace=prod lookout state wi --workload=Deployment/prod/api --format=json ``` # lookout triage changes > What changed around one workload in the window before onset — rollouts, config/secret updates, rescales, node ops — chronological, scoped to the target's graph neighborhood; full fidelity from a sentinel store, best-effort live otherwise. What changed around one workload in the window before onset — rollouts, config/secret updates, rescales, node ops — chronological, scoped to the target’s graph neighborhood; full fidelity from a sentinel store, best-effort live otherwise. MCP tool: `k8s_recent_changes` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage changes /[/] [flags] ``` `/[/]` — the pod or workload at the center of the question: \/\/\, \/\ (namespace from —namespace, else “default”), or a bare pod name; kinds are case-insensitive with the usual short forms (po, deploy, rs, sts, ds, cj). —workload=\/\/\ is the flag-shaped alternative. ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | --------- | ---- | ------- | ----------------------------------------------------------------------------------------------------- | | `--depth` | int | `2` | neighborhood radius: graph edges followed per direction to decide which objects’ changes are in scope | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Point-in-time flags (graph-backed commands) [Section titled “Point-in-time flags (graph-backed commands)”](#point-in-time-flags-graph-backed-commands) This command answers from the topology graph and accepts the point-in-time flags: | Flag | Type | Default | Meaning | | ----------------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--at` | string | — | answer as of this instant instead of live: RFC3339 (2026-07-25T10:00:00Z) or a duration ago (20m). Requires —store. | | `--store` | string | — | path to a sentinel’s SQLite store (its —store file); source for —at point-in-time topology | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------- | -------- | ------------------------------------------------------------------------------------------------ | | `change.rollout` | info | a workload’s pod template changed — a new image, container or mount, or a controller churn event | | `change.scale` | info | a workload’s replica count changed | | `change.config` | info | a ConfigMap in the neighborhood changed | | `change.secret` | info | a Secret in the neighborhood changed (names and shortened hashes only, never values —) | | `change.node` | info | a Node in the neighborhood changed | | `change.label` | info | only labels changed on a neighborhood object — enough to move it in or out of a selector | | `change.topology` | info | a neighborhood object appeared, disappeared, or changed in a way none of the other classes name | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ---------- | --------------------------------------------------------------------------------------------------------------------- | | `at` | when the change happened, RFC 3339 (also the summary-line note for the resolved —at instant) | | `relation` | the changed object’s place in the target’s neighborhood: self (the target or its pods), upstream, lateral, downstream | | `fields` | changed fields as path=from→to pairs — names, counts, and shortened hashes only, never values | | `origin` | where the change was seen: log (delta log), event (Kubernetes Event), api (reconstructed from current API state) | | `revision` | deployment.kubernetes.io/revision of a rollout’s ReplicaSet (live approximation) | | `image` | first container image of a rollout’s new pod template (live approximation) | | `window` | summary-line note: the (from, to] window the answer covers, RFC 3339 | | `source` | summary-line note: history (delta log from —store) or live-approximation (no store; see the fidelity gap in —help) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage changes Deployment/prod/api --store=/var/lib/lookout/lookout.db lookout triage changes Deployment/prod/api --since=1h --at=2026-07-25T10:00:00Z --store=/var/lib/lookout/lookout.db lookout triage changes payments-api-7d9c4b-x2n8p --namespace=prod ``` # lookout triage delta > Every abnormal object in one scan — the first call for "anything wrong in this cluster?": broken/pending pods, stalled rollouts, workloads blocked from creating pods at all, node pressure/NPD/preemption, gridlocked PDBs, degraded kube-system add-ons, quotas at their limits. Every abnormal object in one scan — the first call for “anything wrong in this cluster?”: broken/pending pods, stalled rollouts, workloads blocked from creating pods at all, node pressure/NPD/preemption, gridlocked PDBs, degraded kube-system add-ons, quotas at their limits. MCP tool: `k8s_triage_delta` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout triage delta [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | --------------- | -------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `--only` | string | `pods,nodes,pdb,system,quota` | comma-separated finding classes to scan: any subset of pods,nodes,pdb,system,quota | | `--restarts` | int | `5` | flag containers restarted at least this many times | | `--pending-age` | duration | `5m` | flag Pending pods older than this; also the grace before a not-ready container in a Running pod is flagged | | `--quota-warn` | int | `90` | warn when a ResourceQuota resource reaches this percent of its hard limit (the hard limit itself is always critical) | | `--cron-grace` | duration | `5m` | how late a CronJob activation may be before it counts as missed; absorbs normal controller scheduling latency | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pod.crashloop` | critical | a container is crash looping | | `pod.imagepull` | critical | a container cannot pull its image | | `pod.waiting` | warning | a container is stuck in an error waiting state (CreateContainerConfigError, InvalidImageName, …) | | `pod.oomkilled` | warning | a container’s last termination was an OOM kill | | `pod.restarts` | warning | a container has restarted at least —restarts times | | `pod.notready` | warning | a container in a Running pod has been not-ready past the —pending-age grace | | `pod.failed` | warning | the pod reached phase Failed | | `pod.pending` | critical, warning | the pod has been Pending longer than —pending-age with no container-level diagnosis; critical when the scheduler has declared it Unschedulable, which is a capacity or constraint problem rather than latency | | `workload.replicafailure` | critical | the controller cannot create pods at all (quota, PodSecurity, admission) — no pod exists to diagnose | | `workload.stalled` | critical | a Deployment’s Progressing condition is False: the rollout has given up | | `workload.rollout` | critical, warning | replicas are short of desired; critical when nothing is serving at all | | `job.failed` | warning | a Job’s Failed condition is set | | `cron.missed` | critical, warning | an unsuspended CronJob’s schedule said to run more than —cron-grace ago and status says it did not; critical once several activations in a row are gone | | `cron.unparseable` | warning | a CronJob’s spec.schedule could not be parsed, so its activations cannot be judged at all | | `node.notready` | critical | the node’s Ready condition is not True | | `node.pressure` | critical | the node reports Memory/Disk/PID pressure | | `node.condition` | critical, warning | a non-standard node condition is True — NPD and its cousins publish problems that way | | `node.cordoned` | warning | the node is unschedulable but still holds pods: a stuck drain or a forgotten maintenance step | | `node.preempt` | critical, warning, info | a reclaim taint marks the node for termination; severity tracks how imminent | | `pdb.gridlocked` | critical, warning | the budget permits no disruptions; critical when healthy pods are already below the required minimum | | `addon.degraded` | critical, warning | a kube-system add-on (dns, proxy, cni, csi, metrics, connectivity) is short of replicas; critical when none are available | | `quota.near` | warning | a ResourceQuota resource is at or past —quota-warn percent of its hard limit | | `quota.exhausted` | critical | a ResourceQuota resource is at its hard limit: the next create is rejected | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | --------------- | ------------------------------------------------------------------- | | `container` | container the finding is about (init containers prefixed init:) | | `image` | image reference that failed to pull | | `restarts` | container restart count | | `exit_code` | exit code of the container’s last termination | | `last_state` | reason of the container’s last termination (e.g. OOMKilled) | | `age` | how long the abnormal state has persisted | | `desired` | desired replica/scheduled count from spec | | `ready` | ready count from status | | `updated` | updated-to-current-revision count from status | | `available` | available count from status | | `failed` | failed pod count of a Job | | `schedule` | a CronJob’s spec.schedule | | `expected` | the activation a CronJob should have run and did not | | `missed_runs` | activations missed since the anchor; ≥N when the walk was capped | | `anchor` | what the missed count was measured from: last\_schedule or creation | | `time_zone` | a CronJob’s spec.timeZone, when set | | `last_schedule` | a CronJob’s status.lastScheduleTime, or never | | `active_jobs` | Jobs a CronJob still has running | | `condition` | node condition type that is abnormal | | `taint` | taint key indicating reclaim/drain | | `pods` | pods affected (behind a cordoned node or a PDB) | | `healthy` | currently healthy pods behind a PDB | | `required` | pods the PDB requires healthy | | `addon` | system add-on role: dns, proxy, cni, csi, metrics, connectivity | | `resource` | ResourceQuota resource name at or near its limit | | `used` | quota usage from status | | `hard` | quota hard limit from status | | `pct` | quota usage as percent of the hard limit | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage delta lookout triage delta --namespace=prod --only=pods,quota lookout triage delta --only=nodes --format=json ``` # lookout triage events > Deduped chronological event timeline: kubectl get events, but collapsed by (object, reason family) over a workload's whole owner-reference tree, with HPA rescale-oscillation (thrash) detection. Deduped chronological event timeline: kubectl get events, but collapsed by (object, reason family) over a workload’s whole owner-reference tree, with HPA rescale-oscillation (thrash) detection. MCP tool: `k8s_event_timeline` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage events [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------------- | -------- | ------- | ------------------------------------------------------------------------------------------- | | `--hpa-window` | duration | `30m` | report event.hpa\_thrash when enough scale-direction changes fall inside a window this long | | `--hpa-flips` | int | `2` | scale-direction changes within —hpa-window that count as thrash (2 = up→down→up) | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `event.warning` | warning | one collapsed timeline entry for a Warning-type event family on a subject | | `event.normal` | info | one collapsed timeline entry for a Normal-type event family — context for the warnings around it, not a problem on its own | | `event.hpa_thrash` | warning | an HPA changed scale direction at least —hpa-flips times inside —hpa-window: the autoscaler is fighting itself | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------------------- | | `count` | events collapsed into this timeline entry: k8s per-event repeat counts summed across the entry’s reason family | | `first_seen` | RFC3339 timestamp of the entry’s oldest activity | | `last_seen` | RFC3339 timestamp of the entry’s newest activity (the timeline sort key) | | `source` | reporting component (kubelet, horizontal-pod-autoscaler, …) | | `variants` | raw Event.Reason values collapsed into this entry, comma-separated (present only when a reason family merged more than one) | | `replicas` | event.hpa\_thrash: the chronological replica sequence recovered from SuccessfulRescale events, e.g. 2->6->2->6 | | `flips` | event.hpa\_thrash: most scale-direction changes observed inside one —hpa-window | | `window` | event.hpa\_thrash: the —hpa-window the flips were counted in | | `target` | event.hpa\_thrash: the HPA’s scaleTargetRef as Kind/name (when the HPA object was readable) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage events --workload=Deployment/prod/api lookout triage events --workload=Pod/prod/api-6d5f8c-x2v9k --since=30m lookout triage events --namespace=prod lookout triage events -A --since=2h --format=json lookout triage events --workload=Deployment/prod/api --hpa-window=15m --hpa-flips=4 ``` # lookout triage list > List what EXISTS in a namespace — kubectl get across every kind at once, one line per object, leading with the // target the other read tools take. The first call for a namespace you have not enumerated: the health scans report only what is abnormal and name nothing when a namespace is clean, so they cannot tell you what is in one. An inventory, not a diagnosis — never guess an object's name, list the namespace. List what EXISTS in a namespace — kubectl get across every kind at once, one line per object, leading with the \/\/\ target the other read tools take. The first call for a namespace you have not enumerated: the health scans report only what is abnormal and name nothing when a namespace is clean, so they cannot tell you what is in one. An inventory, not a diagnosis — never guess an object’s name, list the namespace. MCP tool: `k8s_list_resources` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout triage list [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | --------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--kinds` | string | — | comma-separated resource kinds to list, spelled as kubectl spells them (pods, deploy, certificates.cert-manager.io); empty lists the default set — deployments,statefulsets,daemonsets,cronjobs,jobs,pods,services,endpoints,ingresses,configmaps,secrets,persistentvolumeclaims,horizontalpodautoscalers,poddisruptionbudgets,serviceaccounts,networkpolicies,resourcequotas,limitranges — which is every namespaced kind an incident normally involves EXCEPT replicasets (one per Deployment revision; ask for them explicitly) | | `--max` | int | `500` | stop after this many objects; the summary line reports how many were left out (pass —kinds to narrow instead) | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `inventory.object` | info | one object in scope, rendered as kubectl’s default columns for its kind — an aggregated `kubectl get`, so every row is emitted, healthy or not | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `target` | the object as \/\/\ (\/\ when cluster-scoped) — paste it into triage spec, state edges, triage radius or triage workload unchanged | | `ready` | ready over desired, as kubectl’s READY column: containers for a Pod, replicas for a workload | | `status` | kubectl’s STATUS column verbatim: a Pod’s phase or its blocking container reason, a Job’s condition, a Node’s readiness | | `restarts` | total container restarts of a Pod | | `up_to_date` | replicas on the current revision | | `available` | replicas counted available | | `completions` | a Job’s succeeded over requested completions | | `schedule` | a CronJob’s cron expression | | `timezone` | a CronJob’s spec.timeZone, when it sets one | | `suspend` | “true” on a suspended CronJob (omitted otherwise) | | `active` | a CronJob’s currently running Jobs | | `last_schedule` | how long ago a CronJob last created a Job | | `type` | a Service’s type or a Secret’s type | | `cluster_ip` | a Service’s cluster IP (“None” for a headless Service) | | `external_ip` | a Service’s provisioned load-balancer address, or “pending” for a LoadBalancer that has none yet | | `ports` | a Service’s ports as port\[:nodePort]/protocol | | `addresses` | how many endpoint addresses an Endpoints object holds (0 means nothing is behind the Service) | | `class` | an Ingress’s ingressClassName or a PVC/PV’s storage class | | `hosts` | an Ingress’s rule hosts | | `address` | an Ingress’s provisioned load-balancer address | | `keys` | how many keys a ConfigMap or Secret holds — Secret VALUES are never read, only counted | | `phase` | status.phase of a PVC, PV or Namespace | | `volume` | the PersistentVolume a PVC is bound to | | `capacity` | a PVC’s or PV’s storage capacity | | `access_modes` | a PVC’s or PV’s access modes, kubectl-abbreviated (RWO, ROX, RWX, RWOP) | | `claim` | the PVC a PersistentVolume is bound to, as \/\ | | `scale_target` | an HPA’s scaleTargetRef as \/\ | | `min` | an HPA’s minimum replicas | | `max` | an HPA’s maximum replicas | | `replicas` | an HPA’s current replica count | | `min_available` | a PDB’s spec.minAvailable (count or percentage) | | `max_unavailable` | a PDB’s spec.maxUnavailable (count or percentage) | | `allowed_disruptions` | how many pods a PDB currently allows to be evicted | | `pod_selector` | a NetworkPolicy’s spec.podSelector; “all” when it is empty, which selects every pod in the namespace | | `roles` | a Node’s node-role.kubernetes.io/\* labels, or “none” | | `version` | a Node’s kubelet version | | `age` | time since metadata.creationTimestamp, kubectl-style (45s, 3h20m, 12d) | | `kinds` | summary-line note: how many kinds the listing covered | | `truncated` | summary-line note: how many objects —max left out; they are the LAST kinds of the listing, which is ordered workloads → routing → configuration for this reason | | `skipped` | summary-line note: kinds that could not be listed and why, as \:\ (forbidden = the caller may not list it, so its absence from the output is a blind spot, not a fact) | | `namespace_absent` | summary-line note: “true” when the listing was empty because the namespace does not exist, which an empty listing alone cannot distinguish from an empty namespace | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage list --namespace=storefront lookout triage list --namespace=prod --kinds=pods,services,endpoints lookout triage list --namespace=prod --kinds=replicasets lookout triage list -A --kinds=ingresses --format=json ``` # lookout triage logs > kubectl logs, distilled: Drain-clusters raw lines into templates with counts (probe noise stripped, stack traces collapsed to top frames) — reach for this instead of reading logs whole. kubectl logs, distilled: Drain-clusters raw lines into templates with counts (probe noise stripped, stack traces collapsed to top frames) — reach for this instead of reading logs whole. MCP tool: `k8s_triage_logs` (MCP profile: `triage`) ## Usage [Section titled “Usage”](#usage) ```sh lookout triage logs [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | ----------------- | ------ | ------- | ------------------------------------------------------------------------------------------- | | `--pod` | string | — | read one pod by name (requires —namespace) | | `--container` | string | — | restrict to one container (default: all init + regular + ephemeral containers) | | `--previous` | bool | — | read the previous container instance (what a crashed container said before it died) | | `--tail` | int | `5000` | max lines fetched per container stream (0 = no limit) | | `--max-templates` | int | `40` | cap emitted template clusters; the low-count tail is summarized in one log.overflow finding | | `--keep-probes` | bool | — | keep health/readiness probe request lines instead of stripping them | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `log.template` | critical, warning, info | one distilled template and how many lines collapsed into it; severity is the guessed level — critical at fatal, warning for error-ish, info otherwise | | `log.stacktrace` | critical, warning, info | a template that is a Go panic, Java exception, or Python traceback, with its innermost frames | | `log.overflow` | info | the low-count tail —max-templates dropped, counted rather than discarded silently (no coverage lies) | | `log.probe_noise` | info | health/readiness probe request lines stripped before distillation, counted so the removal is visible | | `log.fetch_error` | warning | a container’s log stream could not be read, so its lines are missing from the distillation | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------- | -------------------------------------------------------------------------------- | | `template` | log template; <\*> marks positions that varied across merged lines | | `count` | lines merged into this cluster (on log.probe\_noise: probe lines stripped) | | `pods` | distinct pods that emitted this template (present when >1) | | `level` | guessed log level (fatal\|error\|warn\|info\|debug) from token/field match | | `first_seen` | RFC3339 timestamp of the oldest merged line (from log timestamps when parseable) | | `last_seen` | RFC3339 timestamp of the newest merged line | | `lang` | stack-trace runtime on log.stacktrace findings: go\|java\|python | | `frames` | top stack frames on log.stacktrace findings, innermost first, ’ < ’ separated | | `sample` | one representative raw line, truncated and sanitized | | `container` | container the finding refers to (log.fetch\_error only) | | `omitted_templates` | clusters dropped by —max-templates (log.overflow only) | | `omitted_lines` | lines inside the dropped clusters (log.overflow only) | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage logs --workload=Deployment/prod/api --since=30m lookout triage logs --namespace=payments --previous --container=app lookout triage logs --pod=api-6d5f9c7b4-xk2p1 --namespace=prod --format=json ``` # lookout triage radius > Blast radius of one pod/workload — who is upstream (routes/owns/governs it), lateral (same node, shared config/volume), downstream (it depends on); --at answers it as of incident onset from a sentinel store. Blast radius of one pod/workload — who is upstream (routes/owns/governs it), lateral (same node, shared config/volume), downstream (it depends on); —at answers it as of incident onset from a sentinel store. MCP tool: `k8s_blast_radius` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage radius /[/] [flags] ``` `/[/]` — the pod or workload at the center of the question: \/\/\, \/\ (namespace from —namespace, else “default”), or a bare pod name; kinds are case-insensitive with the usual short forms (po, deploy, rs, sts, ds, cj). —workload=\/\/\ is the flag-shaped alternative. ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | --------- | ---- | ------- | --------------------------------------------------------- | | `--depth` | int | `3` | graph edges followed per direction from the target’s pods | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Point-in-time flags (graph-backed commands) [Section titled “Point-in-time flags (graph-backed commands)”](#point-in-time-flags-graph-backed-commands) This command answers from the topology graph and accepts the point-in-time flags: | Flag | Type | Default | Meaning | | ----------------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--at` | string | — | answer as of this instant instead of live: RFC3339 (2026-07-25T10:00:00Z) or a duration ago (20m). Requires —store. | | `--store` | string | — | path to a sentinel’s SQLite store (its —store file); source for —at point-in-time topology | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- | | `radius.neighbor` | info | one object in the target’s neighborhood, with its direction, relation, and hop distance — an enumeration of impact, not a defect | | `radius.missing` | warning | a neighbor the graph references but never observed, in a kind the snapshot does watch: the reference is dangling | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `direction` | neighbor’s direction from the target: upstream (routes/owns/governs it), lateral (shares a node/volume/config), downstream (the target points at it) | | `relation` | how the neighbor attaches: the edge kind (RoutesTo, Owns, Selects, Governs, RunsOn, Mounts) for upstream/downstream, shared-node\|shared-zone\|shared-config\|shared-secret\|shared-pvc for lateral | | `hop` | BFS depth from the target at which the neighbor was first reached (1 = direct edge) | | `shared` | on lateral neighbors: the shared object as \/\ | | `ready` | pod readiness (live mode only — history stores topology, not status) | | `source` | summary-line note: live (one-shot List pass) or history (reconstructed from —store) | | `at` | summary-line note: the resolved —at instant the history answer is as of, RFC 3339 | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage radius Deployment/prod/api lookout triage radius payments-api-7d9c4b-x2n8p --namespace=prod lookout triage radius --workload=StatefulSet/db/postgres --depth=2 lookout triage radius Deployment/prod/api --at=20m --store=/var/lib/lookout/lookout.db ``` # lookout triage spec > Read ONE resource's spec: kubectl describe, but token-dense, secret-safe, and default-elided — healthy conditions are omitted. Read ONE resource’s spec: kubectl describe, but token-dense, secret-safe, and default-elided — healthy conditions are omitted. MCP tool: `k8s_resource_spec` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage spec /[/] [flags] ``` `/[/]` — the resource to read; Kind is case-insensitive, accepts the aliases po=Pod, deploy=Deployment, rs=ReplicaSet, sts=StatefulSet, ds=DaemonSet, svc=Service, cm=ConfigMap, pvc=PersistentVolumeClaim, ing=Ingress, netpol=NetworkPolicy, no=Node, and unlisted kinds (CRDs) resolve via API discovery (qualify as \.\ if ambiguous). Omit \ for cluster-scoped kinds, or to use —namespace (falling back to “default”). —workload=\/\/\ is the flag-shaped alternative. ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------- | ---- | ------- | ------------------------------------------------------------------------------------------------- | | `--diff` | bool | — | diff against the previous graph-history revision — requires a sentinel store; not yet implemented | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | ---------------- | -------- | ------------------------------------------------------------------------------------- | | `spec.resource` | info | the object itself: metadata, owner, and the kind-specific highlights (one per target) | | `spec.container` | info | one container of the target: image, resources, ports, probes, env (one per container) | | `spec.condition` | warning | a status condition of the target that is not in its nominal state | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------- | | `labels` | resource labels as sorted k=v pairs | | `owner` | controlling owner as Kind/name | | `phase` | status.phase, only when abnormal for the kind (zero nominal state) | | `node` | node the pod is scheduled on | | `service_account` | pod’s service account | | `volumes` | pod volumes as name:source (source names its referent, never its payload) | | `container` | container name (one spec.container finding per container) | | `init` | “true” when the container is an init container | | `image` | container image reference | | `requests` | resource requests as sorted k=v pairs | | `limits` | resource limits as sorted k=v pairs | | `ports` | container or service ports, compact (\[name:]port\[->target]\[/proto]) | | `liveness` | liveness probe one-liner (kind, target, non-default timings) | | `readiness` | readiness probe one-liner | | `env` | env vars; literal credential values are \[REDACTED], valueFrom entries render as named references | | `env_from` | envFrom sources as kind:name | | `replicas` | desired replica count | | `strategy` | rollout strategy summary (type + non-default knobs) | | `selector` | workload/service selector as sorted k=v pairs | | `type` | Service or Secret type, only when non-default | | `external_name` | ExternalName service target | | `session_affinity` | service session affinity, only when not None | | `keys` | ConfigMap/Secret data KEYS with byte sizes — values are never rendered | | `condition` | abnormal status condition as Type=Status | | `since` | the condition’s lastTransitionTime | | `spec` | kinds without a dedicated renderer: sanitized spec flattened to path=value pairs | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage spec Deployment/prod/api lookout triage spec po/payments-api-7d9c4b-x2n8p --namespace=prod lookout triage spec Node/gke-prod-pool-1-8f2a lookout triage spec Certificate/prod/api-tls --format=json lookout triage spec --workload=Deployment/prod/api ``` # lookout triage status > Write (or read back) the triage-status record for an incident — diagnosis, action taken, and your severity judgment — so health scans stop reporting it as a fresh unknown and the sentinel stops re-paging followups; the incident playbooks' closing move. Write (or read back) the triage-status record for an incident — diagnosis, action taken, and your severity judgment — so health scans stop reporting it as a fresh unknown and the sentinel stops re-paging followups; the incident playbooks’ closing move. MCP tool: `k8s_triage_status` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage status [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | --------------------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--store` | string | — | path to the sentinel’s SQLite store (its —store file). Required: triage-status records live in the sentinel’s —store SQLite file; see docs/triage-status-write-design.md | | `--store-cluster` | string | — | read/write the store for THIS cluster, treating —store as the multi-cluster stem the sentinel was given: —store=/var/lib/lookout/lookout.db —store-cluster=prod-us opens /var/lib/lookout/lookout-prod-us.db (issue #410). Set it only against a sentinel running —clusters/—clusters-from; a single-cluster sentinel writes the literal —store path | | `--fingerprint` | string | — | the incident-class fingerprint from the inject payload or store row (sha256:…). Required to write; to read, this or —resource selects the record(s) | | `--resource` | string | — | resource key pinning the record to one object: \/\/\ (namespace segment empty for cluster-scoped objects, e.g. Node//gke-node-1). Required to write | | `--status` | string | — | triage state to record: investigating\|triaged\|actioned\|escalated (resolved is written by the sentinel’s recovery flip, never by agents). Empty = read mode: print the current record(s) instead of writing | | `--session` | string | — | incident session id that produced this record — the paper trail’s pointer back to the transcript | | `--root-cause` | string | — | root-cause hypothesis one-liner | | `--severity-override` | string | — | your routing judgment for further signals of this incident: critical\|warning\|info (empty = keep the signal’s own class). Honored by sentinel routing and health scans while the record is open | | `--action` | string | — | action taken / paper trail (“fix PR opened; config rollout pending”) | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `triage.status` | info | the triage record for an incident subject as it now stands — state, root-cause hypothesis, action, and who wrote it; a receipt, not a defect | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ------------------- | --------------------------------------------------------------------------------- | | `resource_key` | the record’s resource pin, as stored (\/\/\) | | `triage_status` | the record’s triage state (investigating\|triaged\|actioned\|escalated\|resolved) | | `triage_root_cause` | the recorded root-cause hypothesis | | `triage_action` | the recorded action / paper trail | | `triage_session` | the incident session that wrote the record | | `severity_override` | the recorded severity judgment (critical\|warning\|info), when one is set | | `updated` | when the record last changed, RFC 3339 | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage status --store=/var/lib/lookout/lookout.db --fingerprint=sha256:e2957792a0b3 --resource=Pod/prod/checkout-697567895d-2gglt --session=sess-0004 --status=triaged --severity-override=warning --root-cause="DB connection string invalid in checkout-config" --action="fix PR opened; config rollout pending" lookout triage status --store=/var/lib/lookout/lookout.db --fingerprint=sha256:e2957792a0b3 lookout triage status --store=/var/lib/lookout/lookout.db --resource=Pod/prod/checkout-697567895d-2gglt ``` # lookout triage top > Point-in-time CPU/memory saturation vs limits: kubectl top, but judged — usage-vs-limit percent per container with the OOM asymmetry built in (memory ≥95% of limit is critical, CPU caps at warning: it throttles, it does not kill); -A adds node usage vs allocatable. Trends/ETAs live in the sentinel's saturation source; --history adds window stats via the cloud provider. Point-in-time CPU/memory saturation vs limits: kubectl top, but judged — usage-vs-limit percent per container with the OOM asymmetry built in (memory ≥95% of limit is critical, CPU caps at warning: it throttles, it does not kill); -A adds node usage vs allocatable. Trends/ETAs live in the sentinel’s saturation source; —history adds window stats via the cloud provider. MCP tool: `k8s_resource_top` ## Usage [Section titled “Usage”](#usage) ```sh lookout triage top [flags] ``` ## Flags [Section titled “Flags”](#flags) | Flag | Type | Default | Meaning | | -------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--top-warn` | int | `80` | report a container/node only at or above this percent of its limit/allocatable (zero nominal state; memory additionally turns critical at 95%) | | `--all` | bool | — | exploratory dump: emit every sampled row regardless of —top-warn (info severity below it), sorted by pct descending; containers capped at —limit | | `--limit` | int | `50` | row cap for the —all container dump | | `--show-unlimited` | bool | — | list each container missing a cpu or memory limit individually (default: one aggregate count) | | `--show-unrequested` | bool | — | list each container missing a cpu or memory request individually (default: one aggregate count); a missing request is the scheduler-side half of the census, always a subset of —show-unlimited | | `--history` | duration | — | enrich container findings with max/avg/p95 usage-vs-limit over this window via the cloud provider metrics backend; no provider → explicit unavailable finding + summary marker, point-in-time output unaffected | ## Common flags (every `lookout` command) [Section titled “Common flags (every lookout command)”](#common-flags-every-lookout-command) | Flag | Type | Default | Meaning | | -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--namespace` | string | — | limit the scan to one namespace | | `-A` | bool | — | scan all namespaces | | `--workload` | string | — | target one workload as \/\/\, e.g. Deployment/prod/api | | `--since` | duration | — | how far back to look (0 = command default) | | `--format` | string | `logfmt` | output format: logfmt\|json (one record per line either way) | | `--timeout` | duration | `10s` | abort the invocation after this long (exit 1) | | `--kubeconfig` | string | — | path to a kubeconfig file, instead of $KUBECONFIG / \~/.kube/config | | `--context` | string | — | kubeconfig context to read, instead of its current-context. Selects a cluster for THIS invocation only — nothing is written back — so concurrent invocations can target different clusters. Reported as context=\ in the summary line | | `--exemptions` | string | — | path to a git-reviewed exemption file (YAML); covered findings are ANNOTATED with their reason and expiry and counted as exempt=\ in the summary, never dropped | ## Finding kinds [Section titled “Finding kinds”](#finding-kinds) Every `kind=` this command can emit, and the severities it carries them at. Nothing else appears in its output; a kind absent from a run means the check looked and found nothing. See the [finding-kind glossary](/k8s-lookout/reference/finding-kinds/) for the whole vocabulary. | Kind | Severity | Claim | | --------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `top.saturation` | critical, warning, info | a container’s usage is close to its limit — critical near the limit, info for an —all row below the threshold | | `top.node` | critical, warning, info | a node’s allocatable is close to committed — critical near the limit, info for an —all row below the threshold | | `top.unlimited` | info | how many containers in scope set no cpu/memory limit, and are therefore invisible to saturation analysis | | `top.unlimited_container` | info | one container that sets no cpu/memory limit (—show-unlimited) | | `top.unrequested` | info | how many containers in scope set no cpu/memory request, so the scheduler bin-packs them as zero | | `top.unrequested_container` | info | one container that sets no cpu/memory request (—show-unrequested) | | `cloud.unavailable` | info | the cloud capability this check needs is unavailable, so nothing was examined — an explicit degradation record, never silence | ## Output fields [Section titled “Output fields”](#output-fields) Beyond the shared envelope fields (`kind`, `severity`, `namespace`, `kind_of_object`, `name`, `reason`, `message`, `fingerprint`, `exempt_reason`, `exempt_expires`): | Field | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `resource` | the judged dimension: cpu or memory | | `usage` | current usage in the dimension’s natural unit (millicores for cpu, IEC bytes for memory) | | `limit` | the container’s configured limit (top.saturation), same unit as usage | | `allocatable` | the node’s allocatable capacity (top.node), same unit as usage | | `pct` | usage as a percent of the limit/allocatable, one decimal | | `container` | container name within the pod | | `node` | node the pod runs on (top.saturation; top.node carries the node as name) | | `pods` | top.unlimited/top.unrequested: pods in scope with at least one container missing a cpu or memory limit (resp. request) | | `containers` | top.unlimited/top.unrequested: containers in scope missing a cpu or memory limit (resp. request) | | `missing` | top.unlimited\_container/top.unrequested\_container: which dimensions are absent (cpu, memory, or both) | | `limitrange` | top.unlimited\_container/top.unrequested\_container: the namespace LimitRange(s) that default a dimension this container is missing — the pod predates them, so recreating it picks the value up | | `limitrange_defaulted` | top.unlimited/top.unrequested: how many of the counted containers sit in a namespace whose LimitRange now defaults the dimension they lack | | `max_pct` | —history: highest usage-vs-limit percent observed in the window | | `avg_pct` | —history: mean usage-vs-limit percent over the window | | `p95_pct` | —history: 95th-percentile usage-vs-limit percent over the window | | `capability` | cloud.unavailable: the provider capability —history needed (metrics) | | `provider` | cloud.unavailable: the provider that was asked | | `history` | summary-line note: the —history window the stats cover | | `unavailable` | summary-line note: why —history could not be served | ## Output contract [Section titled “Output contract”](#output-contract) Output: one finding per line (logfmt; —format=json for one JSON object per line), keys in fixed order; healthy resources emit nothing. The final line is always the summary: scanned= findings= elapsed= — findings=0 with a summary present means “scanned and healthy”; a stream without a summary line is void. Exit 0 data, 1 runtime error (diagnostics on stderr only), 2 usage. ## Examples [Section titled “Examples”](#examples) ```sh lookout triage top --namespace=prod lookout triage top -A lookout triage top --workload=Deployment/prod/api lookout triage top --namespace=prod --all --limit=20 lookout triage top -A --top-warn=90 --show-unlimited lookout triage top -A --show-unrequested lookout triage top --namespace=prod --history=1h --format=json ``` # lookout watch > The resident per-cluster sentinel: every flag, derived from the live flag surface. `lookout watch` is the watch-path half of the binary: a resident per-cluster sentinel that turns leading indicators into per-incident agent sessions on a core-agent daemon. ## Usage [Section titled “Usage”](#usage) ```sh lookout watch [flags] ``` Signal sources are individually enabled via `--sources`; the default, `auto`, probes each portable source’s needs at startup and enables what the deployment supports. Flags of a disabled source are still validated — a nonsensical value is a config error in every mode. ## Flags [Section titled “Flags”](#flags) The table is generated from the sentinel’s real flag declarations (`internal/watch.FlagInventory`), sorted by name. The core flag surface is pinned by `TestFlagSurfaceFrozen`: removing or renaming one of those is a breaking change to running deployments, never a refactor. | Flag | Type | Default | Meaning | | ------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--access-recheck` | duration | `2m0s` | How often to re-run the SelfSubjectAccessReview probe over every enabled source’s declared access, so a grant revoked AFTER startup surfaces as a kind=sentinel.access\_revoked signal instead of a silently empty watch. A denial must repeat across two consecutive sweeps before it counts, so IAM propagation does not read as a revocation. Losing a REQUIRED permission stops this cluster’s runner (the same terminal path a startup refusal takes); an optional one degrades loudly and keeps running. 0 disables the re-check. | | `--backoff-min-count` | int | `3` | Require the crash-loop family (canonical CrashLoopBackOff — kubelet’s repeating BackOff cycle) to reach this Event.Count before firing, so a transient startup blip that self-heals does not open a noise session. Image-pull backoff is gated separately by —imagepull-transient-min-count. 1 fires on the first event. | | `--burn-eta` | duration | `30m0s` | Budget-exhaustion projection inside this window fires token.burn at critical (with the linear forecast); clearance requires the ETA to recede beyond 2x this threshold. Must be > 0. | | `--burn-multiple` | float | `4` | Session token rate at or above this multiple of the cross-session trailing-median baseline (sustained 2 polls) fires token.burn at warning. Must be > 1. | | `--capacity-poll` | duration | `1m0s` | Poll interval for the capacity source’s cluster-autoscaler-status ConfigMap read, provider scale-decision query, and pending-pod age sweep. Must be > 0. | | `--cluster-name` | string | — | Human-readable cluster name included in every inject payload. | | `--clusters` | string | — | Multi-cluster: comma-separated name=endpoint pairs to watch from one process, e.g. prod-us=abc.us-central1.gke.goog,prod-eu=def.europe-west1.gke.goog. A bare endpoint derives a short name from its first DNS label. Mutually exclusive with —clusters-from; needs a Fleet-capable provider (-tags gke). Leave empty for the one-sentinel-per-cluster default. | | `--clusters-from` | string | — | Multi-cluster: discover the clusters to watch instead of listing them. A project, or project/location, queried via the cloud provider’s cluster API (GKE: Container API ListClusters over the project; needs a Fleet-capable provider, -tags gke). Or the reserved value kubeconfig (optionally kubeconfig:\) to watch one cluster per context in a kubeconfig instead — no cloud API and no build tag, so EKS, on-prem and kind fleets work; with no path it follows client-go’s search ($KUBECONFIG, colon-separated and merged, else \~/.kube/config), which is also how you select a subset. Mutually exclusive with —clusters. | | `--compute-class-dwell` | duration | `10m0s` | How long a compute class’s rank verdict must persist before the source raises a finding. The resolve dwell (30m) and the flap guard are not separately tunable. Must be > 0. | | `--compute-class-infer` | bool | `true` | Cross-check GKE’s ccc\_priority\_index node annotation by independently matching each node against its compute class’s priority rules, and count every disagreement (lookout\_leeway\_preference\_disagreement). The annotation ALWAYS wins either way — this is a check on `k8s-lookout`’s model of the rules, not an override of GKE’s answer — so the only thing turning it off buys is silence on a cluster where the matcher is known to be behind the rules people write. Turning it off also blinds the unmatched and ambiguous counters, which are how that gap is meant to become visible. | | `--compute-class-last-rank-ceiling` | float | `0.9` | Raise leeway.rank\_degraded when more than this fraction of a class’s windowed pod-time ran at its LEAST-preferred priority. The default of 0.9 admits only two readings — capacity at every better priority is chronically unavailable, or the priority list is in the wrong order — and both are findings. Pass 0 to turn the rule off, which is the right answer for a class whose last rung is the cheap capacity it was always meant to run on. | | `--compute-class-rank0-floor` | float | — | Raise leeway.rank\_degraded when less than this fraction of a class’s windowed pod-time ran at its most-preferred priority. OFF by default (0), because on a class with three or more priorities the same reading also describes an estate that is merely mixed, and only somebody who knows what their class was for can say which. Set it when the first priority is the one that matters — a reservation, or the only family your licence covers. | | `--compute-class-tier-c-signals` | bool | — | Put Tier C compute-class findings on the wire. There is one: leeway.rank\_tier\_unused, a whole priority nothing has occupied for thirty days — a dead rung on the ladder, or reserved capacity being paid for and never drawn on. It is exported as a metric by default and not as a signal, because an unused priority is frequently the intended configuration and the finding is a bill to look at rather than a page. The rank\_wedged, rank\_degraded and rank\_no\_migration kinds always signal. | | `--compute-class-window` | duration | `1h0m0s` | How much recent history a compute class’s rank shares are a share of. The counters are cumulative, so a share has to be taken over a bounded window or a bad week in March goes on reporting in June. Longer smooths a class whose capacity comes and goes; shorter notices a fallback sooner and reads a rolling estate replacement as one. Composes with —compute-class-dwell rather than duplicating it: the window decides whether the condition is true now, the dwell decides whether it has been true long enough to say. Must be > 0. | | `--daemon-url` | string | — | Base URL of the core-agent daemon (http\://… or https\://…). Required. | | `--dedup-persist` | string | — | Optional path to persist dedup cache across sidecar restart. In multi-cluster mode this is a stem: each runner gets its own file, suffixed with the cluster’s project/location/name, so snapshots never clobber each other. | | `--dedup-window` | duration | `5m0s` | Rolling window for (uid,reason) dedup. | | `--degradation-drop` | float | `0.3` | Minimum ready-ratio decline from window start (with >= 2 distinct downward steps) that fires degradation.capacity. Must be in (0, 1]. | | `--degradation-window` | duration | `15m0s` | Trend window for the degradation source’s ready-ratio series and probe-flap counting. Must be > 0. | | `--distill-interval` | duration | `6h0m0s` | How often the distiller pass converts recurring occurrences into durable memory facts (requires —store; the pass reads the last 7d of occurrences). 0 disables distillation. Must be >= 0. | | `--dry-run` | bool | — | Watch the cluster for real (informers, sources, filter/dedup/routing all run) but print inject payloads to stdout instead of calling the daemon/sink. Needs cluster access like a normal run. | | `--enrich` | string | `critical` | Which severities get enrichment on their per-incident session’s initial inject: critical (default), warning (critical+warning), or off. | | `--enrich-cap` | int | `4096` | Byte budget for the attached enrichment bundle (fixed budget). Kept under —inject-max-bytes so the bundle plus the rest of the payload clears the daemon’s per-inject ceiling with headroom for the double-JSON envelope. Truncation happens at section boundaries; dropped sections become overflow trailers naming the `lookout` command that reproduces them. | | `--enrich-lists` | string | `all` | Which cluster resources the scoped-list enrichment fallback reads: ‘all’ (default), a comma-separated allowlist (pods,deployments), or subtractions (all,-secrets) to keep the watcher SA least-privilege. Denied or deselected lists degrade to a partial bundle with a skipped= note on the head, never a resolve failure. | | `--enrich-lists-preflight` | bool | — | Before the scoped-list pass, SelfSubjectAccessReview each selected resource and drop the denied ones proactively (fewer 403s in the watcher log); falls back to reactive Forbidden-skip if SSAR is not permitted. | | `--enrich-log-lines` | int | `200` | Log tail per container stream distilled into the enrichment bundle’s logs section. Must be >= 1. | | `--enrich-timeout` | duration | `5s` | Hard wall-clock budget for one enrichment run; on expiry the inject fires with whatever sections completed plus enrichment\_error trailers. Must be > 0. | | `--exclude-namespace` | string | — | Comma-separated deny-list of namespaces. Scopes the watch: the informers list and watch with a metadata.namespace!= field selector, so these namespaces never enter the cache. Cluster-scoped objects (nodes) are unaffected. | | `--expiry-interval` | duration | `1h0m0s` | Interval between expiry scans (periodic paged LISTs — deliberately no Secret informer). Must be > 0. | | `--expiry-namespaces` | string | — | Comma-separated namespaces the expiry scan LISTs secrets/serviceaccounts/Certificates in. Empty = all namespaces. Scopes the sensitive secrets-list grant — the startup RBAC probe verifies exactly this scope. | | `--expiry-warn` | duration | `336h0m0s` | Warning threshold for expiry.warning: certificates with notAfter inside this window fire at warning severity (critical at the design-fixed 72h). Must be >= 72h. | | `--gateway-grace` | duration | `5m0s` | How long a Gateway/HTTPRoute status condition (Programmed/Accepted/ResolvedRefs=False, reason != Pending) must be sustained — timed from its lastTransitionTime — before gateway.programming\_failed / gateway.route\_rejected fires. Absorbs normal LB provisioning latency. Must be > 0. | | `--graph-snapshot-interval` | duration | `5m0s` | How often to persist a compressed topology snapshot to —store (the per-delta change log is written continuously). Effective only with —store AND storm correlation on (the graph feed). Must be > 0. | | `--imagepull-transient-min-count` | int | `3` | Require an image-pull failure whose cause is RETRYABLE (registry 429/quota, 5xx, timeout, connection reset) to reach this Event.Count before firing, so a rate limit kubelet clears on its own does not open a noise session. Terminal causes (bad tag, denied, no space) and unrecognized ones still fire on the first event. 1 fires on the first event. | | `--in-cluster` | bool | — | Use in-cluster service account credentials. Auto-detected inside a pod. | | `--inject-max-bytes` | int | `8192` | Per-inject wire-body ceiling the dispatcher fits payloads to before POSTing (default matches the core-agent daemon’s 8192-byte limit). An over-limit payload is shrunk least-signal-first — enrichment dropped, then message truncated — never identity, so the incident still routes; without this the daemon 400s the whole inject and a new incident lands as an empty session (issue #198). | | `--kubeconfig` | string | — | Explicit kubeconfig path. Used outside a pod. | | `--log-level` | string | `info` | One of: debug, info, warn, error. | | `--metrics-addr` | string | — | Prometheus /metrics + /healthz + /readyz listener address (host:port). Empty = disabled. | | `--mode` | string | `per-incident` | Session routing mode: per-incident (create per (uid,reason)) or shared (all to —target-session). | | `--namespace` | string | — | Comma-separated allow-list of namespaces. Empty = all namespaces. A post-watch output filter, NOT a watch scope or a security boundary: every namespace is still listed, watched and cached. Use —exclude-namespace to shrink what is watched. | | `--notifications-subscription` | string | — | Subscription the notifications source reads (GKE: a Pub/Sub subscription on the cluster’s notificationConfig topic) — either projects/\

/subscriptions/\ or a bare name resolved against the provider project. Required when the notifications source is enabled. | | `--otel-exporter` | string | `none` | OpenTelemetry span exporter: none \| console \| otlp. The OTEL\_TRACES\_EXPORTER env var overrides this. | | `--owner` | string | — | X-Asserted-Caller value for POST /sessions in per-incident mode. Sidecar must be in daemon’s proxy\_identities. | | `--pending-age` | duration | `5m0s` | How long a pod must be Pending+Unschedulable before capacity.pending-aged fires at warning (critical at the design-fixed 15m, or at this value when set higher). Must be > 0. | | `--project` | string | — | Cloud project/account the cluster runs in, stamped into payloads. Empty = detect from the cloud provider’s metadata when a provider is compiled in; vanilla clusters can set it explicitly. | | `--quota-poll` | duration | `15m0s` | Poll interval for the quota source’s inventory read and per-watched-quota history query. Must be > 0. | | `--quota-warn` | float | `0.8` | Usage/limit ratio above which a quota is always watched (history fetched every poll) in addition to the top-10 nearest exhaustion. Must be in (0, 1). | | `--quota-window` | duration | `168h0m0s` | History window the quota usage slope is fitted over (the linear-\ confidence basis); a forecast needs usage points spanning at least half of it. Must be > 0. | | `--reason` | string | — | Comma-separated allow-list of Event.Reason values. Empty = shipped default set. | | `--recovery-stable-for` | duration | `5m0s` | How long a cleared symptom must stay clear before kind=resolved is injected into the incident’s session; recurrence within this window after a resolve fires kind=resolved.reverted. 0 disables recovery tracking. | | `--region` | string | — | Region the cluster runs in, stamped into payloads. Set for zonal and regional clusters alike. Empty = detect from the cloud provider’s metadata when a provider is compiled in; vanilla clusters can set it explicitly. Setting either —region or —zone stops metadata detection for BOTH — a half-flagged location would mix a flag’s region with a metadata zone somewhere else. | | `--rollout-observe` | duration | `3m0s` | How long a new revision must make zero ready-count progress (while the old revision stays healthy) before rollout.stall fires. Fired well before progressDeadlineSeconds. | | `--saturation-interval` | duration | `30s` | Sampling interval for the saturation source (metrics.k8s.io + kubelet volume stats). | | `--saturation-warn` | duration | `1h0m0s` | Forecast ETA below which saturation.forecast fires at severity warning (critical fires below 15m); clearance requires the ETA to recede beyond 2x this threshold. | | `--saturation-window` | duration | `1h30m0s` | Regression window for saturation forecasts; a forecast needs samples spanning at least half of it (the linear-\-window confidence basis). | | `--severity` | repeatable | — | Per-kind severity override(s): kind=level\[,kind=level…] with level one of critical\|warning\|info. Repeatable and additive; overrides the source-stamped default for that kind. Each kind may appear at most once. | | `--sink` | string | `core-agent` | Agent sink receiving incident payloads: core-agent (default: POST /sessions + /sessions/\/inject against —daemon-url) or webhook (generic receiver: POST \/incidents opens an incident with the schema-v1 payload JSON as the body; POST \/incidents/\/events appends follow-ups). | | `--sink-token-env` | string | — | Env var name holding the bearer token the webhook sink sends as Authorization: Bearer. Optional (unset = unauthenticated POSTs); only valid with —sink=webhook. | | `--sink-url` | string | — | Base URL of the generic webhook receiver (no trailing slash). Required with —sink=webhook. https is STRONGLY recommended: plain http is allowed (remote receivers are the point) but warns loudly at startup — incident payloads and the bearer token ride unencrypted. | | `--snapshot-interval` | duration | `30s` | How often to persist the dedup cache when —dedup-persist is set. 0 = only on shutdown. | | `--sources` | string | `auto` | Comma-separated signal sources to enable, or auto (the default): probe the portable sources’ needs at startup — RBAC via SelfSubjectAccessReview, plus metrics.k8s.io presence for saturation — and enable what this deployment supports, skipping misses with one loud line each (k8s-events must pass; a sentinel that cannot watch events is misdeployed). Known sources: k8s-events, object-state, rollout, workload, autoscaling, saturation, degradation, expiry, capacity, ingress, gateway, topology-drift, compute-class, quota, notifications, token-burn. quota (project tier), notifications (needs —notifications-subscription), and token-burn (core-agent cost stack) are never auto-enabled. An explicit list keeps semantics: a named source’s missing REQUIRED grant is fatal (optional dimensions — saturation’s nodes/proxy PVC read — still degrade loudly instead, issue #145). | | `--store` | string | — | Path to the sentinel-local SQLite occurrence store, e.g. /var/lib/lookout/lookout.db — put it on the —dedup-persist volume. Every emitted signal is recorded with its routing outcome; info-severity signals are persisted instead of dropped. In multi-cluster mode this is a stem: each runner opens its own file, suffixed with the cluster name, and —store-max-mb bounds EACH one. Reach a fleet file from the CLI with the same stem plus —store-cluster. Empty (default) disables the store. | | `--store-max-mb` | int | `512` | Size bound for the occurrence store in MiB; when exceeded, the oldest occurrences are pruned first (loudly). Bounds EACH store, so a multi-cluster fleet may use this much per cluster — a deliberate choice over a fleet budget divided N ways, which would make one cluster’s retention depend on how many clusters discovery found. Must be >= 1. | | `--store-ttl` | duration | `720h0m0s` | Retention for stored occurrences (default 30 days); the prune loop deletes older rows. Must be > 0. | | `--storm` | string | `auto` | Storm correlation: auto (the default — probe the graph informers’ grants at startup: pods/nodes/replicasets list+watch; all present resolves on, a miss resolves off with one loud line naming the grant), on (fatal at startup when a grant is missing), or off. true/false are aliases for on/off; bare —storm is no longer valid syntax. When on, new incidents sharing a blast-radius key (nearest common topology ancestor) group into one kind=storm session. | | `--storm-cluster-fallback` | bool | `true` | Group simultaneous NODE failures that nothing else groups (issue #334) under a synthetic Cluster ancestor, so a fleet-wide outage is one page instead of one per node. Applies only to nodes carrying no topology.kubernetes.io/zone label (a zone key is the modelled answer and always wins) and is deliberately expensive to trigger: a fifth of the fleet, at least 3 nodes, all inside 20s, and the storm expires after 5 idle minutes. Set false to keep one session per node. Requires storm correlation on. | | `--storm-min` | int | `3` | Minimum incidents sharing a blast-radius key within —storm-window to form a storm. Must be >= 2. | | `--storm-mine` | bool | — | Also correlate on DISCOVERED keys (issue #225): when —storm-mine-min incidents in the window share an exact image reference, node or container, group them into one storm even though no topology ancestor or external dependency connects them. Off by default — a mined key is circumstantial, so it needs more members than a modelled one, and every mined storm names what it grouped on. Requires storm correlation on. | | `--storm-mine-min` | int | — | Minimum incidents sharing a mined attribute value to form a storm. 0 (the default) means auto: the larger of 5 and —storm-min. An explicit value must be >= —storm-min — a discovered key must never be cheaper to form than a modelled one. Effective only with —storm-mine. | | `--storm-window` | duration | `1m0s` | Second-level correlation window for storm formation. 0 disables correlation even with —storm=on. | | `--target-session` | string | — | Required when —mode=shared: SessionID to post all injects to. | | `--token-budget-usd` | float | — | Per-session spend budget in USD for the token-burn source’s critical trigger; 0 (default) = unknown, budget trigger disarmed. Lookout-side config because core-agent v2.7.0 does not expose its CostCeiling over the attach API (TODO(core-agent) in pkg/sources/tokenburn). Must be >= 0. | | `--token-endpoint` | string | — | Override base URL for the core-agent cost stack (default: —daemon-url — the boundary rides the same daemon the injector talks to). No trailing slash. | | `--token-env` | string | — | Env var name holding the bearer token for the daemon. Required. | | `--token-poll` | duration | `1m0s` | Poll interval for the token-burn source’s cost-stack reads (core-agent GET /sessions + per-session /usage). Must be > 0. | | `--topology-baseline-band` | float | `4` | How many learned deviations wide a baseline’s tolerance band is — the false-positive knob for Tier C. A subject breaches when a domain’s share leaves the band around what it learned. Raise it if learned baselines are noisy on your cluster; the default is deliberately wide, because Tier C is the tier where nobody asked to be watched. Must be > 0. | | `--topology-baseline-half-life` | duration | `12h0m0s` | How long a learned baseline takes to half-absorb a step change in placement. Shorter follows a cluster that is legitimately rebalancing and stops calling it drift; longer keeps a longer memory of normal and so keeps noticing a slow slide that a short half-life would quietly adopt as the new normal. Must be > 0. | | `--topology-capacity-ratio` | float | `1.25` | How unequal a subject’s eligible zones have to be, as a max/min ratio of allocatable CPU, before an even split stops being the expectation and capacity does. Three zones where one is a quarter the size of the others cannot hold a third of anything; scoring them evenly reports drift on a cluster behaving exactly as its shape requires. Applies only where nobody declared a weighting — a policy that names one is always honoured. Raise it to keep the even expectation on mildly uneven clusters; below 1 it fires on any inequality at all. Must be > 0. | | `--topology-cluster-defaults` | string | — | Your cluster’s kube-scheduler PodTopologySpread defaultConstraints, as `key=maxSkew[:DoNotSchedule\|ScheduleAnyway]` comma-separated — they are not readable from a managed control plane, so leeway cannot find them out. THREE STATES: leave this unset and the upstream system defaults are ASSUMED (every intent from them is labelled source=cluster-default-assumed and can never raise a critical finding); pass “none” to assert your cluster configures none; or name them to be scored against your real numbers. These only ever apply to pods that declare no topologySpreadConstraints of their own. | | `--topology-domain-unavailable-keys` | string | `topology.kubernetes.io/zone,topology.kubernetes.io/region` | Comma-separated topology axes on which a domain with no usable node raises leeway.domain\_unavailable. The subject is the domain, so a zone that goes away is one finding for the cluster instead of one per workload that drifted because of it, and the workloads stay suppressed. Defaults to zone and region. Only name axes whose domains hold many nodes: an axis that is unique per node — kubernetes.io/hostname is one, and is a topology key — would raise a finding per NotReady node, which is objectstate’s job and not this one. Pass an empty string to turn the detector off. | | `--topology-dwell` | duration | `10m0s` | How long a placement breach must persist before the topology-drift source raises a finding. Placement is rebuilt constantly — by rollouts, by the descheduler, by a drain — so the dwell is what separates drift from motion. Shorter pages you during a routine rollout; the resolve dwell (30m) and the flap guard are not separately tunable. | | `--topology-keys` | string | `topology.kubernetes.io/zone,topology.kubernetes.io/region` | Comma-separated node labels the topology-drift source treats as topology axes, in precedence order. The defaults are the two standard well-known labels; a cluster that partitions on something else (a rack or cell label) names it here. | | `--topology-learn-baselines` | bool | `true` | Learn each workload’s normal placement, so that a workload which declared no spread constraint is scored against what it actually does instead of against an even split. Learning is passive and cheap: it samples every subject once a minute, matures after 6h, and only ever applies where nothing else expressed an intent — it cannot override a declared constraint. Turn it off to score every undeclared workload against an even apportionment, which is what happened before this existed. | | `--topology-max-node-groups` | int | `200` | How many node groups may be tracked before leeway tracks none of them. Past the bound nothing is scored and nothing is exported except the discovered count, which is the reading that sends you to —topology-node-group-keys; truncating instead would score an arbitrary subset that changes every pass. Pass a negative value to turn node-group subjects off entirely. | | `--topology-node-group-keys` | string | `cloud.google.com/compute-class,karpenter.sh/nodepool,eks.amazonaws.com/nodegroup,cloud.google.com/gke-nodepool,kops.k8s.io/instancegroup,agentpool` | Comma-separated node labels a node group’s name is read from, in precedence order, first match wins (FR-3). Node groups are tracked as subjects of their own, so a pool that was configured for three zones and has all its nodes in one is one finding naming the pool rather than one per workload riding it. Compute class comes before node pool in the default because auto-provisioned pools are named per machine type and are numerous and short-lived. A label that is unique per node turns every node into a group — see —topology-max-node-groups, which is what stops that reaching your metrics. | | `--topology-per-domain-collapse-states` | bool | `true` | Halve the per-domain series count by folding the four scheduling states onto two labels: state=“active” for an object holding the domain’s capacity (running or terminating) and state=“waiting” for one that is not (pending or unschedulable). The distinction needs between the four is upstream of the metric and is unaffected — this changes the label, not the count or any finding. Turn it off to get running/pending/unschedulable/terminating back, at twice the series. | | `--topology-per-domain-exclude-namespaces` | string | — | Comma-separated namespaces whose subjects never export a per-domain breakdown, applied AHEAD of —topology-per-domain-namespaces — naming a namespace in both excludes it. This is the knob for the one churning namespace that dominates the series count; it does not stop the namespace being watched, scored or alerted on, only its per-domain breakdown being exported. | | `--topology-per-domain-max-keys` | int | `4` | How many topology axes one subject may contribute a per-domain breakdown on. A cluster that names five or six axes in —topology-keys multiplies every admitted subject’s series by that many, and the axes past the first two or three are almost never the one being read. Which survive is the —topology-keys precedence order, so the cap is stable across scrapes rather than following whichever axis drifted. What it dropped is counted by lookout\_leeway\_domain\_series\_withheld{reason=“key\_cap”}. Pass a negative value for no cap. | | `--topology-per-domain-min-drift` | float | `0.05` | Drift (ρ, the fraction of a subject’s objects that would have to move) at which a subject’s per-domain breakdown is exported anyway. The default keeps the breakdown for the subjects somebody is about to investigate and withholds it for the rest, which is what makes the standing cost the aggregate one. Pass a negative value for every scored subject; —topology-per-domain-series overrides this entirely. | | `--topology-per-domain-namespaces` | string | — | Comma-separated namespaces whose subjects may export a per-domain breakdown. Empty — the default — admits every namespace, so this is the narrowing knob for a cluster that wants the breakdown standing for the namespaces it cares about and the aggregate everywhere else. Cluster-scoped subjects (node groups, and the domains themselves) are never filtered by this: they belong to no namespace, and dropping them would silently remove the cluster-wide reading. What it dropped is counted by lookout\_leeway\_domain\_series\_withheld{reason=“namespace”}. | | `--topology-per-domain-series` | bool | — | Export lookout\_leeway\_domain\_objects and lookout\_leeway\_domain\_expected for EVERY tracked subject, not only the drifting ones. OFF by default because the count is multiplicative: roughly 480k series on a 20k-subject cluster, against \~3.5k for every other leeway metric combined. See —topology-per-domain-min-drift for what you get without it. Turn this on to debug one cluster’s placement, not as a standing posture. | | `--topology-tier-c-signals` | bool | — | Put Tier C topology-drift findings on the wire. Tier C is the tier where nobody declared anything: the workload expressed no spread constraint or anti-affinity, so it was scored against an even apportionment over the domains it can reach — or, once one is learned, against its own baseline — and a breach says “this changed” rather than “this is wrong”. Those findings are exported as metrics only by default. Tiers A and B — a declared contract, or an intent inferred from what the workload does say — always signal. | | `--triage-regress-factor` | int | `3` | A downgraded incident (severity\_override) whose dedup-window count reaches this multiple of its count at downgrade time gets ONE kind=triage.regressed evidence followup into its bound session — never an automatic re-page (docs/triage-status-write-design.md). Must be >= 2; 0 disables. | | `--unhealthy-min-count` | int | `3` | Require this many consecutive Unhealthy events before firing. | | `--watchboard-batch` | int | `5` | Buffered warning-class signals that trigger a watchboard digest flush (per-incident mode). Must be >= 1. | | `--watchboard-flush` | duration | `1m0s` | Maximum age of a buffered warning before the watchboard digest flushes regardless of batch size. Must be > 0. | | `--watchboard-rotate` | int | `200` | Digest injects per watchboard session before size-based rotation opens a fresh session. Must be >= 1. | | `--zone` | string | — | Zone the cluster runs in, stamped into payloads. Set it only for a ZONAL cluster: a regional cluster has no zone of its own (its nodes are spread across the region’s zones), and leaving it empty there is the correct answer, not a gap. The failure domain — this zone when set, else —region — is what enters the signal fingerprint hash, so a deployment that stamps neither produces domain-less fingerprints: stable, but cross-cluster joins within a failure domain need one stamped. | See also: [Signal kinds](/k8s-lookout/reference/signal-kinds/) — everything the sentinel can inject; [Prometheus metrics](/k8s-lookout/reference/metrics/) — the `--metrics-addr` surface.