CI/CD gate¶
A pipeline needs a yes/no answer it can branch on, not a wall of text to grep.
kubeagent scan exits 0 whether the cluster is healthy or on fire — it is a
report, not a check — so a CI step could previously only gate a deploy by
parsing the text output for magic strings. kubeagent gate is a separate
subcommand built for that job: it runs the same read-only diagnosis and turns
it into a small, stable exit-code contract a build step can branch on
directly.
Two modes¶
- Pre-deploy sanity —
kubeagent gatewith no--wait-forjudges the whole cluster (or-n namespace) as it stands right now. Run it before a deploy to refuse to ship onto an already-broken cluster. - Post-deploy verify —
kubeagent gate --wait-for deployment/api -n prodwaits for that one workload's rollout to settle, then judges only the findings attributable to it. Run it after a deploy to confirm the thing you just shipped actually came up healthy, without an unrelated failure elsewhere in the namespace failing your build.
Flags¶
| Flag | Default | Meaning |
|---|---|---|
--kubeconfig |
$KUBECONFIG or ~/.kube/config |
path to kubeconfig |
--context |
current-context | kubeconfig context to use |
--output |
text |
output format: text | json | sarif |
--fail-on |
critical |
fail the gate at this severity or above: critical | warning | info |
--wait-for |
(empty — pre-deploy mode) | post-deploy verify: wait for this workload's rollout to settle, then judge only it (kind/name, e.g. deployment/api) |
--timeout |
5m0s |
with --wait-for: give up waiting after this long (exit 3) |
--poll-interval |
2s |
with --wait-for: how often to re-read the workload |
--allow-partial-read |
(none) | accept that this resource cannot be read, instead of exiting 2 (repeatable, e.g. leases) |
--namespace / -n |
all namespaces | namespace to judge |
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Pass — nothing at or above --fail-on |
1 |
Fail — findings at or above --fail-on |
2 |
Inconclusive — kubeagent could not see enough to judge |
3 |
Timeout — --wait-for did not settle within --timeout |
4 |
Usage — bad flags or arguments |
The boundary between 4 and 2 is deliberate, not incidental:
4covers input kubeagent never got to use against the cluster: a bad flag, an unparsable--wait-for, or a kubeconfig/context that fails to even build a client.cluster.NewClientbuilds arest.Configand a clientset without touching the network, so its failure is an unusable-input problem, the same class as a typo in a flag name — not a claim about the cluster's health.2covers everything that got as far as talking to the cluster but could not finish: an unreachable API server, an RBAC denial, a failed rollout poll, a failed scan, or a failed render (including a closed stdout pipe). An unreachable cluster or an RBAC-deniedlistsurfaces here — as a read failure during the scan — not as a startup failure, so it is never a4.
Why exit 2 exists, and is not opt-in¶
A gate that goes green because kubeagent could not see the cluster is worse than one that fails: it tells a pipeline "safe to ship" on the strength of no evidence at all. So a partial read is never silently downgraded to a pass — it costs a distinct exit code, and a pipeline that wants to soldier on through it has to say so explicitly, either broadly:
or narrowly, per resource, when the operator has already decided one specific read failure is acceptable to ignore:
A waived resource still appears in the output (RenderText prints it,
--output json lists it under inconclusive, marked waived) — an operator
should still see what they chose not to be told about, even though it no
longer forces exit 2.
One carve-out: a --policy rule kubeagent could not evaluate is exit 1, not
2, even when the read failure behind it also shows up as a blind spot (see
Policy as code).
The kubeagent gate || [ $? -eq 2 ] pattern above is for soldiering on
through a partial read — it is not meant to also soldier on through a rule
that never ran, so an unevaluated rule at or above --fail-on keeps the exit
code at 1 regardless of --allow-partial-read.
A preflight check for missing grants¶
kubeagent gate fails closed on a resource it cannot read — that is exit
2, deliberately, per the section above — but a red exit 2 on its own
doesn't say which grant is missing. Running kubeagent rbac check
--profile scan as an earlier step in the same pipeline turns "the gate is
red because a grant is missing" into a message that names the grant before
gate ever runs. Like gate, rbac check exits 1 when anything it
checked is blocked, so it composes into the same "fail the step and stop the
pipeline" pattern — see Least-privilege RBAC.
--wait-for scope¶
With --wait-for, only findings attributable to the named workload decide
the exit code: the workload itself, and anything owned by it (its pods).
Everything else the scan turned up is still printed, under a
not counted (outside scope) line, so an unrelated problem elsewhere in the
namespace is visible without failing the build for it.
Accepted kinds, case-insensitive, each also taking its kubectl short form
and plural:
| Kind | Aliases |
|---|---|
| Deployment | deployment, deployments, deploy |
| StatefulSet | statefulset, statefulsets, sts |
| DaemonSet | daemonset, daemonsets, ds |
Limitation — staged StatefulSet rollouts. kubeagent treats a StatefulSet
as settled only once its rollout has fully converged
(currentRevision == updateRevision). kubectl rollout status, by contrast,
calls a partition-based staged rollout complete as soon as the updated
count clears the partition boundary — it does not wait for every replica to
move onto the new revision. Gating a deliberately staged canary on
--wait-for statefulset/… will therefore never see kubeagent's stricter
condition, run out --timeout, and exit 3. This is a documented
limitation of this slice, not a bug: gate the final, un-partitioned step of
the rollout instead of the intermediate staged one.
Output formats¶
--output text — a failing gate, no --wait-for¶
GATE: fail — 1 finding at or above critical (scope: cluster)
critical Pod demo/broken-68bbbbcf5b-vn8vr ImagePullBackOff
Bad image reference or registry authentication (container "broken": Back-off pulling image "registry.k8s.io/does-not-exist:v1": ErrImagePull: rpc error: code = NotFound desc = failed to pull and unpack image "registry.k8s.io/does-not-exist:v1": failed to resolve reference "registry.k8s.io/does-not-exist:v1": registry.k8s.io/does-not-exist:v1: not found)
Exit code: 1.
--output text — a --wait-for-scoped pass, same cluster¶
The same cluster also has a healthy Deployment, api. Scoping the gate to
just that workload passes, even though the cluster as a whole has the
broken Deployment failing above:
Deployment/api in demo: 2/2 updated, 2 available
GATE: pass — nothing at or above critical (scope: Deployment/api in demo)
not counted (outside scope): 1 finding
Exit code: 0. The broken Deployment's finding is still real, still
running, and still reported — it is just outside demo/api's scope, so it
did not decide this exit code.
--output json¶
The same failing, unscoped run as above, as JSON:
{
"schemaVersion": "1.0",
"verdict": "fail",
"exitCode": 1,
"failOn": "critical",
"scope": "cluster",
"failing": [
{
"level": "critical",
"kind": "Pod",
"namespace": "demo",
"name": "broken-68bbbbcf5b-vn8vr",
"issue": "ImagePullBackOff",
"reason": "Bad image reference or registry authentication (container \"broken\": Back-off pulling image \"registry.k8s.io/does-not-exist:v1\": ErrImagePull: rpc error: code = NotFound desc = failed to pull and unpack image \"registry.k8s.io/does-not-exist:v1\": failed to resolve reference \"registry.k8s.io/does-not-exist:v1\": registry.k8s.io/does-not-exist:v1: not found)",
"owner": "Deployment/broken"
}
],
"reported": [],
"inconclusive": []
}
verdict and exitCode are derived together and never disagree: a shell
script reads exitCode (or the process exit status directly), a jq filter
reads verdict, and neither has to derive the other. failing only ever
holds findings that decided this exit code; reported holds everything else
kubeagent saw (out of scope, or below --fail-on); inconclusive lists any
blind spot, waived or not.
The shape of this document is versioned; see JSON schema contract.
SARIF¶
kubeagent gate --output sarif renders the same verdict as a
SARIF 2.1.0 document, so a
CI pipeline can upload kubeagent's findings straight to GitHub code
scanning.
SARIF results are keyed to a physicalLocation — normally a file and a line.
kubeagent findings are live cluster objects, not source files, so there is
nothing to point a line number at. Rather than invent one, the renderer
emits a synthetic k8s://<namespace>/<Kind>/<name> URI (or k8s://<Kind>/<name>
for a cluster-scoped object) and no region. Mapping a finding back to the
repo YAML that produced it — Helm values, kustomize overlays,
operator-created objects — is a separate, much larger problem and is
deliberately out of scope for this slice.
Severity maps onto SARIF's three levels:
| kubeagent | SARIF |
|---|---|
critical |
error |
warning |
warning |
info |
note |
An unwaived partial read — a blind spot the operator has not explicitly
accepted with --allow-partial-read — sets the run's executionSuccessful
to false, and so does a --wait-for that timed out. A waived partial read
does not: the operator already said that gap was acceptable. This is on
purpose: a code-scanning upload must not look clean when the gate was blind
or the rollout never settled, even though the exit code (not the SARIF
document) is what actually fails the build.
The same failing run rendered as SARIF:
{
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "kubeagent",
"version": "dev",
"informationUri": "https://github.com/imantaba/kubeagent",
"rules": [
{
"id": "ImagePullBackOff",
"name": "ImagePullBackOff",
"shortDescription": {
"text": "ImagePullBackOff"
},
"defaultConfiguration": {
"level": "error"
}
}
]
}
},
"results": [
{
"ruleId": "ImagePullBackOff",
"level": "error",
"message": {
"text": "Bad image reference or registry authentication (container \"broken\": Back-off pulling image \"registry.k8s.io/does-not-exist:v1\": ErrImagePull: rpc error: code = NotFound desc = failed to pull and unpack image \"registry.k8s.io/does-not-exist:v1\": failed to resolve reference \"registry.k8s.io/does-not-exist:v1\": registry.k8s.io/does-not-exist:v1: not found)"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "k8s://demo/Pod/broken-68bbbbcf5b-vn8vr"
}
}
}
]
}
],
"invocations": [
{
"executionSuccessful": true,
"toolConfigurationNotifications": []
}
]
}
]
}
"version": "dev" here is this build's own version string — a released
binary reports its tag instead. executionSuccessful is true in this
example because the run saw the cluster cleanly; it went blind on nothing,
so the failing finding above is a confident error, not a guess.
GitHub Actions example¶
- name: kubeagent gate
run: kubeagent gate --wait-for deployment/api -n prod --output sarif > kubeagent.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: kubeagent.sarif
- name: fail the build on a gate failure
run: kubeagent gate --wait-for deployment/api -n prod
The first step's continue-on-error: true matters: without it, a non-zero
exit from the SARIF run would stop the job before the upload step runs, and
the whole point of uploading is to see the findings even on a failing gate.
The second kubeagent gate invocation (plain text output, no
continue-on-error) is what actually fails the job — running the gate twice
is deliberate, not wasteful: one run's job is to produce the artifact, the
other's is to set the exit code.
Not in this slice¶
Deliberately absent, and not planned for this slice:
- The opt-in advisory sections —
--logs,--security,--certs,--operators,--drift,--capacity, and the three health probes (--kubelet-health,--control-plane-health,--dns-health) — are not exposed ongate. It runs the same bare scanscanruns with none of those flags set. - No mapping of findings back to repository YAML (Helm values, kustomize overlays, the manifest that actually produced the object).
- Diff mode covers restart rates only:
--baselinecompares this run's restart rates against a captured baseline. Nothing else is compared against a previous run — findings, inventory and resource usage are judged fresh each time. - No JUnit XML output.
- No packaged GitHub Action — the example above is a plain shell invocation a workflow can call directly.