Skip to content

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-gate Helm chart (deploy/helm/veridor-gate), in one of three hook.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

  1. The Sync phase applies your app's resources and the gate's ConfigMap.
  2. Argo CD waits until every Sync-phase resource is Healthy.
  3. 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".
  4. Veridor's exit code decides the Job. backoffLimit is 0 and not configurable: a retried gate reports its luckiest attempt.
  5. 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 RollingUpdate Deployment is Healthy while the pod it replaced is still terminating and answering. The gate probes whatever target.baseUrl routes 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 Synced and Healthy. Alert on status.operationState.phase being Failed (for example with an Argo CD Notifications trigger on that field), not on sync or health status, and on a Succeeded operation with no PostSync entry, 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.phase accepts only PostSync. Sync is 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. PreSync is refused: it runs before the chart's ConfigMap is applied (the equivalent Helm pre-install and pre-upgrade hooks 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=Sync fails the render with value must be 'PostSync'.
  • hook.argocd.deletePolicy is one or more of BeforeHookCreation (the default), HookSucceeded and HookFailed. HookSucceeded deletes a passing Job, and HookFailed a failing one with the log that says why, as soon as Argo CD has recorded the result. With job.ttlSecondsAfterFinished: null the render is refused unless both are listed, because every sync adds a Job.
  • hook.argocd.syncWave sets argocd.argoproj.io/sync-wave, to order the Job among other PostSync hooks.
  • With the schema skipped (--skip-schema-validation, or Argo CD's skipSchemaValidation), the templates refuse the same values, reading them the way Argo CD does: each comma-separated value trimmed and matched case-sensitively, so PreSync is 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

  1. 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.
  2. target.baseUrl reaches 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's spec.selector. This works with helm install and helm upgrade only; helm template renders the revision as 1 (Argo CD needs the pod-template hash). strategy: Recreate aimed at the app's own Service is the other way measured correct, together with step 3.
  3. Install and upgrade with --rollback-on-failure (Helm 4; Helm 3's --atomic is the same option and was not measured):

    helm upgrade --install shop ./shop --namespace shop --rollback-on-failure --timeout 10m
    

    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 failed with 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.events must include both post-install and post-upgrade. With test alone, helm install and helm upgrade created no Job and exited 0 (recorded). test and post-rollback may be added; with test, helm test's exit status is the gate and a failed test leaves the release deployed.
  • pre-install, pre-upgrade and pre-rollback are refused: they run before the chart's ConfigMap is created or updated, so an upgrade ran the previous revision's configuration (recorded).
  • hook.helm.deletePolicy must include before-hook-creation: the Job keeps one name, and with hook-failed alone an upgrade exited 0 on 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-UPGRADE is refused as a pre-upgrade hook even with the schema skipped.
  • A render that reaches no cluster is refused unless hook.helm.allowRenderWithoutCluster is true (a boolean): Argo CD renders that way, and would run the Job as a PostSync hook 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 headers map (the key in any case) and every session inject value 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_key or access_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, and pass, pwd and senha as 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, jsessionid and sid count too, so ?code=US must be a reference as well;
  • target.baseUrl may not carry a ${NAME} or a literal credential-named parameter, and neither target URL may carry user:password@;
  • every ${NAME} outside sequences must have a secretEnv entry.

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, runAsUser and runAsGroup 10001, fsGroup 10001
  • seccompProfile: RuntimeDefault
  • allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities.drop: [ALL]
  • automountServiceAccountToken: false (Veridor never calls the Kubernetes API), enableServiceLinks: false
  • writable paths only /work (an emptyDir, or output.existingClaim) and /tmp (an emptyDir)

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.