Kubernetes and Argo CD¶
In a cluster the gate is a Kubernetes Job that runs veridor run. Veridor's
exit code is the Job's result: exit 0 completes it, any other exit fails it.
There is no wrapper script and no || true. The Veridor repository ships that
Job three ways:
- The
veridor-gateHelm chart (deploy/helm/veridor-gate), in one of threehook.modes: an Argo CD PostSync hook, a Helm hook, or a plain Job. - A Kustomize plain Job (
deploy/kustomize/veridor-gate/overlays/plain), the chart's default Job without Helm, for a pipeline that applies it and waits for it. - An Argo Rollouts AnalysisTemplate (
deploy/argo-rollouts), which gates a canary before it takes all the traffic. It has its own page.
Not published
Neither the chart nor the image is in any registry, and the source
repository is private. The image ghcr.io/veridor-authz/veridor and the
example's chart repoURL are placeholders. Package the chart yourself (helm package
deploy/helm/veridor-gate) and serve it from your own Helm repository or a
Git source, build the image from the repository's Dockerfile, then set
image.repository and image.tag (Helm) or images: (Kustomize).
Pick one¶
| What you run | Use | What fails when the gate fails | What it does not do |
|---|---|---|---|
| Argo CD | the chart, hook.mode: argocd |
the sync operation (status.operationState.phase: Failed), read from that sync's own Job |
undo the sync: the new version is already serving; run at all on a selective or Apply Only sync (below) |
| The Helm CLI | the chart, hook.mode: helm, as a dependency of your app's chart |
helm upgrade exits non-zero; with --rollback-on-failure the release is rolled back |
gate the new version under Helm's default flags (below); run under Argo CD, which cannot render it |
kubectl, or anything else that applies manifests |
the chart's hook.mode: none, or the Kustomize plain Job |
nothing by itself: your pipeline must wait for this run's Job | anything, if the pipeline does not wait for the right Job |
| Argo Rollouts | the AnalysisTemplate | the rollout is aborted and the stable ReplicaSet kept | let a failing canary take all the traffic: this is the one that blocks |
PostSync reports; Rollouts blocks. A PostSync gate runs once the new version is serving, and a failed gate leaves it serving. To keep a failing version from taking the traffic, gate an Argo Rollouts canary.
The chart has no default mode. A Job carrying Argo CD annotations installed
with plain helm install runs, fails, and leaves the release green, so the
render refuses until you choose. Measured for this page with helm template
(Helm 4.2.2) on an earlier tree (before 38025ed): no hook.mode exits 1 with
veridor-gate: hook.mode is "". Set it to argocd ....
Under Argo CD the gate's mode is argocd, including when the chart is a
dependency of your app's chart. Argo CD refuses to render hook.mode: helm
(below).
Argo CD: the PostSync gate¶
deploy/argocd/application-example.yaml is an Application with two sources:
your app, and the chart with hook.mode: argocd. Argo CD renders the chart
itself, so you never run Helm, and your app's own source can stay Kustomize or
plain manifests.
# the chart as a second source of your Application
- repoURL: https://charts.example.com/veridor # a placeholder
chart: veridor-gate
targetRevision: 0.1.0
helm:
releaseName: invoices-api-gate
valuesObject:
hook:
mode: argocd
target:
baseUrl: http://invoices-api.shop.svc.cluster.local:8080
secretEnv:
- {name: VERIDOR_OWNER_TOKEN, secretName: invoices-gate-credentials, key: owner-token}
- {name: VERIDOR_OUTSIDER_TOKEN, secretName: invoices-gate-credentials, key: outsider-token}
gate:
failOn: new+confirmed
requireComplete: true
config:
version: 1
target: {base_url: http://invoices-api.shop.svc.cluster.local:8080}
identities:
invoice_owner:
headers: {Authorization: "Bearer ${VERIDOR_OWNER_TOKEN}"}
marker: vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1
other_tenant:
headers: {Authorization: "Bearer ${VERIDOR_OUTSIDER_TOKEN}"}
marker: vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6
marker_probe: {method: GET, path: /api/invoices/inv-200}
anonymous: {headers: {}}
checks: ["..."]
other_tenant's marker and marker_probe are not decoration. The demo API
answers an unknown token with the same 404 it gives the cross-tenant request,
so without them that denial is unproven and, with gate.requireComplete: true,
the gate exits 3 (liveness_unproven) against a fixed target. The chart
README records it measured both ways: without the two lines, fixed exit 3
and vulnerable 1; with them, fixed 0 and vulnerable 1.
The chart renders:
veridor run --config /config/veridor.json --output-dir /work/.veridor \
--fail-on new+confirmed --base-url <target.baseUrl> --require-complete \
--wait-for-target 60
Only Argo CD renders hook.mode: argocd. helm install, helm upgrade
and a server-side dry run refuse it at render, because the Helm CLI does not
run Argo CD hooks. Before that refusal, helm install --server-side=false
exited 0 with the release deployed and a Job nothing waited for (recorded
in the chart README, Helm 4.2.2). Argo CD renders with helm template, which
reaches no cluster and is not refused.
And Argo CD does not render hook.mode: helm. Argo CD maps
helm.sh/hook: post-install,post-upgrade to PostSync and
before-hook-creation to BeforeHookCreation, so under Argo CD every sync's
helm-mode gate Job had one fixed name, the shape the
next section describes. Recorded in the chart
README, on kind with Argo CD v3.5.3, in an Application of the gate alone with
the target switched between a vulnerable and a fixed API: 14 of 80 helm-mode
syncs were reported from the previous sync's Job, 6 of the 40 vulnerable ones
Succeeded while their own gate exited 1; argocd mode, 0 of 50. The chart
now refuses helm mode on any render that reaches no cluster, which is how
Argo CD renders: the Application shows a ComparisonError naming the refusal
(the e2e's argocd scenario has the row, and its control: the same
Application with hook.helm.allowRenderWithoutCluster: true renders).
There is no Kustomize overlay for Argo CD. There was one, and it was
removed: Argo CD needs metadata.generateName on the hook Job (next section),
and Kustomize refuses a Job that has only generateName. kubectl kustomize
(Kustomize v5.7.1) and the Kustomize bundled in Argo CD v3.5.3 (v5.8.1) both
stop with missing metadata.name (recorded in the Kustomize README; the
Veridor test suite checks kubectl's). With Argo CD, take the gate from the
chart; your app can stay Kustomize.
What happens on a sync¶
- The Sync phase applies your app's resources and the gate's ConfigMap.
- Argo CD waits until every Sync-phase resource is Healthy.
- The PostSync phase creates the gate Job under a name of its own. The
chart renders
metadata.generateName, and Argo CD names the Job<helm.releaseName>-veridor-gate-<revision>-postsync-<the sync's start, Unix seconds>, where<revision>is the first 7 characters of the sync's revision and empty for an Application with several sources, like the example. Rendered for this page:generateName: gate-veridor-gate-,argocd.argoproj.io/hook: "PostSync",hook-delete-policy: "BeforeHookCreation". - Veridor's exit code decides the Job.
backoffLimitis0and not configurable: a retried gate reports its luckiest attempt. - A failed hook Job fails the sync operation; a passing one lets it succeed. A sync that runs no hooks runs no gate and succeeds (syncs that run no gate).
Each sync's Job and its log stay for job.ttlSecondsAfterFinished (3600 s by
default) after the Job finished. Since no earlier sync used the name,
BeforeHookCreation finds nothing to delete. The Argo CD README records it on
kind with a 30 s TTL: 10 syncs, one Job per sync, and 50 s after the last no
Job or pod was left.
Each sync reads its own Job¶
An earlier version of the chart gave the Job one fixed name, re-created on
every sync under BeforeHookCreation, and Argo CD v3.5.3 then reported some
syncs from the previous sync's Job. Recorded in the Veridor repository's
deploy/argocd/README.md, on kind: 5 of 24 syncs whose gate passed were
reported Failed; with a stand-in Job, 5 of 13 whose Job failed were reported
Succeeded; and in 4 of 5 runs of the e2e's argocd scenario a vulnerable
sync was reported Succeeded while its gate exited 1. A gate whose sync can
be reported from another run's Job does not gate.
With generateName, the same harness reported every sync from its own Job, 36
of 36, and in 5 runs of the e2e's argocd scenario on 2026-09-25 every sync's
phase matched its own gate Job's exit, 35 of 35. Not re-run for this page.
Syncs that run no gate¶
Argo CD v3.5.3 runs no hook, the gate included, for a sync that names
resources or uses the apply strategy (its controller/sync.go passes
skipHooks for either):
- a selective sync:
argocd app sync <app> --resource ..., or syncing some of the resources from the UI; - an apply-strategy sync:
argocd app sync <app> --strategy apply, the UI's Apply Only; - automated self-heal, which names the resources it re-applies (read in Argo CD's source, not measured). An automated sync of a new revision names none and runs the gate.
Such an operation applies what it was asked to and ends Succeeded, message
successfully synced (all tasks run), with no PostSync entry in
status.operationState.syncResult.resources and no gate Job, whatever version
it applied. Recorded by the e2e's argocd scenario with the vulnerable version
deployed, straight after its full sync Failed on the gate: a selective sync
of the Deployment and an Apply Only sync each ended that way. Treat a
Succeeded operation with no PostSync entry as ungated.
What the sync phase proves, and what it does not¶
Recorded by scripts/e2e_kubernetes.sh on kind with Argo CD v3.5.3 (core
install), running the example's own gate values with only the image, chart
repository and target URL replaced; not re-run for this page:
| vulnerable target | fixed target | |
|---|---|---|
status.operationState.phase |
Failed |
Succeeded |
status.operationState.message |
one or more synchronization tasks completed unsuccessfully |
successfully synced (no more tasks) |
hook entry in status.operationState.syncResult.resources |
kind: Job, name: invoices-api-gate-veridor-gate--postsync-<start>, hookType: PostSync, hookPhase: Failed, message: Job has reached the specified backoff limit |
hookPhase: Succeeded, message: Reached expected number of succeeded pods |
| gate container exit code | 1 |
0 |
status.sync.status / status.health.status |
Synced / Healthy |
Synced / Healthy |
A Failed phase says this sync's own gate Job failed. It does not say:
- That the new version was probed. PostSync starts once every Sync-phase
resource is Healthy, and a
RollingUpdateDeployment is Healthy while the pod it replaced is still terminating and answering. The gate probes whatevertarget.baseUrlroutes to then. See aim the gate at the new pods. - That the version is out of service. The new version keeps running, and
the Application stays
SyncedandHealthy. Alert onstatus.operationState.phasebeingFailed(for example with an Argo CD Notifications trigger on that field), not on sync or health status, and on aSucceededoperation with noPostSyncentry, which ran no gate (above). Notifications itself was not measured. - Why. Neither the operation message nor the hook's status message names a finding. The verdict and the findings are in the Job log.
Reading the result¶
# the sync operation
kubectl -n argocd get application invoices-api \
-o jsonpath='{.status.operationState.phase}{"\n"}{.status.operationState.message}{"\n"}'
# the hook this sync ran, with its result
kubectl -n argocd get application invoices-api -o json \
| jq '.status.operationState.syncResult.resources[] | select(.hookType=="PostSync")'
# the name of this sync's gate Job (empty until Argo CD has created it, and
# empty after the operation has finished: this sync ran no gate)
job="$(kubectl -n argocd get application invoices-api \
-o jsonpath='{.status.operationState.syncResult.resources[?(@.hookType=="PostSync")].name}')"
# the gate Job's own outcome, Complete or Failed (empty while it runs)
kubectl -n shop get job "$job" -o jsonpath='{.status.conditions[?(@.status=="True")].type}{"\n"}'
# why: the gate's own output, including the findings and Veridor's scope sentences
kubectl -n shop logs "job/$job"
Several gate Jobs sit in the namespace at once, one per sync that ran hooks
within the TTL;
the one a sync ran is the one its syncResult names. Set helm.releaseName
on the gate source so the prefix does not depend on the Application's name.
The container sets terminationMessagePolicy: FallbackToLogsOnError, so
kubectl describe shows the tail of the log when it fails. The e2e runs Argo
CD core, without the API server or UI, so only the fields above are measured.
Aim the gate at the new pods¶
The revision label does not work under Argo CD. The chart README's recipe
for the Helm CLI, a Service selecting pods by a .Release.Revision label,
does nothing here: Argo CD renders the app's chart with helm template, where
.Release.Revision is always 1, so the old and the new pods carry the same
label and the Service lists both.
Recorded through Argo CD v3.5.3 on kind by the e2e's argocd scenario, synced
from fixed to vulnerable, in the chart README; not re-run for this page. The
two label rows are 10 runs on 2026-09-25 with the app on RollingUpdate (the
replaced pod serving for up to 90 s after it is told to stop); the Recreate
row is 10 runs as well:
| Gate aimed at | The gate's Service lists the replaced pod after the sync | Gate requests the replaced pod answered | The gate on the vulnerable version |
|---|---|---|---|
a Service on the .Release.Revision label |
yes, 10 of 10 | 26 of 50 | passed it in 5 of 10 |
| a Service on a label hashed from the pod template | no, 10 of 10 | 0 of the 45 counted | failed in each of the 9 runs whose exit code was read |
the app's own Service, app on strategy: Recreate |
— | — | failed, 10 of 10 |
So give the pod template a label that changes whenever the pod template does,
kept out of the Deployment's spec.selector, and point the gate at a Service
with that selector. The chart README has the whole snippet; its core is:
{{- define "invoices-api.version" -}}
{{- include "invoices-api.pod" . | sha256sum | trunc 16 | quote -}}
{{- end }}
# on the pod template, and in the gate Service's selector:
example.com/pod-template: {{ include "invoices-api.version" . }}
The hash covers what is inside invoices-api.pod; anything else that rolls the
pods, such as a checksum annotation on the pod template, has to be hashed too.
strategy: Recreate, aimed at the app's own Service, also works through Argo
CD, at the cost of a gap in service: Argo CD waits for the rollout before
PostSync, and Recreate's old pod is gone before the new one starts.
Either way the gate reports on a version that is already serving. Only an Argo Rollouts canary keeps a failing version from taking the traffic.
Phase, delete policy and sync wave¶
hook.argocd.phaseaccepts onlyPostSync.Syncis refused: Argo CD runs a Sync hook together with the apply of the app's manifests, not once they are Healthy, so the gate would probe the version being replaced.PreSyncis refused: it runs before the chart's ConfigMap is applied (the equivalent Helmpre-installandpre-upgradehooks were measured on kind; PreSync itself was not run). Any other value, or another letter case, is refused too. Measured for this page:--set hook.argocd.phase=Syncfails the render withvalue must be 'PostSync'.hook.argocd.deletePolicyis one or more ofBeforeHookCreation(the default),HookSucceededandHookFailed.HookSucceededdeletes a passing Job, andHookFaileda failing one with the log that says why, as soon as Argo CD has recorded the result. Withjob.ttlSecondsAfterFinished: nullthe render is refused unless both are listed, because every sync adds a Job.hook.argocd.syncWavesetsargocd.argoproj.io/sync-wave, to order the Job among other PostSync hooks.- With the schema skipped (
--skip-schema-validation, or Argo CD'sskipSchemaValidation), the templates refuse the same values, reading them the way Argo CD does: each comma-separated value trimmed and matched case-sensitively, soPreSyncis refused as PreSync.
Retries¶
The example sets no syncPolicy.retry. A retried sync re-runs the gate, and a
gate retried until it passes reports its luckiest attempt. Retries also undo
the per-sync name: Argo CD builds it from the operation's start time, which a
retry within one operation keeps, so each attempt's Job replaces the last under
one name. That was read in Argo CD v3.5.3's source, not measured. Automated
sync and self-heal were not measured; the e2e starts every sync explicitly.
Helm CLI: hook.mode: helm¶
hook.mode: helm renders a post-install,post-upgrade hook with
before-hook-creation. A failed gate makes helm install or helm upgrade
exit non-zero. It is for the Helm CLI: a render that reaches no cluster is
refused, because Argo CD renders that way (above).
To render it with helm template or helm install --dry-run=client, set
hook.helm.allowRenderWithoutCluster=true, and never under Argo CD. Measured
for this page with helm template (Helm 4.2.2): without it, exit 1 with
veridor-gate: hook.mode is helm, and this render reached no cluster; with
it, the hook above; with the string "true", refused by the schema.
hook.mode helm: what the gate probes¶
Helm starts post-install and post-upgrade hooks as soon as it has applied
the manifests, unless it waits first: Helm 4's default is --wait=hookOnly,
and Helm 3 waits for nothing without --wait. The previous version's pods are
still serving then, so under Helm's default flags an upgrade gates the
version it is replacing. --wait-for-target returns at once, because an old
pod accepts the connection.
Recorded in the chart README, measured on kind with Helm 4.2.2 on 2026-09-24, upgrading a one-replica app from a fixed version to a vulnerable one; not re-run for this page (selected rows of its table):
| Helm flags | App Deployment | Gate aimed at | Vulnerable upgrades the gate passed |
|---|---|---|---|
| none (the defaults) | RollingUpdate |
the app's Service | 2 of 2 |
| none (the defaults) | Recreate |
the app's Service | 2 of 2 |
--wait |
RollingUpdate |
the app's Service | 1 of 3 |
--rollback-on-failure |
RollingUpdate, new pod Ready after 15 s |
the app's Service | 2 of 6 |
--rollback-on-failure |
Recreate |
the app's Service | 0 of 3, rolled back |
--rollback-on-failure |
RollingUpdate, new pod Ready after 15 s |
a Service of this revision's pods | 0 of 4, rolled back; 4 of 4 fixed upgrades deployed |
strategy: Recreate does not help under the default flags: the hook starts
while the old pod is terminating and, like any process that does not stop on
SIGTERM, still answering.
The invocation measured correct¶
- The gate is a dependency of your app's chart, so the hook runs in the release that rolls out the app and a failed gate fails that release. A gate installed as a release of its own fails and rolls back only itself.
target.baseUrlreaches only the pods this release rolled out: a Service whose selector includes a pod-template label set from{{ .Release.Revision | quote }}, kept out of the Deployment'sspec.selector. This works withhelm installandhelm upgradeonly;helm templaterenders the revision as1(Argo CD needs the pod-template hash).strategy: Recreateaimed at the app's own Service is the other way measured correct, together with step 3.-
Install and upgrade with
--rollback-on-failure(Helm 4; Helm 3's--atomicis the same option and was not measured):Helm then waits for the release's resources before it runs the hooks, and when the gate fails it rolls the release back and exits non-zero. Without it a failed gate leaves the release
failedwith the new version serving.
The e2e's helm scenario runs these three steps and, as its control, the same
upgrade with Helm's default flags aimed at the app's own Service, which passes
the vulnerable version.
What the chart refuses in helm mode¶
hook.helm.eventsmust include bothpost-installandpost-upgrade. Withtestalone,helm installandhelm upgradecreated no Job and exited0(recorded).testandpost-rollbackmay be added; withtest,helm test's exit status is the gate and a failed test leaves the releasedeployed.pre-install,pre-upgradeandpre-rollbackare refused: they run before the chart's ConfigMap is created or updated, so an upgrade ran the previous revision's configuration (recorded).hook.helm.deletePolicymust includebefore-hook-creation: the Job keeps one name, and withhook-failedalone an upgrade exited0on the Job the install had left, running nothing (recorded).- Values are read the way Helm reads them, lower-cased and trimmed per event,
so
post-install,PRE-UPGRADEis refused as a pre-upgrade hook even with the schema skipped. - A render that reaches no cluster is refused unless
hook.helm.allowRenderWithoutClusteristrue(a boolean): Argo CD renders that way, and would run the Job as aPostSynchook with one fixed name.
hook.mode: none and the Kustomize plain Job¶
Nothing fails when a plain Job fails. Whatever applies it must wait for this run's Job and read its outcome, and it probes whatever the target URL routes to when it runs, so run it after the app's rollout and aim it at the new pods only.
Re-applying an unchanged Job prints unchanged and runs nothing, and a
kubectl wait then reads the previous run's result. Recorded on kind: after
the target was redeployed vulnerable, the re-apply printed unchanged and
kubectl wait --for=condition=Complete exited 0 at once, from the Job that
had passed before. So every run deletes the previous Job, checks that apply
created a new one, and waits for that Job's own outcome. The Kustomize
README's block:
old="$(kubectl get job veridor-gate --ignore-not-found -o jsonpath='{.metadata.uid}')" || exit 1
kubectl delete job veridor-gate --ignore-not-found --wait=true || exit 1
kubectl apply -k my-overlay/ || exit 1
uid="$(kubectl get job veridor-gate -o jsonpath='{.metadata.uid}')" || exit 1
if [ -z "$uid" ] || [ "$uid" = "$old" ]; then echo "veridor-gate: apply created no new Job" >&2; exit 1; fi
while outcome="$(kubectl get job veridor-gate -o jsonpath='{.metadata.uid} {.status.conditions[?(@.status=="True")].type}')"; do
case "$outcome " in "$uid "*) ;; *) outcome=""; break ;; esac
case " $outcome " in *" Complete "* | *" Failed "*) break ;; esac
sleep 5
done
kubectl logs job/veridor-gate
case " $outcome " in *" Complete "*) true ;; *) false ;; esac
The last line is the gate: 0 only when the Job this run created completed.
Every step checks itself and the block does not depend on set -e; the
previous version of it did not, and under plain sh an identity that may not
delete Jobs got Forbidden, then unchanged, then exit 0 from the previous
run's Job against a target that had turned vulnerable (recorded on kind). Run
it as a script or as the last lines of a CI step: its exit 1 would also close
an interactive shell.
The chart's hook.mode: none names its Job <release>-veridor-gate-r<revision>
(rendered for this page as gate-veridor-gate-r1); the chart README has the
same block with helm template in front of it, and a render that fails exits
1 there too. The e2e's kustomize and plain scenarios extract each block
from its README and run it under sh, each with the previous block as its
control (recorded; not re-run for this page).
What the chart checks that Kustomize cannot. Kustomize has no render-time
refusals, so a literal credential in your veridor.json would be stored in a
ConfigMap, and nothing checks that each ${NAME} has a Secret behind it before
apply. Veridor still refuses an unset or empty variable at run time. To check a
configuration in CI, veridor.capsule.literal_credentials(json.load(...))
returns the JSON path of every literal credential the capsule rule finds.
Credentials in the Kustomize base are env entries for
VERIDOR_OWNER_TOKEN, VERIDOR_OUTSIDER_TOKEN and VERIDOR_ADMIN_TOKEN from
the Secret veridor-gate-credentials, optional: true. Entries merge by name,
so a patch that moves them to another Secret must list every one; an earlier
README example moved only one, and Veridor exited 2 on the others (recorded).
The Veridor test suite (tests/test_kustomize.py) renders the overlay with
kubectl kustomize and compares every field of the Job spec with the chart's
default render, except the pod template's labels and the ConfigMap name, and
checks that Kustomize refuses a Job with only generateName. Run for this page
on the tree at commit 38025ed (kubectl v1.34.1, Kustomize v5.7.1, helm
v4.2.2): 34 tests, OK.
The chart's values¶
The full table is in the chart's README.md; values.schema.json rejects
unknown keys, so a misspelt value fails the render. The defaults below were
compared for this page with values.yaml from an earlier
tree (before 38025ed).
| Value | Default | What it does |
|---|---|---|
hook.mode |
"" (required) |
argocd, helm or none. |
hook.argocd.phase |
PostSync |
The only value accepted. |
hook.argocd.deletePolicy |
BeforeHookCreation |
Also HookSucceeded, HookFailed. Each sync's Job has a name of its own, so BeforeHookCreation deletes nothing. |
hook.argocd.syncWave |
"" |
argocd.argoproj.io/sync-wave; empty is not set. |
hook.helm.events |
post-install,post-upgrade |
Must include both; test and post-rollback may be added; pre-* refused. |
hook.helm.deletePolicy |
before-hook-creation |
Must include it. |
hook.helm.weight |
"0" |
helm.sh/hook-weight. |
hook.helm.allowRenderWithoutCluster |
false |
true lets helm template or a client-side dry run render helm mode for the Helm CLI. A helm-mode render that reaches no cluster is otherwise refused; Argo CD renders that way. Never under Argo CD. |
config / existingConfigMap.name |
{} / "" |
The Veridor configuration, rendered to a ConfigMap at /config/veridor.json; or your own ConfigMap, which the chart does not read. One of them. |
target.baseUrl |
"" |
--base-url. Required unless config.target.base_url is set; config.target must still be an object (target: {} is enough). No ${NAME} and no literal credential parameter. |
secretEnv |
[] |
{name, secretName, key}: environment variables from existing Secrets, the only way a credential reaches the pod. |
allowLiteralHeaders |
[] |
Header names that may carry a literal because they are not credentials. |
gate.failOn |
new+confirmed |
--fail-on. No --baseline is passed, so every finding counts as new. |
gate.requireComplete |
true |
--require-complete: an unanswered probe exits 3 and fails the Job. |
gate.requireCleanTeardown |
false |
--require-clean-teardown. |
gate.readOnly |
false |
--read-only. |
gate.proveLiveness |
true |
false passes --no-prove-liveness. |
gate.sarif |
false |
Also write SARIF under output.dir. |
gate.waitForTargetSeconds |
60 |
--wait-for-target; 0 passes no flag. See waiting for the target. |
extraArgs |
[] |
Appended after the chart's arguments. An element that starts with a prefix of a flag the chart sets (--config, --output-dir, --fail-on, --base-url, --require-complete, --require-clean-teardown, --read-only, --no-prove-liveness, --sarif, --wait-for-target), --help, and any single-dash token with an h in it are refused: Veridor reads an unambiguous prefix as the flag and keeps its last value, so --base-u URL replaced target.baseUrl without its credential check (recorded). |
image.repository, image.tag, image.digest, image.digestTag |
ghcr.io/veridor-authz/veridor, "", "", "" |
Empty tag and digestTag mean the chart's appVersion. A digest is used only with the tag it belongs to: with image.digest set, a different image.tag is refused, because the kubelet pulls the digest and ignores the tag. |
image.command |
["veridor"] |
Always set, so the chart does not depend on the image's ENTRYPOINT. |
output.dir, output.existingClaim, output.sizeLimit |
/work/.veridor, "", 128Mi |
--output-dir, under /work; a PersistentVolumeClaim to keep the files; the emptyDir limit. |
job.activeDeadlineSeconds |
600 |
Deadline for the whole Job. |
job.ttlSecondsAfterFinished |
3600 |
How long a finished Job and its log are kept. |
job.annotations |
{} |
May not set any key under helm.sh/ or argocd.argoproj.io/. A hook key there was rendered after the chart's own and won (helm.sh/hook: test made install and upgrade run no gate), and helm.sh/resource-policy: keep let a later install adopt a stale passing Job (both recorded). |
job.podLabels |
{} |
May not set app.kubernetes.io/name, instance or component, the NetworkPolicy's selector. |
podSecurityContext.runAsUser / runAsGroup / fsGroup |
10001 |
null omits them, for platforms that assign the uid. |
networkPolicy.* |
enabled: false |
See NetworkPolicy. |
Credentials¶
secretEnv renders valueFrom.secretKeyRef with optional: true, on
purpose: a missing Secret or key leaves the variable unset and Veridor refuses
the configuration at once (exit 2, naming the variable). Marked required, the
pod would wait in CreateContainerConfigError until the deadline. Either way
the Job fails. The Secret must exist in the destination namespace; the
Application does not create it.
The render fails when config holds a literal credential the chart's rule
recognises. It is the same rule veridor capsule applies before it publishes
anything, and the two are held to each other by a shared fixture
(tests/test_credential_parity.py; run for this page: 34 tests, OK). In
outline:
- every value in a
headersmap (the key in any case) and every sessioninjectvalue must be${NAME}references, optionally after one auth-scheme word; - every string or number anywhere beneath a credential-named key must be a
reference. A key counts when it ends in
password,passwd,passphrase,secret,token,jwt,credential(s),api_key,private_key,secret_keyoraccess_key, with or without a separator before it (accessToken,refreshToken,aws_secret_access_key), plus a list of confirmation forms, some non-English words for password, andpass,pwdandsenhaas words of their own; user:password@in any URL, and credential-named parameters in query strings,;path parameters and form bodies. As a parameter only,key,auth,code,sig,signature,sessionid,session_id,jsessionidandsidcount too, so?code=USmust be a reference as well;target.baseUrlmay not carry a${NAME}or a literal credential-named parameter, and neither target URL may carryuser:password@;- every
${NAME}outsidesequencesmust have asecretEnventry.
The message names the path and never the value. In Argo CD it shows as a
ComparisonError condition on the Application (recorded by the e2e). The rule
goes by names: a secret under a name it does not know (pin, otp,
passcode) is not caught, and a ConfigMap supplied through
existingConfigMap.name is not read at all. The chart README lists the rule
in full.
Waiting for the target¶
gate.waitForTargetSeconds: 60 (and --wait-for-target 60 in the Kustomize
base and the Rollouts template) makes Veridor try a plain TCP connection to the
target's host and port once a second, before its first request, until one is
accepted or 60 seconds pass. It then runs either way, and one line in the Job
log says which. It never changes a verdict: a target that never accepts is
measured by the probes' own transport errors, which under
gate.requireComplete: true is exit 3. Keep it well under
job.activeDeadlineSeconds; 0 turns it off. The check connects to the target
directly even when a proxy carries the requests, and its line says so then.
It exists for a race: PostSync can start in the same second the new pod turns
Ready, before its Service routes to it. Before the wait existed, the removed
Kustomize overlay's Argo CD leg failed once in four e2e runs that way (three
probes refused, exit 3, a failed sync about a version the gate never
reached). The e2e's wait scenario, plain Jobs next to a target whose only pod
turns Ready 15 s after it starts, recorded: no wait, exit 3 with all 6 probes
refused; a 60 s wait, accepted a connection after 15s and then exit 1
against the vulnerable target and 0 against the fixed one.
An accepted connection proves only that a port is open. Not that the
application behind it is ready, and not that it is the new version: while the
previous version's pods are routed, one of them accepts at once, and the gate
measures that version (recorded: 0 s under a RollingUpdate Helm upgrade).
Aim the gate at the new pods for that.
Restricted Pod Security¶
The chart and the Kustomize base produce the same pod, written for the Pod
Security Standards restricted profile. From helm template of the chart on an
earlier tree (before 38025ed), checked for this page:
runAsNonRoot: true,runAsUserandrunAsGroup10001,fsGroup10001seccompProfile: RuntimeDefaultallowPrivilegeEscalation: false,readOnlyRootFilesystem: true,capabilities.drop: [ALL]automountServiceAccountToken: false(Veridor never calls the Kubernetes API),enableServiceLinks: false- writable paths only
/work(anemptyDir, oroutput.existingClaim) and/tmp(anemptyDir)
The e2e runs the gate in namespaces that enforce restricted (recorded). On a
platform that assigns the uid itself (OpenShift restricted-v2), set the three
podSecurityContext values to null; that path was checked only with helm
template, not run on OpenShift.
NetworkPolicy¶
networkPolicy.enabled: true renders a policy that selects the gate's pod,
allows no ingress, and allows egress only to networkPolicy.targetPeers on
networkPolicy.targetPorts, plus DNS. Name the target's pods and the port
the pods listen on; a policy sees pod IPs and ports, not the Service's.
The render refuses values under which a rule would allow more than that: no
peers or ports while enabled, a targetPorts or dns.ports entry with no
port (which allows every port of its protocol), and empty dns.peers or
dns.ports while dns.enabled (a rule with no peers is egress to every
destination; recorded on kind). A job.podLabels entry for one of the policy's
selector labels is refused because it detached the policy from the pod.
Recorded on kindnet: the policy did not constrain an undelayed gate
On kind's default network plugin (kindnet with kube-network-policies), egress rules were not applied to connections a pod made in its first seconds, and Veridor connects at once. The e2e's NetworkPolicy rows delay Veridor by 8 seconds to test the rules themselves. Other plugins were not measured. Check your plugin before relying on the policy.
The Kustomize packaging ships no NetworkPolicy.
Exit codes in a cluster¶
| Exit | Job | Argo CD sync | Helm release |
|---|---|---|---|
0 |
Complete | Succeeded | deployed |
1, 2, 3, 70, 143 |
Failed | Failed | failed, or rolled back with --rollback-on-failure |
Every non-zero exit fails the sync or the release alike; read the Job log to tell them apart.
A Complete Job means Veridor exited 0: nothing the selected gate
(gate.failOn, gate.requireComplete, gate.requireCleanTeardown) selects
was present. It does not mean every declared expectation held. A finding
gate.failOn does not select leaves the Job Complete (under none, every
finding: the chart README records a vulnerable target exiting 0), and so does
an unanswered probe with gate.requireComplete: false. gate.failOn: none
does not switch off --require-complete: a run that answered nothing still
exits 3. Nor is a Complete Job evidence that authorization is enforced
beyond the requests sent, or that the version you meant to gate was the one
that answered.
When job.activeDeadlineSeconds expires, Kubernetes stops the pod and marks
the Job Failed with reason DeadlineExceeded (recorded by the e2e). Veridor
turns the SIGTERM into sequence teardown and exit 143; that path is measured
by Veridor's own tests (tests/test_process_exit.py), not inside a cluster.
What was tested¶
| What | Status |
|---|---|
Chart render: hook annotations per mode, generateName in argocd mode, the hook and value refusals, Pod Security fields, backoffLimit: 0, the credential refusals (never echoing the value), the ConfigMap through Veridor's own loader, the Job's arguments through Veridor's own CLI parser, the README blocks against a stub kubectl |
Executed for this page on the tree at commit 38025ed (macOS, 2026-11-09): VERIDOR_REQUIRE_HELM=1 python3.12 -m unittest tests.test_helm_chart, 168 tests, OK (helm v4.2.2); tests.test_kustomize, 34 tests, OK; tests.test_credential_parity, 34 tests, OK. The renders quoted on this page were also made by hand. |
The chart, the plain overlay and the Rollouts template on kind: Argo CD PostSync (each sync from its own Job), the Helm invocation and its control, the plain blocks and their controls, the pod-template label and its control, restricted namespaces, NetworkPolicy rows, helm install of argocd mode refused |
Recorded by scripts/e2e_kubernetes.sh (Argo CD v3.5.3, Argo Rollouts v1.10.0, kind) in the Veridor repository's deploy/ READMEs. Not re-run for this page. The same script also ran in the project's CI on GitHub-hosted runners, each in a kind cluster, as the jobs "Kubernetes end to end (helm)" and "(plain argocd rollouts psa kustomize wait)", the second after a negative control that must end in mismatch rows: both succeeded in workflow run 42508338432 (push to main at 38025ed). The repository is private, so the run is not public. Still kind only. |
helm lint |
Recorded in the chart README with helm 4.2: it passes a chart whose render the chart refuses, because in lint mode fail only logs. Render with helm template to check values. |
Helm 3; Argo CD automated sync, self-heal, retries, Notifications, UI and API server; other Argo CD versions; PreSync and Sync hooks through Argo CD; standalone kustomize, Flux; CNIs other than kindnet; OpenShift; a Git source for the chart |
Untested |
Related: Argo Rollouts, exit codes, install, safety posture.