Set up or troubleshoot Wardyn's Kubernetes runner substrate (the Helm chart with k8s.enabled=true) — cluster prereqs (proving the CNI enforces NetworkPolicy), authoring values.yaml, wiring Entra ID App Roles for admin/member RBAC, install/upgrade, and verification. Use when deploying Wardyn to Kubernetes, enabling k8s.enabled, configuring SSO/RBAC on a k8s install, or troubleshooting one (redirect loops, "no Wardyn role assigned", ImagePullBackOff, canary INDETERMINATE, NetworkPolicy not enforcing, SSH connection refused). Triggers on "deploy wardyn to kubernetes", "wardyn k8s setup", "k8s runner substrate", "helm install wardyn", "k8s.enabled", "wardyn Entra SSO", "NetworkPolicy canary", "wardyn RBAC on k8s".
Instal·la
npx skillscat add cjohnstoniv/wardyn/wardyn-k8s-setup Instal·la des del registre de SkillsCat.
Wardyn k8s setup — cluster prereqs, values, Entra RBAC, install, verify
Goal: get wardynd running on Kubernetes with the k8s runner substrate
actually able to create confined sandboxes, and admin/member RBAC wired
through a real IdP (Entra ID is the worked example — the trap it has that a
generic OIDC provider doesn't is exactly why it gets its own section below).
Reuse the shipped chart and substrate; never hand-roll a manifest.
Ground truth (read these, don't restate from memory)
- Chart defaults + every value's own doc comment:
deploy/helm/wardyn/values.yaml. - Worked install commands, the
k8s.enabledwalkthrough, and the honest
gap list (what the k8s substrate does not do yet — BYOI,local_dir
mounts, per-pod PIDs/disk limits, ground-truth correlator):deploy/helm/wardyn/README.md. - Every
WARDYN_*var this touches, with defaults and gotchas spelled out
per row (WARDYN_K8S_*,WARDYN_OIDC_*,WARDYN_SSH_*,WARDYN_TLS_TERMINATED):docs/ENV.md. - Multi-user semantics (admin vs member, ownership scoping, the shared
admin-token ceiling) and the k8s known-gaps detail behind the chart
README's summary:docs/OPERATIONS.md. - SSH gateway setup/use once the cluster is up (the run's owner, or an
admin on a run with no personal owner — the admin override re-checks the key's
role everyWARDYN_SSH_ROLE_TTL;
see this skill's troubleshooting table):docs/SSH.md.
Recipe
Cluster prereqs — prove the CNI enforces NetworkPolicy before anything
else. The k8s substrate's boot-time egress canary
(internal/runner/k8s/canary.go) runs a two-phase check against the
cluster itself the momentwardyndstarts withk8s.enabled— a
throwaway pod first confirms it CAN reach the apiserver with no policy in
place, then a deny-allNetworkPolicyis applied and the same pod is
proven UNREACHABLE. If phase two still gets through, the CNI does not
enforceNetworkPolicyand wardynd refuses to boot the substrate —
this is fail-closed by design, not a bug to work around. Prove it on a
throwaway cluster before touching a real one, using the exact recipe CI
uses (.github/workflows/ci.yml'sconformance-k8sjob,deploy/kind/conformance-kind-config.yaml): kind's bundledkindnetCNI
does not enforceNetworkPolicy, so start kind withnetworking.disableDefaultCNI: trueand install a real CNI yourself —
Calico is what CI pins:kind create cluster --config deploy/kind/conformance-kind-config.yaml kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml kubectl -n kube-system rollout status daemonset/calico-node --timeout=180sAny NetworkPolicy-enforcing CNI works the same way on a real cluster
(Calico, Cilium, etc.) — this is just the reproducible local proof.
The opt-out and its cost:WARDYN_K8S_ALLOW_UNENFORCED_NETPOL=1
(helm:env.WARDYN_K8S_ALLOW_UNENFORCED_NETPOL) lets wardynd boot anyway
on a CNI that doesn't enforce policy — logged loudly at construction, and
every sandbox this substrate creates then has UNCONFINED egress. Only
ever accept that on a cluster you have some other confinement story for;
it is never an invisible downgrade (ClassSupport.NetworkPolicy/StructuralEgressboth keep reportingfalse, so/healthznever reads
like a genuinely confined install).Author
values.yaml.- Runner:
k8s.enabled=trueandserviceAccount.automount=true— the
chart REFUSES to renderk8s.enabledwithout the automount flip (the
substrate drives the apiserver directly via client-go, which needs the
pod's own projected token). - Images:
k8s.proxyImage(required — the chart refuses to render
withk8s.enabledand nok8s.proxyImage,templates/deployment.yaml; also
what the egress canary itself launches) andimage.repository/image.tag
for wardynd. runtimeClasses:k8s.runtimeClasses.CC2/.CC3pin a Confinement
Class to a RuntimeClass NAME already registered in the cluster (e.g.CC2: gvisor) — unlike Docker's well-known runtime family names, a
RuntimeClass object name is operator-chosen, so CC2/CC3 stay
unadvertised (CC1-only) until pinned here.- SSO block:
env.WARDYN_OIDC_ISSUER,env.WARDYN_OIDC_INTERNAL_ISSUER
(only if the browser and wardynd reach the IdP at different hostnames),
andextraEnvforWARDYN_OIDC_CLIENT_SECRET(🔒 in docs/ENV.md — never
a literalenvvalue). RBAC itself isenv.WARDYN_OIDC_ROLE_MAP— see
step 3. - SSH block:
ssh.enabled,ssh.port(default2222), andssh.advertiseHost(required when enabled — the chart never guesses a
public address). See Split SSH exposure
if SSH needs different exposure than HTTP (its ownLoadBalancerwhile
HTTP staysClusterIP, for example).
- Runner:
Entra ID app registration — admin/member RBAC. Wardyn has real
admin/userroles (internal/auth/oidc'sderiveRole), and Entra
App Roles are the recommended way to feed them — do this as its own step,
not folded into general SSO setup, because Entra has a trap a generic OIDC
provider doesn't (below).- Register the App Roles on the app registration (Azure Portal →
App registrations → your app → App roles → Create app role, or via
the manifest editor):
(the{ "appRoles": [ { "id": "3b1f6e2a-...-generate-a-fresh-guid", "allowedMemberTypes": ["User"], "displayName": "Wardyn Admin", "value": "Wardyn.Admin", "description": "Full Wardyn control-plane access", "isEnabled": true }, { "id": "9c2d7f4b-...-generate-a-fresh-guid", "allowedMemberTypes": ["User"], "displayName": "Wardyn Member", "value": "Wardyn.Member", "description": "Launch and use Wardyn runs", "isEnabled": true } ] }appRoleskey merges into the app's existing manifest — the
snippet above is deliberately just that one key, not a full manifest
replacement)valueis what lands in the ID token'srolesclaim — that string is
whatWARDYN_OIDC_ROLE_MAPmatches against, notdisplayName. - Set "Assignment required?" to Yes on the Enterprise Application
(Azure Portal → Enterprise applications → your app → Properties). Without
this, Entra hands out the app to any tenant user with NO role assigned —
the token carries an emptyrolesclaim, and every such login falls
through toWARDYN_OIDC_DEFAULT_ROLE(or is denied if that's unset). Turn
it on, then assign each role to the right users/groups under Users and
groups. - The role-map values —
env.WARDYN_OIDC_ROLE_MAPmaps the App Role'svalue(or agroupsentry, or an email) to a Wardyn role:env: WARDYN_OIDC_ROLE_MAP: "Wardyn.Admin=admin,Wardyn.Security=security_admin,Wardyn.Member=user" # Optional: no match at all -> this role instead of denying the login. WARDYN_OIDC_DEFAULT_ROLE: "user"security_adminis reachable only through this map — there is no
allowlist twin ofWARDYN_OIDC_OPERATOR_EMAILSfor it, andWARDYN_OIDC_DEFAULT_ROLErefusessecurity_adminat boot (it accepts
onlyadmin/user).
Matching is case-insensitive and ASCII-only; anyadminmatch wins over
ausermatch regardless of which claim produced it; an email onWARDYN_OIDC_OPERATOR_EMAILSis an ADDITIONAL admin match (see step 3's
legacy note below). LeavingWARDYN_OIDC_ROLE_MAPunset disables role
derivation entirely — every signed-in human is admin, upgrade-safe but
not RBAC. - The
email_verifiedtrap: Entra ID tokens typically omitemail_verifiedentirely.WARDYN_OIDC_EMAIL_DOMAINS(domain-restricted
login) fails closed on that — it denies every login against an Entra
tenant, since it requires a verified email that never arrives. Do not
useWARDYN_OIDC_EMAIL_DOMAINSwith Entra; App Roles plus "assignment
required" is the tenant-scoping mechanism instead — only assigned users
ever reach a role, and everyone else denies viaWARDYN_OIDC_DEFAULT_ROLE
(or the same fall-through denial with it unset). - Legacy allowlist still works:
WARDYN_OIDC_OPERATOR_EMAILS(the
pre-0.5 operator list) is not replaced byWARDYN_OIDC_ROLE_MAP— an
email on it is still an additionaladminmatch, so an existing
deployment adopting App Roles keeps its current operators as admins with
zero re-configuration. env.WARDYN_OIDC_ROLE_MAPis the chart's BOOTSTRAP layer, not the only
place mappings get edited. Once the install is up, an admin adds,
edits and removes further mappings live from the console's Getting
Started → People step (GET /access,POST /access/mappings,DELETE /access/mappings/{id}) — no chart change or redeploy needed. The chart
map always wins a duplicate key against a console row; a console row
takes effect at that person's next sign-in, never retroactively. An
email-shaped console mapping is refused by default (steer to an App
Role orgroupsvalue instead, per the trap above) — opt in withenv.WARDYN_OIDC_ALLOW_EMAIL_MAPPINGS=trueonly when this deployment
has no usable App Role/groupsclaim at all. Full semantics (the merged
table, the posture-flip and lockout guards, the preview panel):
docs/OPERATIONS.md's "Who decides who gets in".
A scripted, worked validation of this whole path against a real
(free-tier, throwaway) Entra tenant lives indeploy/azure-entra-sso/—
run it before trusting any of this on a tenant that matters.
- Register the App Roles on the app registration (Azure Portal →
Install / upgrade.
kubectl create namespace wardyn kubectl create namespace wardyn-runs # k8s.runsNamespace below — must pre-exist kubectl create secret generic wardyn-auth -n wardyn \ --from-literal=admin-token="$(openssl rand -hex 32)" # fallback if OIDC misconfigures kubectl create secret generic wardyn-pg -n wardyn \ --from-literal=dsn="$PG_DSN" \ --from-literal=age-key="$(docker run --rm "$REGISTRY/wardynd:$TAG" -gen-age-key)" helm upgrade --install wardyn ./deploy/helm/wardyn \ --namespace wardyn \ --set postgres.dsn.secretRef.name=wardyn-pg \ --set secrets.ageKeyFromSecret=true \ --set auth.adminToken.secretRef.name=wardyn-auth \ --set serviceAccount.automount=true \ --set k8s.enabled=true \ --set k8s.runsNamespace=wardyn-runs \ --set k8s.proxyImage="$REGISTRY/wardyn-proxy:$TAG" \ --set k8s.runtimeClasses.CC2="<your gVisor RuntimeClass>" \ -f my-k8s-values.yaml # the SSO/SSH/runtimeClasses block from step 2-3No CC2/CC3 pin and no
defaultPolicyoverride is needed to render this: the
shipped policy floor is CC1, so a stock install clears it with the bare[CC1]the substrate advertises. The--set k8s.runtimeClasses.CC2=…above
is optional hardening — pin the gVisor (or Kata) RuntimeClass name from step 2
to advertise a stronger class (a duplicate of the pin inmy-k8s-values.yaml
is harmless). A stronger floor with no matching RuntimeClass fails closed at
dispatch.postgres.dsn.secretRefis a PERSISTENT DSN, so the age identity riding in
the SAME Secret (age-key, via-gen-age-key) is required, not optional —
without it wardynd generates a fresh identity every boot and cannot decrypt
what the previous boot encrypted (crash-loops on the SECOND restart). Keep
an admin token secret configured even with OIDC set up — it is the
recovery path if the Entra role map ever locks everyone out.Verify.
kubectl -n wardyn rollout status deploy/wardyn --timeout=120s— notImagePullBackOff(see the troubleshooting table).- Console → setup checks:
k8s_egress_containmentreads Enforcing ·
NetworkPolicy, not Indeterminate or Not enforcing (see step 1). - Launch a run through the console or
wardyn run, confirm it reachesRUNNING, and attach (web terminal, or SSH ifssh.enabled—ssh <run-id>@<advertiseHost> -p <port>, owner or admin, docs/SSH.md). - Sign in via SSO with a member-mapped account and confirm the console
shows member-scoped nav (no policy/workspace/secret CRUD, no BYOI); sign
in with an admin-mapped account and confirm the full console.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Login redirect loop (bounces back to the IdP forever) | Secure-flagged cookies over plain HTTP — WARDYN_TLS_TERMINATED=true set while the browser is NOT actually on HTTPS (or the reverse: real TLS termination in front but the flag left unset can also confuse a strict ingress). A Secure cookie is silently dropped by the browser off HTTPS, so the OIDC state/nonce/PKCE cookies never survive the round trip. |
Set env.WARDYN_TLS_TERMINATED=true (helm) only when an ingress/LB genuinely terminates TLS in front of wardynd (browser sees https://); leave it unset for plain-HTTP access (port-forward, no ingress TLS). |
| "no Wardyn role assigned; ask your operator to map you via WARDYN_OIDC_ROLE_MAP" | WARDYN_OIDC_ROLE_MAP is set, the signed-in user's roles/groups/email matched none of its entries, and WARDYN_OIDC_DEFAULT_ROLE is unset (fail-closed default: deny). |
Either assign the user an Entra App Role (step 3) and confirm "Assignment required" didn't block them, or set env.WARDYN_OIDC_DEFAULT_ROLE=user to admit unmatched users with the user role instead of denying. |
| wardynd won't start / crash-loops, logs "parse WARDYN_OIDC_ROLE_MAP" or "invalid WARDYN_OIDC_DEFAULT_ROLE" | Both are validated at boot, not first use: a malformed role-map entry (bad role value, non-ASCII key, duplicate key, or non-blank input with no valid entry) or a WARDYN_OIDC_DEFAULT_ROLE that isn't admin/user fails boot outright rather than reaching a session cookie later. |
Fix the env.WARDYN_OIDC_ROLE_MAP CSV syntax (value=admin, value=security_admin or value=user pairs only, ASCII keys, no duplicates — security_admin is reachable only through this map, and env.WARDYN_OIDC_DEFAULT_ROLE refuses it) or env.WARDYN_OIDC_DEFAULT_ROLE value named in the boot log, then redeploy. |
id_token verification failed / issuer mismatch |
env.WARDYN_OIDC_ISSUER doesn't byte-match the token's iss claim — common with Entra: v1 vs v2 endpoint, or the wrong tenant segment in the URL. |
Use Entra's v2 issuer exactly: https://login.microsoftonline.com/<tenant-id>/v2.0. If wardynd and the browser reach the IdP at different hostnames, set env.WARDYN_OIDC_INTERNAL_ISSUER for wardynd's own server-side calls and leave WARDYN_OIDC_ISSUER as the public one. |
ImagePullBackOff |
image.repository/image.tag (or k8s.proxyImage) point at a tag that was never pushed, or the cluster can't reach the registry / lacks a pull secret. |
Confirm the tag exists in the registry the cluster can reach; for a private registry pass image.pullSecrets/k8s.imagePullSecret. A wrong image still renders and installs fine — this is a runtime symptom, always check kubectl rollout status, never assume render success means a working image. |
Setup check k8s_egress_containment reads Indeterminate |
wardynd reports a k8s driver but no canary verdict — either a build too old to compute one, or setupRunnerInfo's own Capabilities() call errored before the canary ran. |
Upgrade wardynd to a build that reports the canary verdict, and check wardynd's boot logs for the egress-canary result directly. |
wardynd refuses to boot: egress canary phase A (no NetworkPolicy) did not confirm baseline apiserver reachability (W27-S1-6) |
Phase A applies no NetworkPolicy of its own — it only proves the cluster is reachable at all. This is indistinguishable from k8s.runsNamespace already carrying a default-deny NetworkPolicy from something ELSE (a cluster-wide baseline, another operator's policy), which blocks the canary pod too. |
Use a namespace with no ambient default-deny for k8s.runsNamespace, or exempt Wardyn's pods from that policy's own podSelector (a matchExpressions entry with key: wardyn.managed, operator: NotIn, values: ["true"]) so it stops selecting them. Do NOT add a separate allow policy for wardyn.managed=true — NetworkPolicy allows are additive and both the agent and proxy pods carry that label, so it would widen every sandbox pod's egress past Wardyn's per-run deny+proxy-only policy and flip the canary's phase B to "not enforcing". |
Setup check k8s_egress_containment reads Not enforcing |
The boot-time canary proved this cluster's CNI does not enforce NetworkPolicy, and the operator accepted that via WARDYN_K8S_ALLOW_UNENFORCED_NETPOL=1 — every sandbox has unconfined egress. |
Unset env.WARDYN_K8S_ALLOW_UNENFORCED_NETPOL and install a NetworkPolicy-enforcing CNI (step 1) to restore real confinement. |
Runner never reaches RUNNING / pods stuck Pending |
Often k8s.runtimeClasses.CC2/.CC3 names a RuntimeClass that doesn't exist in the cluster yet, or the namespace lacks the Pod Security Standard level the substrate's pods need. |
Confirm kubectl get runtimeclass lists the name you pinned; confirm the runs namespace isn't blocking the substrate's restricted securityContext (PSS restricted is what CI's own conformance namespace uses). |
wardynd crash-loops at boot with unknown -runner "k8s" (want "none" or a registered substrate; the docker substrate requires a wardynd built with -tags docker) |
Misleading pre-fix headline (W27-S1-3) — ignore the -tags docker framing, it never applies here. The k8s substrate IS registered; its CONSTRUCTOR refused to start, most often the boot-time egress canary (k8s: refusing to boot: ...) or, on a non-chart install, a missing k8s.proxyImage (the chart refuses to render without one). |
Read past the headline to the wrapped -runner "k8s" failed to start: ... cause; check the canary verdict (k8s_egress_containment rows above) and, on a non-chart install, confirm k8s.proxyImage is set. |
| SSH connection refused | Either the gateway was never turned on (WARDYN_SSH_LISTEN unset — ssh.enabled=false is the chart's own default, and NOTHING generates a host key until it's on), or a client is dialing the wrong port/Service. |
Set ssh.enabled=true plus ssh.advertiseHost; confirm /healthz's ssh.enabled reads true; if SSH is split onto its own Service (LoadBalancer, etc. — see the chart README), confirm that Service's targetPort is ssh, matching the Deployment's named containerPort. |
| Role mapping added on the People step, but the user's access didn't change | Role derivation runs once, at login, and is stamped into the session cookie — a console row change is never applied to an already-signed-in session. | Tell the person to sign out and back in. Their next login re-derives the role against the now-current merged map. |
| Sign-in redirects with "your sign-in is too old to verify this change" while adding/removing a People-step mapping | The acting admin's own session snapshot (groups) is nil (a pre-0.6 cookie) or was truncated at the 2048-byte cap, so the server can't re-derive whether THEY currently hold admin from it — refused rather than risk a false lockout claim either way. |
Sign out and back in to refresh the snapshot, then retry the write. |
POST /access/mappings refused: "Email mappings are disabled on this install" |
The value contains @ and env.WARDYN_OIDC_ALLOW_EMAIL_MAPPINGS is unset (false is the chart default) — the console steers Entra deployments to an App Role or groups key by default. |
Map an App Role or groups value instead (preferred), or set env.WARDYN_OIDC_ALLOW_EMAIL_MAPPINGS=true if this deployment genuinely has no usable claim besides email. |