Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <operator-chart-source> -n forail --set forail.token=<PAT>
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
13 changes: 13 additions & 0 deletions files/receptor/receptor.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

{{/*
Expand Down
6 changes: 6 additions & 0 deletions templates/forail-assistant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions templates/forail-frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions templates/forail-init-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions templates/forail-task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
7 changes: 7 additions & 0 deletions templates/forail-web.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,22 @@ spec:
labels:
{{- include "forail.componentLabels" (dict "root" . "component" "web") | nindent 8 }}
spec:
serviceAccountName: {{ include "forail.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 8 }}
{{- 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 }
Expand Down
93 changes: 93 additions & 0 deletions templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
53 changes: 53 additions & 0 deletions templates/rbac.yaml
Original file line number Diff line number Diff line change
@@ -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:<ns>: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 }}
29 changes: 24 additions & 5 deletions templates/secret.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
Loading
Loading