From 4f868e4978b7625a0847d48b2ed8dc341d65d9cf Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Wed, 24 Jun 2026 17:30:40 +0200 Subject: [PATCH 01/10] release: bump chart 2026.7.0 / appVersion 2026.07.0, pin images to 2026.07.0 --- Chart.yaml | 4 ++-- values.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Chart.yaml b/Chart.yaml index 138857d..6cf9aa0 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: forail description: Forail Platform — automation, RBAC, EDA, observability (k8s deployment) type: application -version: 2026.6.0 -appVersion: "2026.06.0" +version: 2026.7.0 +appVersion: "2026.07.0" # forail-operator lives in its own repo (forail-platform/forail-operator) and # is installed separately via its own helm chart: # helm install forail-operator -n forail --set forail.token= diff --git a/values.yaml b/values.yaml index 40b8e09..53cf81d 100644 --- a/values.yaml +++ b/values.yaml @@ -13,15 +13,15 @@ namespace: images: backend: repository: ghcr.io/forail-platform/forail-backend - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent frontend: repository: ghcr.io/forail-platform/forail-frontend - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent assistant: repository: ghcr.io/forail-platform/forail-assistant - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent postgres: repository: postgres From b4d5bb0828c18741e7afe5a7b1e084c1f2a97624 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Sat, 11 Jul 2026 10:30:00 +0200 Subject: [PATCH 02/10] helm: remove insecure defaults - No working secret defaults: postgres/SECRET_KEY/broadcast secrets are auto-generated (reused across upgrades via Secret lookup); admin password is required and install fails without it (H3). - forail-task defaults to non-privileged, no host cgroup mount; opt in for the podman-in-pod path (H4). - Session cookies Secure by default (M8); ALLOWED_HOSTS defaults to the ingress host instead of '*' (M9). --- templates/secret.yaml | 29 +++++++++++++++++++++++----- values.yaml | 45 +++++++++++++++++++++++++++++-------------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/templates/secret.yaml b/templates/secret.yaml index 71f3dbe..4a6f031 100644 --- a/templates/secret.yaml +++ b/templates/secret.yaml @@ -1,13 +1,32 @@ +{{- /* +needtofix H3: never ship working secret defaults. Infrastructure secrets +(postgres password, Django SECRET_KEY, broadcast websocket secret) are taken +from an explicit override when set, otherwise reused from the existing +forail-secrets Secret (so upgrades keep the same value), otherwise generated +randomly on first install. forailAdminPassword has no safe auto-value and is +required. +*/ -}} +{{- $ns := include "forail.namespace" . -}} +{{- $existing := (lookup "v1" "Secret" $ns "forail-secrets") -}} +{{- $existingData := dict -}} +{{- if $existing -}}{{- $existingData = $existing.data -}}{{- end -}} +{{- $pgPass := .Values.secrets.postgresPassword | default (index $existingData "postgresPassword" | default "" | b64dec) | default (randAlphaNum 32) -}} +{{- $secretKey := .Values.secrets.forailSecretKey | default (index $existingData "forailSecretKey" | default "" | b64dec) | default (randAlphaNum 50) -}} +{{- $bcastSecret := .Values.secrets.forailBroadcastWebsocketSecret | default (index $existingData "forailBroadcastWebsocketSecret" | default "" | b64dec) | default (randAlphaNum 50) -}} +{{- $adminPass := .Values.secrets.forailAdminPassword | default (index $existingData "forailAdminPassword" | default "" | b64dec) -}} +{{- if not $adminPass -}} +{{- fail "secrets.forailAdminPassword is required — set it, e.g. --set secrets.forailAdminPassword=\"$(openssl rand -base64 24)\" (see needtofix H3)" -}} +{{- end -}} apiVersion: v1 kind: Secret metadata: name: forail-secrets - namespace: {{ include "forail.namespace" . }} + namespace: {{ $ns }} labels: {{- include "forail.componentLabels" (dict "root" . "component" "secrets") | nindent 4 }} type: Opaque stringData: - postgresPassword: {{ .Values.secrets.postgresPassword | quote }} - forailSecretKey: {{ .Values.secrets.forailSecretKey | quote }} - forailBroadcastWebsocketSecret: {{ .Values.secrets.forailBroadcastWebsocketSecret | quote }} - forailAdminPassword: {{ .Values.secrets.forailAdminPassword | quote }} + postgresPassword: {{ $pgPass | quote }} + forailSecretKey: {{ $secretKey | quote }} + forailBroadcastWebsocketSecret: {{ $bcastSecret | quote }} + forailAdminPassword: {{ $adminPass | quote }} diff --git a/values.yaml b/values.yaml index 53cf81d..d83054d 100644 --- a/values.yaml +++ b/values.yaml @@ -44,28 +44,42 @@ images: imagePullSecrets: [] # ── Secrets ─────────────────────────────────────────────── -# In production, manage via external secret operator. These are dev defaults. +# The chart ships NO working secret defaults (see needtofix H3). Leave these +# empty and the chart will auto-generate the infrastructure secrets on first +# install and reuse them across upgrades (via a Secret lookup). In production, +# set them explicitly or manage via an external secret operator. +# +# forailAdminPassword has no safe auto-value (the operator must know it), so it +# is REQUIRED: `helm install` fails unless you provide one, e.g. +# --set secrets.forailAdminPassword="$(openssl rand -base64 24)" secrets: - postgresPassword: "changeme-postgres" - forailSecretKey: "changeme-replace-with-openssl-rand-hex-32-output-aaaaaaa" - forailBroadcastWebsocketSecret: "changeme-replace-with-openssl-rand-hex-32-output-bbbb" - forailAdminPassword: "changeme-admin" + # Auto-generated when empty (override to pin an explicit value): + postgresPassword: "" + forailSecretKey: "" + forailBroadcastWebsocketSecret: "" + # Required — no default; install fails if unset: + forailAdminPassword: "" # ── Forail configuration ─────────────────────────────────── forail: admin: user: admin email: admin@forail.local - allowedHosts: "*" + # needtofix M9: do NOT default to "*" (Host-header injection → cache + # poisoning / poisoned password-reset links). Keep this in sync with + # ingress.host; add extra comma-separated hosts as needed. + allowedHosts: "forail.lan" # NOTE: avoid the .local TLD — Avahi/mDNS (`mdns_minimal [NOTFOUND=return]` # in nsswitch.conf on most desktop Linux distros) intercepts every # `.local` lookup and bypasses /etc/hosts, which makes browser access # to a manually-mapped forail.local fail with "Server Not Found" even # though curl from the same host works. `.lan` has no such hijack. csrfTrustedOrigins: "http://forail.lan:30080,https://forail.lan:30443,https://forail.lan" - # Set to false when serving over plain HTTP (test cluster). Forail's - # custom_settings.py reads FORAIL_COOKIE_SECURE for SESSION/CSRF cookies. - cookieSecure: "false" + # needtofix M8: default Secure cookies on (ingress TLS is on by default). + # Set to "false" only when serving over plain HTTP (e.g. a local dev run + # without TLS). Forail's custom_settings.py reads FORAIL_COOKIE_SECURE for + # SESSION/CSRF cookies. + cookieSecure: "true" node: name: forail-node type: hybrid @@ -122,11 +136,14 @@ web: task: replicas: 1 - # forail-task runs ansible-runner with podman, which needs a privileged - # container and host cgroup namespace. This is k8s test-cluster style; - # in prod consider a dedicated worker daemonset on isolated nodes. - privileged: true - hostCgroup: true + # needtofix H4: forail-task runs ansible-runner with podman, which CAN want + # a privileged container + host cgroup namespace. Both default OFF now — a + # privileged pod with a host cgroup mount is a trivial container escape to + # node-root. Opt in explicitly for the podman-in-pod execution path, and + # isolate such workers onto dedicated tainted nodes: + # --set task.privileged=true --set task.hostCgroup=true + privileged: false + hostCgroup: false resources: # 2Gi was OOM-killed during a single Demo Project sync + hello_world # run on a fresh cluster — receptor work units in /tmp die with the From 47b27e16ffb4ac7ba7683307f57761db3cb42e28 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Sun, 12 Jul 2026 15:40:00 +0200 Subject: [PATCH 03/10] helm: add opt-in NetworkPolicy and per-pod securityContext knobs - Tiered NetworkPolicy (default-deny ingress + scoped allows so Postgres/Redis aren't reachable cluster-wide), gated behind networkPolicy.enabled=false since k3s flannel doesn't enforce policy (M11). - podSecurityContext + per-workload securityContext values wired into web / frontend / assistant, empty by default pending per-image validation (M10). - Flag the floating opa/otel :latest tags for pinning (L15). --- templates/forail-assistant.yaml | 6 +++ templates/forail-frontend.yaml | 6 +++ templates/forail-web.yaml | 6 +++ templates/networkpolicy.yaml | 93 +++++++++++++++++++++++++++++++++ values.yaml | 25 +++++++++ 5 files changed, 136 insertions(+) create mode 100644 templates/networkpolicy.yaml diff --git a/templates/forail-assistant.yaml b/templates/forail-assistant.yaml index c1e64ab..ec4e0a6 100644 --- a/templates/forail-assistant.yaml +++ b/templates/forail-assistant.yaml @@ -56,10 +56,16 @@ spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-assistant image: "{{ .Values.images.assistant.repository }}:{{ .Values.images.assistant.tag }}" imagePullPolicy: {{ .Values.images.assistant.pullPolicy }} + {{- with .Values.securityContext.assistant }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: http, containerPort: 8100 } env: diff --git a/templates/forail-frontend.yaml b/templates/forail-frontend.yaml index fb2005b..c9d342d 100644 --- a/templates/forail-frontend.yaml +++ b/templates/forail-frontend.yaml @@ -34,10 +34,16 @@ spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-frontend image: "{{ .Values.images.frontend.repository }}:{{ .Values.images.frontend.tag }}" imagePullPolicy: {{ .Values.images.frontend.pullPolicy }} + {{- with .Values.securityContext.frontend }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: http, containerPort: 80 } readinessProbe: diff --git a/templates/forail-web.yaml b/templates/forail-web.yaml index 13b647f..8656c20 100644 --- a/templates/forail-web.yaml +++ b/templates/forail-web.yaml @@ -39,10 +39,16 @@ spec: {{- end }} # No explicit dep on forail-init: web crashloops until DB schema is ready, # which is k8s-native and avoids extra RBAC for a kubectl initContainer. + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-web image: "{{ .Values.images.backend.repository }}:{{ .Values.images.backend.tag }}" imagePullPolicy: {{ .Values.images.backend.pullPolicy }} + {{- with .Values.securityContext.web }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} command: ["launch_awx_web.sh"] ports: - { name: http, containerPort: 8013 } diff --git a/templates/networkpolicy.yaml b/templates/networkpolicy.yaml new file mode 100644 index 0000000..fe33e12 --- /dev/null +++ b/templates/networkpolicy.yaml @@ -0,0 +1,93 @@ +{{- /* +needtofix M11: tiered NetworkPolicy. Off by default (see values.networkPolicy); +enable only on a policy-enforcing CNI after validation. The intent: + + - default-deny all ingress in the namespace, then + - Postgres accepts 5432 only from web + task, + - Redis accepts 6379 only from web + task, + - web/frontend accept their HTTP/WS ports from anywhere (the ingress). + +Datastore ports are thereby unreachable from arbitrary pods on a flat network. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $ns := include "forail.namespace" . -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-default-deny-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: {} + policyTypes: ["Ingress"] + # No ingress rules → deny all; the allow policies below add exceptions. +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-postgres-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: postgres + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: web + - podSelector: + matchLabels: + app.kubernetes.io/component: task + ports: + - { protocol: TCP, port: 5432 } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-redis-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: redis + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: web + - podSelector: + matchLabels: + app.kubernetes.io/component: task + ports: + - { protocol: TCP, port: 6379 } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-http-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: ["web", "frontend"] + policyTypes: ["Ingress"] + ingress: + # Allow the HTTP/WS ports from any source (ingress controller terminates + # external traffic). Tighten to the ingress namespace/pod once known. + - ports: + - { protocol: TCP, port: 8013 } + - { protocol: TCP, port: 8015 } + - { protocol: TCP, port: 80 } +{{- end }} diff --git a/values.yaml b/values.yaml index d83054d..01648e6 100644 --- a/values.yaml +++ b/values.yaml @@ -31,12 +31,15 @@ images: tag: 7-alpine opa: repository: openpolicyagent/opa + # needtofix L15: pin to an immutable version tag (e.g. 0.70.0-rootless) + # before shipping — a floating tag drifts and undermines supply-chain trust. tag: latest-rootless nginx: repository: nginx tag: 1.27-alpine otelCollector: repository: otel/opentelemetry-collector-contrib + # needtofix L15: pin to an immutable version tag (e.g. 0.110.0) before shipping. tag: latest # Public ghcr.io images do not require pull secrets. @@ -180,6 +183,28 @@ assistant: requests: { memory: 1Gi, cpu: 250m } limits: { memory: 4Gi, cpu: 2000m } +# ── Network policy (needtofix M11) ──────────────────────── +# Off by default: k3s' default flannel CNI does not enforce NetworkPolicy, and +# a default-deny would silently do nothing there while breaking traffic on a +# CNI that DOES enforce it. Enable on a policy-capable cluster (Calico/Cilium) +# after validating: default-deny ingress + scoped allows between tiers so that +# Postgres:5432 / Redis:6379 are not reachable cluster-wide. +networkPolicy: + enabled: false + +# ── Pod hardening (needtofix M10) ───────────────────────── +# Per-workload securityContext. Empty by default (no behaviour change) so the +# chart stays deployable while each context is validated per image — note the +# frontend binds :80 and needs NET_BIND_SERVICE or a non-root port before caps +# can be dropped. The operator chart is the reference model to copy: +# runAsNonRoot: true, allowPrivilegeEscalation: false, +# capabilities: { drop: ["ALL"] }, readOnlyRootFilesystem: true +podSecurityContext: {} +securityContext: + web: {} + frontend: {} + assistant: {} + # ── Ingress ─────────────────────────────────────────────── ingress: enabled: true From 459e0f4d21abe0d3844688ebe13285045237fbc2 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Tue, 14 Jul 2026 11:15:00 +0200 Subject: [PATCH 04/10] docs: changelog + README for secure defaults and breaking changes --- CHANGELOG.md | 15 +++++++++++++++ README.md | 31 +++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f91c5..43f9365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and the chart uses SemVer (`version`) plus the upstream Forail CalVer ## [Unreleased] +### Security +- **No working secret defaults**: `postgresPassword`, `forailSecretKey` and + `forailBroadcastWebsocketSecret` are auto-generated on first install and + reused across upgrades (via a Secret `lookup`). `forailAdminPassword` is now + **required** — `helm install` fails unless you provide one. +- **`forail-task` defaults to non-privileged** with no host cgroup mount. Opt in + for the podman-in-pod execution path: `--set task.privileged=true --set + task.hostCgroup=true`. +- **Secure cookies on by default** (`forail.cookieSecure: "true"`) and + `forail.allowedHosts` defaults to the ingress host instead of `"*"`. +- **Opt-in NetworkPolicy** (`networkPolicy.enabled`, default false): default-deny + ingress with scoped allows so Postgres/Redis aren't reachable cluster-wide. +- **Per-workload `securityContext` / `podSecurityContext`** values wired into + web / frontend / assistant (empty by default, pending per-image validation). + ## [2026.06.0] - 2026-06-14 ### Changed diff --git a/README.md b/README.md index 6be47a6..9ef8173 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,40 @@ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -addext 'subjectAltName=DNS:forail.local,DNS:*.forail.local' kubectl -n forail create secret tls forail-tls --cert=tls.crt --key=tls.key -# Install: -helm install forail . -n forail --create-namespace -f values.yaml +# Install (admin password is required — install fails without it): +helm install forail . -n forail --create-namespace -f values.yaml \ + --set secrets.forailAdminPassword="$(openssl rand -base64 24)" ``` If you mirror the images to a private registry, override `images.*.repository` and set `imagePullSecrets` in your values file. +## Secure defaults & breaking changes + +This chart ships **no working secret defaults**: + +- `secrets.postgresPassword`, `secrets.forailSecretKey` and + `secrets.forailBroadcastWebsocketSecret` are **auto-generated** on first + install and reused across upgrades — leave them empty unless you want to pin + explicit values. +- `secrets.forailAdminPassword` is **required**; `helm install` fails if unset. +- `forail-task` runs **non-privileged by default**. The podman-in-pod job + execution path needs privileges — enable it explicitly and, ideally, isolate + such workers onto dedicated tainted nodes: + + ```sh + --set task.privileged=true --set task.hostCgroup=true + ``` + +- Session cookies are `Secure` by default (`forail.cookieSecure: "true"`) and + `forail.allowedHosts` defaults to the ingress host (not `"*"`). +- `networkPolicy.enabled` (default false) adds a default-deny ingress policy with + scoped allows so Postgres/Redis aren't reachable cluster-wide. Enable it on a + policy-enforcing CNI (Calico/Cilium) — k3s' default flannel does not enforce it. +- `podSecurityContext` and per-workload `securityContext.{web,frontend,assistant}` + are available for pod hardening (empty by default; validate per image — the + frontend binds `:80` and needs `NET_BIND_SERVICE` or a non-root port). + ## Layout ``` From f7ca280b8f32ea1415f39c1ddb6669c9aa92ddd2 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Wed, 15 Jul 2026 10:20:00 +0200 Subject: [PATCH 05/10] helm: keep loopback hosts in allowedHosts so probes don't crash-loop The M9 hardening narrowed allowedHosts to the ingress host, but the in-cluster liveness/readiness probes reach the app on 127.0.0.1. Django rejects any Host not in ALLOWED_HOSTS with HTTP 400, so the probes failed and the web pod crash-looped. Keep 127.0.0.1 and localhost listed. --- values.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/values.yaml b/values.yaml index 01648e6..f97a398 100644 --- a/values.yaml +++ b/values.yaml @@ -69,9 +69,11 @@ forail: user: admin email: admin@forail.local # needtofix M9: do NOT default to "*" (Host-header injection → cache - # poisoning / poisoned password-reset links). Keep this in sync with - # ingress.host; add extra comma-separated hosts as needed. - allowedHosts: "forail.lan" + # poisoning / poisoned password-reset links). Keep the ingress host in sync + # here; 127.0.0.1 and localhost MUST stay in the list because the in-cluster + # liveness/readiness probes reach the app over the loopback address (Django + # rejects any Host not listed with HTTP 400, which would crash-loop the pod). + allowedHosts: "forail.lan,127.0.0.1,localhost" # NOTE: avoid the .local TLD — Avahi/mDNS (`mdns_minimal [NOTFOUND=return]` # in nsswitch.conf on most desktop Linux distros) intercepts every # `.local` lookup and bypasses /etc/hosts, which makes browser access From 935355926724e62133babbcc9cbf00e4a6070bef Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Wed, 15 Jul 2026 13:45:00 +0200 Subject: [PATCH 06/10] helm: register kubernetes-incluster-auth receptor worktype The default instance group is a container group, so the scheduler submits each job to receptor as the kubernetes-incluster-auth work type. Without a matching work-kubernetes worktype receptor rejects the launch ("unknown work type kubernetes-incluster-auth") and the job errors at 0s. authmethod incluster uses the task pod's own ServiceAccount token. --- files/receptor/receptor.conf | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/files/receptor/receptor.conf b/files/receptor/receptor.conf index 3a0c095..7a57c8b 100644 --- a/files/receptor/receptor.conf +++ b/files/receptor/receptor.conf @@ -14,3 +14,16 @@ command: /var/lib/awx/venv/awx/bin/ansible-runner params: worker allowruntimeparams: true + +# Container-group job execution. The "default" instance group is a container +# group, so the scheduler submits each job to receptor as the +# "kubernetes-incluster-auth" work type; without this worktype receptor rejects +# the launch ("unknown work type kubernetes-incluster-auth") and the job errors +# at 0s. authmethod incluster uses the task pod's own ServiceAccount token, which +# has the pod RBAC granted in rbac.yaml. +- work-kubernetes: + worktype: kubernetes-incluster-auth + authmethod: incluster + allowruntimeauth: true + allowruntimepod: true + allowruntimeparams: true From e0032330acd50c8e393406269921485f36b8a6cd Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Wed, 15 Jul 2026 17:10:00 +0200 Subject: [PATCH 07/10] helm: add namespaced pod RBAC and ServiceAccount for job execution Receptor lists/creates/deletes container-group job pods with the task pod's ServiceAccount, so it needs pod RBAC in the release namespace. Without it every launch fails ("pods is forbidden ... cannot list resource pods") and the job stays pending. Adds a dedicated ServiceAccount plus a namespaced Role/RoleBinding (pods, pods/log, pods/attach, pods/exec); both gated behind serviceAccount.create / rbac.create. --- templates/rbac.yaml | 53 +++++++++++++++++++++++++++++++++++++++++++++ values.yaml | 13 +++++++++++ 2 files changed, 66 insertions(+) create mode 100644 templates/rbac.yaml diff --git a/templates/rbac.yaml b/templates/rbac.yaml new file mode 100644 index 0000000..0e0fcad --- /dev/null +++ b/templates/rbac.yaml @@ -0,0 +1,53 @@ +{{/* +Job-execution RBAC. + +The forail-task scheduler runs each automation job in a Kubernetes "container +group": receptor lists, creates and deletes those job pods through the task +pod's ServiceAccount. Without pod RBAC in this namespace every launch fails +with "pods is forbidden: User \"system:serviceaccount::default\" cannot +list resource \"pods\"", and the job never leaves pending. The Role below is +scoped to this namespace only (no cluster-wide pod access) and paired with the +MY_POD_NAMESPACE downward-API env so pods land here, where the grant applies. +*/}} +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "forail.serviceAccountName" . }} + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +{{- end }} +{{- if .Values.rbac.create }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: forail-job-runner + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods/log", "pods/attach", "pods/exec"] + verbs: ["get", "list", "watch", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: forail-job-runner + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: forail-job-runner +subjects: + - kind: ServiceAccount + name: {{ include "forail.serviceAccountName" . }} + namespace: {{ include "forail.namespace" . }} +{{- end }} diff --git a/values.yaml b/values.yaml index f97a398..1e881e5 100644 --- a/values.yaml +++ b/values.yaml @@ -158,6 +158,19 @@ task: requests: { memory: 1Gi, cpu: 200m } limits: { memory: 4Gi, cpu: 2000m } +# ── Job-execution RBAC ──────────────────────────────────── +# The forail-task scheduler launches every automation job as a pod in a +# Kubernetes container group; receptor manages those pods with the task pod's +# ServiceAccount. That account therefore needs namespaced pod RBAC — without it +# each launch fails ("pods is forbidden ... cannot list resource pods") and the +# job stays pending. See templates/rbac.yaml. Disable both only when you wire an +# external execution node and want no in-cluster job pods. +serviceAccount: + create: true + name: forail +rbac: + create: true + # ── Frontend ────────────────────────────────────────────── frontend: replicas: 1 From ed56c4711697a08400b3f4d30aed140fb7083af0 Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Thu, 16 Jul 2026 09:30:00 +0200 Subject: [PATCH 08/10] helm: add serviceAccountName helper and MY_POD_NAMESPACE env serviceAccountName resolves the job-runner ServiceAccount (falls back to the namespace default account when serviceAccount.create is false). MY_POD_NAMESPACE is bound to the release namespace via the downward API so the container-group scheduler launches job pods where the pod RBAC applies, not the cluster "default" namespace. --- templates/_helpers.tpl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index dfbe14b..3a7a798 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -98,6 +98,26 @@ Wraps DB, Redis, secrets, OTel, admin into one block to avoid drift. value: {{ .Values.forail.otel.tracesSampler | quote }} - name: OTEL_TRACES_SAMPLER_ARG value: {{ .Values.forail.otel.tracesSamplerArg | quote }} +# Namespace the container-group scheduler launches automation job pods into +# (AWX_CONTAINER_GROUP_DEFAULT_NAMESPACE reads MY_POD_NAMESPACE). Bind it to +# the release namespace via the downward API so jobs run where the pod RBAC +# in rbac.yaml is granted, not the cluster "default" namespace. +- name: MY_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +{{- end }} + +{{/* +Name of the ServiceAccount the web/task/init pods run as. When +serviceAccount.create is false, fall back to the namespace "default" account. +*/}} +{{- define "forail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- .Values.serviceAccount.name -}} +{{- else -}} +default +{{- end -}} {{- end }} {{/* From e67ccfdade6716e60a7dc9250b8f3cb5ea199e0a Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Thu, 16 Jul 2026 13:15:00 +0200 Subject: [PATCH 09/10] helm: run web and task pods under the job-runner ServiceAccount Bind both backend deployments to the ServiceAccount that carries the pod RBAC so the task scheduler (and receptor) can manage container-group job pods. --- templates/forail-task.yaml | 1 + templates/forail-web.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/forail-task.yaml b/templates/forail-task.yaml index b84480f..e805f9c 100644 --- a/templates/forail-task.yaml +++ b/templates/forail-task.yaml @@ -15,6 +15,7 @@ spec: labels: {{- include "forail.componentLabels" (dict "root" . "component" "task") | nindent 8 }} spec: + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/templates/forail-web.yaml b/templates/forail-web.yaml index 8656c20..4191786 100644 --- a/templates/forail-web.yaml +++ b/templates/forail-web.yaml @@ -34,6 +34,7 @@ spec: labels: {{- include "forail.componentLabels" (dict "root" . "component" "web") | nindent 8 }} spec: + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} From beff552d26a3183a3f337f4c39c9c2fcba45180b Mon Sep 17 00:00:00 2001 From: Krstan Vjestica Date: Thu, 16 Jul 2026 16:40:00 +0200 Subject: [PATCH 10/10] helm: run the init job under the job-runner ServiceAccount Keep the provisioning job on the same ServiceAccount as web/task for a consistent identity across all backend pods. --- templates/forail-init-job.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/forail-init-job.yaml b/templates/forail-init-job.yaml index dbfb10f..75c3fcc 100644 --- a/templates/forail-init-job.yaml +++ b/templates/forail-init-job.yaml @@ -16,6 +16,7 @@ spec: {{- include "forail.componentLabels" (dict "root" . "component" "init") | nindent 8 }} spec: restartPolicy: OnFailure + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }}