|
| `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]
```
`/[/]