diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index c4986c39..bf82cd34 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.4.1 +version: 3.5.0 appVersion: "3.1.1" home: https://plane.so/ diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 8dfa3fa5..8ee2abc9 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -27,12 +27,23 @@ If you plan to use Traefik as your ingress controller, install it before deployi ## Migrating the Ingress Controller -The chart selects between two ingress templates based on `ingress.ingressClass`: +The chart selects between three ingress templates based on `ingress.ingressClass`: -| `ingressClass` value | Template rendered | Resource kind | -| ------------------------------ | -------------------------------- | ---------------------------------- | -| `traefik` (or starts with it) | `templates/ingress-traefik.yaml` | `traefik.io/v1alpha1 IngressRoute` | -| Any other value (e.g. `nginx`) | `templates/ingress.yaml` | `networking.k8s.io/v1 Ingress` | +| `ingressClass` value | Template rendered | Resource kind | +| ----------------------------- | ---------------------------------- | -------------------------------------------- | +| `traefik` (or starts with it) | `templates/ingress-traefik.yaml` | `traefik.io/v1alpha1 IngressRoute` | +| `openshift` | `templates/ingress-openshift.yaml` | `route.openshift.io/v1 Route` (one per path) | +| `nginx` | `templates/ingress.yaml` | `networking.k8s.io/v1 Ingress` | + +> **Any other value renders no ingress at all**, silently. `templates/ingress.yaml` is +> gated on `ingressClass` being exactly `nginx`, so `alb`, `haproxy`, `contour`, +> `openshift-default` or a custom IngressClass name produce a successful-looking +> install with nothing reachable. Use one of the three values above, or create the +> Ingress yourself. + +> **No body-size limit on Routes.** `ingress.traefik.maxRequestBodyBytes` has no +> OpenShift equivalent; HAProxy Routes cannot cap request bodies. Enforce upload +> limits in the application or at a WAF/CDN in front of the router. The default value is `"traefik"`. If you are switching to a standard ingress controller such as nginx, follow the migration steps below. @@ -44,7 +55,7 @@ The default value is `"traefik"`. If you are switching to a standard ingress con ```yaml ingress: - ingressClass: "nginx" # or whichever class your controller exposes + ingressClass: "nginx" # supported: nginx | traefik* | openshift ``` 3. **Run `helm upgrade`**: @@ -90,7 +101,11 @@ The default value is `"traefik"`. If you are switching to a standard ingress con | `ingress.ingressClass` | `traefik` | Selects which template is active (see table above). | | `ingress.traefik.maxRequestBodyBytes` | `20971520` | Max request body size for Traefik's buffering middleware. Ignored when not using Traefik. | | `ingress.traefik.entryPoints` | `[]` | Traefik entrypoints for the `IngressRoute`. Empty means derive from your SSL settings — see below. Ignored when not using Traefik. | -| `ingress.ingress_annotations` | `{}` | Standard `Ingress` annotations. Ignored when `ingressClass` starts with `traefik`. | +| `ingress.ingress_annotations` | `{}` | Standard `Ingress` annotations. Only rendered when `ingressClass` is exactly `nginx`; the `openshift` Route path uses `ingress.openshift.route_annotations`. | +| `ingress.openshift.timeout` | `300s` | HAProxy per-route timeout. The router default of 30s severs `/live/` WebSockets and `/pi/` streaming. | +| `ingress.openshift.termination` | `edge` | Route TLS termination (`edge` or `reencrypt`; `passthrough` cannot do path routing). | +| `ingress.openshift.externalCertificate` | `''` | Name of a TLS Secret for the router to serve instead of its wildcard cert. OpenShift 4.16+. | +| `ingress.openshift.route_annotations` | `{}` | Extra annotations on every Route, e.g. `haproxy.router.openshift.io/rewrite-target`. | ### TLS options: choosing how HTTPS is handled @@ -436,6 +451,65 @@ securityContext: runAsUser: 10001 ``` +### OpenShift (`restricted-v2` SCC) + +OpenShift is the inverse case: it refuses to let you choose the UID at all. The +`restricted-v2` SCC ignores the image's `USER`, assigns an arbitrary UID from the +namespace's range, and places the process in group 0. It also validates the pod's +own request, using a *different* strategy for each field: + +- **`runAsUser` — `MustRunAsRange`.** Must fall inside the namespace's + `openshift.io/sa.scc.uid-range` annotation. +- **`fsGroup` — `MustRunAs`.** Must match the range or value derived from + `openshift.io/sa.scc.supplemental-groups`, falling back to the UID range when + that annotation is absent. + +Either way, a manifest naming a *specific* `runAsUser` or `fsGroup` outside what +the namespace allows is **rejected at admission**, so enabling the block above +with its defaults means nothing schedules. + +Keep the hardening and drop only the IDs. A `null` in a values file removes the key +during Helm's coalescing, so the rendered `securityContext` keeps `runAsNonRoot`, +`seccompProfile` and the dropped capabilities while carrying no UID: + +```bash +helm upgrade --install plane-app plane/plane-enterprise \ + --namespace plane \ + -f my-values.yaml \ + -f examples/values-openshift.yaml +``` + +[`examples/values-openshift.yaml`](examples/values-openshift.yaml) applies that, +un-pins the email service's uid 100, selects the OpenShift ingress path, and forces +the bundled datastores off. Three things to know before you use it: + +- **Image requirement.** The images must grant group 0 write access to the paths + they write at runtime. Older images crash under an arbitrary UID — nginx exits + with `mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`. +- **Datastores must be external.** `postgres`, `redis`, `rabbitmq`, `minio` and + `opensearch` are third-party images with baked-in UID and data-directory + ownership; they cannot run under an arbitrary UID and the chart deliberately does + not apply the hardened context to them. Use managed services and leave + `local_setup` off, or grant those ServiceAccounts a relaxed SCC. +- **Upgrading an existing deployment: verify before you rely on it.** Moving a + running install from the pinned-uid-1000 posture to this one *often* needs no + data migration, because kubelet re-applies `fsGroup` to volume contents on + mount — but that is not guaranteed, and a PVC left owned by uid/gid 1000 is + unwritable by the SCC-assigned identity. Whether it happens depends on the CSI + driver: + - `fsGroupPolicy: ReadWriteOnceWithFSType` (the default) only relabels + `ReadWriteOnce` volumes with a defined `fsType` — an **RWX** volume (NFS, + EFS, Azure Files) gets nothing. + - `fsGroupPolicy: None` disables it entirely. + - A driver advertising `VOLUME_MOUNT_GROUP` takes ownership over itself, and + both `fsGroupPolicy` and `fsGroupChangePolicy` are ignored. + + Check yours with + `kubectl get csidriver -o jsonpath='{.spec.fsGroupPolicy}'`, and + rehearse the upgrade against a **snapshot or clone** of the real PVCs before + doing it in production. If ownership is not relabelled, `chown -R` the volume + to the namespace's assigned GID from a maintenance pod. + ### Docker Registry | Setting | Default | Required | Description | diff --git a/charts/plane-enterprise/examples/values-openshift.yaml b/charts/plane-enterprise/examples/values-openshift.yaml new file mode 100644 index 00000000..e6870edf --- /dev/null +++ b/charts/plane-enterprise/examples/values-openshift.yaml @@ -0,0 +1,98 @@ +# ============================================================================= +# OpenShift (restricted-v2 SCC) +# ============================================================================= +# Overlay these on top of your own values: +# +# helm upgrade --install plane-ee plane/plane-enterprise \ +# -f my-values.yaml -f examples/values-openshift.yaml +# +# WHY THIS FILE EXISTS +# +# OpenShift's default `restricted-v2` SCC ignores the image's USER and runs every +# container as an arbitrary UID from the namespace's range, always in group 0. It +# also validates the pod's own request with MustRunAsRange: a manifest that asks +# for a *specific* runAsUser or fsGroup outside that range is REJECTED at +# admission, so `securityContext.enabled: true` with the chart's defaults means no +# pod ever schedules. +# +# The fix is not to disable hardening — it is to keep everything except the IDs +# and let the platform assign those. Setting a key to `null` in a values file +# removes it during Helm's coalescing, so the rendered securityContext keeps +# runAsNonRoot, seccompProfile and the dropped capabilities but carries no UID. +# +# REQUIRES the images that grant group 0 write access to their runtime paths +# (plane-ee #9018). Older images crash under an arbitrary UID — nginx exits with +# `mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`. +# ============================================================================= + +securityContext: + enabled: true + podSecurityContext: + runAsUser: null # assigned by the SCC from the namespace's UID range + runAsGroup: null # the SCC always places the process in group 0 + fsGroup: null # assigned by the SCC; still applied to PVCs on mount + containerSecurityContext: + runAsUser: null + +# ----------------------------------------------------------------------------- +# Services that pin their own UID outside the block above +# ----------------------------------------------------------------------------- +services: + email_service: + # The image's app-user is uid 100; let the SCC choose instead. + runAsUser: null + + # --------------------------------------------------------------------------- + # Bundled datastores MUST be external on OpenShift. + # + # postgres / redis / rabbitmq / minio / opensearch are third-party images with + # baked-in UID and data-directory ownership assumptions. They cannot run under + # an arbitrary UID, and the chart deliberately does not apply the hardened + # securityContext to them. Point the chart at managed services (RDS, + # ElastiCache, Amazon MQ, S3, OpenSearch Service) and leave local_setup off. + # + # If you must run them in-cluster, they need a relaxed SCC (e.g. `oc adm + # policy add-scc-to-user anyuid -z `), which defeats the point. + # --------------------------------------------------------------------------- + postgres: + local_setup: false + redis: + local_setup: false + rabbitmq: + local_setup: false + minio: + local_setup: false + opensearch: + local_setup: false + +# ----------------------------------------------------------------------------- +# Ingress +# ----------------------------------------------------------------------------- +# 'openshift' renders one route.openshift.io/v1 Route per path, with the HAProxy +# timeout set explicitly. This is the only OpenShift ingress path the chart offers +# — 'openshift-default' (letting the ingress-to-route controller convert a plain +# Ingress) renders nothing, because templates/ingress.yaml is gated on 'nginx'. +ingress: + enabled: true + ingressClass: 'openshift' + openshift: + # The router default is 30s, which severs /live/'s collaborative-editing + # WebSockets and /pi/'s streaming responses. Do not lower this. + timeout: '300s' + termination: 'edge' + insecureEdgeTerminationPolicy: 'Redirect' + # Leave empty to serve the Ingress Operator's wildcard certificate. + externalCertificate: '' + +# The router terminates TLS, so the chart's own cert-manager wiring is not used. +ssl: + createIssuer: false + generateCerts: false + +# ----------------------------------------------------------------------------- +# NOT handled by this file +# ----------------------------------------------------------------------------- +# - Traefik's body-size Middleware (ingress.traefik.maxRequestBodyBytes) has NO +# OpenShift Route equivalent. Enforce upload limits in the app or at a WAF. +# - A path that needs prefix stripping requires +# `haproxy.router.openshift.io/rewrite-target` via ingress.openshift.route_annotations. diff --git a/charts/plane-enterprise/templates/ingress-openshift.yaml b/charts/plane-enterprise/templates/ingress-openshift.yaml new file mode 100644 index 00000000..8304905d --- /dev/null +++ b/charts/plane-enterprise/templates/ingress-openshift.yaml @@ -0,0 +1,117 @@ +{{/* +================================================================================ +OpenShift ingress: one route.openshift.io/v1 Route per path. +================================================================================ +Rendered when ingress.ingressClass == "openshift". + +Why explicit Routes rather than a plain Ingress: + +OpenShift's ingress-to-route controller can convert a networking.k8s.io/v1 +Ingress into Routes. This chart does not offer that path: templates/ingress.yaml +is gated on ingressClass == "nginx", so setting "openshift-default" renders +nothing. Declaring the Routes here is also the more predictable option — the +conversion only picks up an Ingress whose class maps to the +openshift.io/ingress-to-route controller, and whether per-path HAProxy +annotations survive it varies by OCP version. Plane needs the timeout below, so +there is no guesswork this way. + +Differences from the Traefik IngressRoute this mirrors: + + - Ordering is irrelevant. HAProxy matches the most specific path prefix, so + the "/" catch-all cannot shadow the others the way it would in Traefik. + - There is NO equivalent of the `-body-limit` Middleware. HAProxy + Routes have no per-route request-body cap, so the upload limit enforced by + ingress.traefik.maxRequestBodyBytes is NOT applied here. Enforce it in the + application or at a WAF/CDN in front of the router. + - Path-based Routes require edge or reencrypt TLS termination; they are not + supported with passthrough. +*/}} +{{- if and .Values.ingress.enabled (eq .Values.ingress.ingressClass "openshift") .Values.license.licenseDomain }} +{{- $host := .Values.license.licenseDomain }} +{{- $name := .Release.Name }} +{{- $oc := .Values.ingress.openshift | default dict }} + +{{/* Every Route below carries a spec.path, and OpenShift rejects a Route that + combines a path with passthrough termination -- the router cannot inspect + the URL of a connection it never decrypts. Caught here rather than at + admission, so a bad value fails the render instead of half-applying. */}} +{{- $termination := $oc.termination | default "edge" }} +{{- if not (has $termination (list "edge" "reencrypt")) }} +{{- fail (printf "ingress.openshift.termination must be \"edge\" or \"reencrypt\", got %q. Path-based Routes cannot use passthrough termination; see charts/plane-enterprise/README.md." $termination) }} +{{- end }} + +{{/* Same path -> service mapping as templates/ingress.yaml, most specific first + (ordering is cosmetic here, kept aligned so the two are easy to diff). */}} +{{- $routes := list + (dict "slug" "spaces" "path" "/spaces/" "svc" (printf "%s-space" $name) "port" 3000) + (dict "slug" "god-mode" "path" "/god-mode/" "svc" (printf "%s-admin" $name) "port" 3000) + (dict "slug" "api" "path" "/api/" "svc" (printf "%s-api" $name) "port" 8000) + (dict "slug" "auth" "path" "/auth/" "svc" (printf "%s-api" $name) "port" 8000) + (dict "slug" "graphql" "path" "/graphql/" "svc" (printf "%s-api" $name) "port" 8000) + (dict "slug" "marketplace" "path" "/marketplace/" "svc" (printf "%s-api" $name) "port" 8000) + (dict "slug" "live" "path" "/live/" "svc" (printf "%s-live" $name) "port" 3000) +}} +{{- if .Values.services.silo.enabled }} +{{- $routes = append $routes (dict "slug" "silo" "path" "/silo/" "svc" (printf "%s-silo" $name) "port" 3000) }} +{{- end }} +{{- if .Values.services.pi.enabled }} +{{- $routes = append $routes (dict "slug" "pi" "path" "/pi/" "svc" (printf "%s-pi-api" $name) "port" 8000) }} +{{- end }} +{{- if and (eq (include "plane.minioEnabled" .) "true") .Values.env.docstore_bucket }} +{{- $routes = append $routes (dict "slug" "docstore" "path" (printf "/%s" .Values.env.docstore_bucket) "svc" (printf "%s-minio" $name) "port" 9000) }} +{{- end }} +{{/* Catch-all LAST, purely for readability. */}} +{{- $routes = append $routes (dict "slug" "web" "path" "/" "svc" (printf "%s-web" $name) "port" 3000) }} + +{{/* The bundled MinIO console and RabbitMQ management UI live on their own hosts, + matching templates/ingress.yaml. Both are gated on the corresponding + local_setup, so neither renders in the recommended OpenShift configuration + (where the bundled datastores are off because they cannot run under an + arbitrary UID) -- they are here for a cluster that grants those workloads a + relaxed SCC. */}} +{{- if and (eq (include "plane.minioEnabled" .) "true") .Values.ingress.minioHost }} +{{- $routes = append $routes (dict "slug" "minio-console" "host" .Values.ingress.minioHost "path" "/" "svc" (printf "%s-minio" $name) "port" 9090) }} +{{- end }} +{{- if and .Values.services.rabbitmq.local_setup .Values.ingress.rabbitmqHost }} +{{- $routes = append $routes (dict "slug" "rabbitmq-console" "host" .Values.ingress.rabbitmqHost "path" "/" "svc" (printf "%s-rabbitmq" $name) "port" 15672) }} +{{- end }} + +{{- range $r := $routes }} +--- +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + namespace: {{ $.Release.Namespace }} + name: {{ $name }}-{{ $r.slug }} + labels: + {{- include "plane.commonLabels" $ | nindent 4 }} + annotations: + {{/* The router's default timeout is 30s, which silently severs /live/'s + collaborative-editing WebSockets and /pi/'s streaming responses. */}} + haproxy.router.openshift.io/timeout: {{ $oc.timeout | default "300s" | quote }} + {{- with $oc.route_annotations }} + {{- range $key, $value := . }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + host: {{ $r.host | default $host }} + path: {{ $r.path }} + to: + kind: Service + name: {{ $r.svc }} + weight: 100 + port: + targetPort: {{ $r.port }} + tls: + termination: {{ $termination }} + insecureEdgeTerminationPolicy: {{ $oc.insecureEdgeTerminationPolicy | default "Redirect" }} + {{- if $oc.externalCertificate }} + {{/* Serve a cert from a namespace Secret instead of the router's wildcard. + Requires OpenShift 4.16+ and a RoleBinding granting the router + permission to read the Secret; leave unset to use the wildcard. */}} + externalCertificate: + name: {{ $oc.externalCertificate }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/plane-enterprise/templates/ingress.yaml b/charts/plane-enterprise/templates/ingress.yaml index 68088a4d..e439bf6c 100644 --- a/charts/plane-enterprise/templates/ingress.yaml +++ b/charts/plane-enterprise/templates/ingress.yaml @@ -1,3 +1,14 @@ +{{/* +Standard networking.k8s.io/v1 Ingress. Gated on ingressClass being exactly +"nginx"; the other templates are ingress-traefik.yaml ("traefik*") and +ingress-openshift.yaml ("openshift"). + +NOTE: any other class (alb, haproxy, contour, openshift-default, a custom +IngressClass name, ...) renders nothing at all, with no error -- despite the +README describing this template as the fallback for "any other value". That +mismatch is deliberately left as-is for now and tracked separately; do not widen +this condition without checking what else assumes the nginx-only behaviour. +*/}} {{- if and .Values.ingress.enabled (eq .Values.ingress.ingressClass "nginx") .Values.license.licenseDomain }} apiVersion: networking.k8s.io/v1 @@ -7,9 +18,9 @@ metadata: name: {{ .Release.Name }}-ingress labels: {{- include "plane.commonLabels" $ | nindent 4 }} - {{- if gt (len .Values.ingress.ingress_annotations) 0 }} + {{- with .Values.ingress.ingress_annotations }} annotations: - {{- range $key, $value := .Values.ingress.ingress_annotations }} + {{- range $key, $value := . }} {{ $key }}: {{ $value | quote }} {{- end }} {{- end }} diff --git a/charts/plane-enterprise/templates/workloads/email.deployment.yaml b/charts/plane-enterprise/templates/workloads/email.deployment.yaml index eaaab8ff..4485a540 100644 --- a/charts/plane-enterprise/templates/workloads/email.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/email.deployment.yaml @@ -54,8 +54,25 @@ spec: - name: {{ .Release.Name }}-email-app imagePullPolicy: {{ .Values.services.email_service.pullPolicy | default "Always" }} image: {{ .Values.services.email_service.image | default "makeplane/email-commercial" }}:{{ .Values.planeVersion }} - securityContext: - runAsUser: 100 + {{- /* The email image's app-user really is uid 100 and owns /opt/email, so + this service pins its own UID instead of taking runAsUser from the + shared block. It still takes everything else from that block when + securityContext.enabled is set -- runAsNonRoot, dropped capabilities + and seccompProfile are not UID-specific, and omitting them would + leave this the one unhardened workload. Set + services.email_service.runAsUser to null on platforms that assign the + UID themselves (OpenShift's restricted-v2 SCC rejects any runAsUser + outside the namespace's range). */}} + {{- $emailCtx := dict }} + {{- if .Values.securityContext.enabled }} + {{- $emailCtx = omit .Values.securityContext.containerSecurityContext "runAsUser" }} + {{- end }} + {{- with .Values.services.email_service.runAsUser }} + {{- $emailCtx = merge (dict "runAsUser" .) $emailCtx }} + {{- end }} + {{- with $emailCtx }} + securityContext: {{- toYaml . | nindent 10 }} + {{- end }} stdin: true tty: true readinessProbe: diff --git a/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml b/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml index a8f4c848..502888c9 100644 --- a/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml +++ b/charts/plane-enterprise/templates/workloads/opensearch.stateful.yaml @@ -39,8 +39,15 @@ spec: {{- include "plane.commonLabels" $ | nindent 8 }} spec: {{- include "plane.podScheduling" .Values.services.opensearch }} + {{- /* The OpenSearch image requires uid/gid 1000 to own its data dir. Set + these to null where the platform assigns them itself (OpenShift's + restricted-v2 SCC rejects a fixed runAsUser/fsGroup) -- but note the + upstream image does not run under an arbitrary UID either, so on + OpenShift use an external OpenSearch and leave local_setup off. */}} + {{- with .Values.services.opensearch.fsGroup }} securityContext: - fsGroup: 1000 + fsGroup: {{ . }} + {{- end }} containers: - image: {{ .Values.services.opensearch.image }} imagePullPolicy: {{ .Values.services.opensearch.pullPolicy | default "IfNotPresent" }} @@ -55,7 +62,9 @@ spec: memory: {{ .Values.services.opensearch.memoryLimit | default "3Gi" | quote }} cpu: {{ .Values.services.opensearch.cpuLimit | default "750m" | quote }} securityContext: - runAsUser: 1000 + {{- with .Values.services.opensearch.runAsUser }} + runAsUser: {{ . }} + {{- end }} runAsNonRoot: true envFrom: - secretRef: diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 29b2cf80..f8175eef 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -38,10 +38,16 @@ ingress: enabled: true minioHost: '' rabbitmqHost: '' + # Selects which ingress template is rendered: + # 'traefik*' -> Traefik IngressRoute CRD (templates/ingress-traefik.yaml) + # 'openshift' -> OpenShift Route per path (templates/ingress-openshift.yaml) + # 'nginx' -> networking.k8s.io/v1 Ingress (templates/ingress.yaml) + # Any OTHER value renders no ingress at all -- see the note in templates/ingress.yaml. ingressClass: 'traefik' - # If using nginx ingress controller, set this to 'nginx' and add the appropriate annotations to set the proxy body size limit. - # These annotations are ONLY rendered when ingressClass is 'nginx' (the nginx Ingress template is gated on - # ingressClass == "nginx"); they have no effect with traefik. Example: + # Annotations for the standard Ingress — e.g. to set the proxy body size limit on + # the nginx controller. ONLY rendered when ingressClass is exactly 'nginx'; they + # have no effect with traefik, and the 'openshift' Route path takes its + # annotations from ingress.openshift.route_annotations instead. Example: # - proxy-body-size: nginx equivalent of traefik's maxRequestBodyBytes (upload size limit). # - proxy-buffer-size: size of the buffer for the response headers from upstream; bump this to avoid # "502 upstream sent too big header" errors. @@ -58,6 +64,21 @@ ingress: # Set explicitly only if your Traefik install renamed the default entrypoints, # e.g. entryPoints: ['websecure', 'web'] or ['https']. entryPoints: [] + # Only read when ingressClass is 'openshift'. + openshift: + # HAProxy's per-route timeout. The router default is 30s, which severs + # /live/'s collaborative-editing WebSockets and /pi/'s streaming responses. + timeout: '300s' + termination: 'edge' # edge | reencrypt (passthrough cannot do path routing) + insecureEdgeTerminationPolicy: 'Redirect' + # Name of a TLS Secret for the router to serve instead of its wildcard cert. + # Requires OpenShift 4.16+ and a RoleBinding letting the router read it. + externalCertificate: '' + # Extra annotations applied to every Route, e.g. rate limiting or + # haproxy.router.openshift.io/rewrite-target on a path that needs stripping. + route_annotations: {} + # NOTE: OpenShift Routes have NO equivalent of Traefik's body-limit + # Middleware. maxRequestBodyBytes above is not enforced on this path. ssl: tls_secret_name: '' # If you have a custom TLS secret name @@ -98,15 +119,76 @@ ssl: # init containers). Plane's images run fine as non-root, so these defaults # satisfy the PSA "restricted" profile. # -# NOT applied to: +# ------------------------------------------------------------------ +# WHICH RECIPE DO I WANT? +# ------------------------------------------------------------------ +# +# 1) Leave it off (the default). Containers start as root and each entrypoint +# drops to the `plane` user (uid 1000) itself. Fine for Docker Compose and +# for clusters with no Pod Security Admission policy. +# +# securityContext: +# enabled: false +# +# 2) Non-root, uid 1000. Satisfies the PSA "restricted" profile. This is the +# common Kubernetes choice and needs nothing but the toggle: +# +# securityContext: +# enabled: true +# +# 3) Non-root, but YOUR platform pins a specific UID (e.g. 10001): +# +# securityContext: +# enabled: true +# podSecurityContext: +# runAsUser: 10001 +# runAsGroup: 10001 +# fsGroup: 10001 +# containerSecurityContext: +# runAsUser: 10001 +# +# 4) OpenShift, or any platform that ASSIGNS the UID for you. +# Set the three id fields to `null`. A null in your values file removes the +# key entirely during Helm's value merge, so the rendered securityContext +# keeps runAsNonRoot / seccompProfile / dropped capabilities but names no +# UID -- which is exactly what OpenShift's `restricted-v2` SCC requires: +# +# securityContext: +# enabled: true +# podSecurityContext: +# runAsUser: null +# runAsGroup: null +# fsGroup: null +# containerSecurityContext: +# runAsUser: null +# services: +# email_service: +# runAsUser: null # this service pins its own; see below +# +# Do NOT instead leave the 1000s in place: `restricted-v2` validates with +# MustRunAsRange and REJECTS any pod requesting an id outside the namespace's +# assigned range, so every pod would fail admission and nothing would +# schedule. Ready-made: `-f examples/values-openshift.yaml`, which also +# selects the OpenShift ingress path and forces the bundled datastores off. +# +# Requires images that grant group 0 write access to their runtime paths. +# Older images crash under an assigned UID, e.g. nginx exits with +# `mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)`. +# +# ------------------------------------------------------------------ +# WHAT THIS DOES NOT COVER +# ------------------------------------------------------------------ # - The bundled local infrastructure (postgres, redis, rabbitmq, minio, # opensearch): third-party images with their own UID/GID requirements, # intended for local/dev use. In hardened clusters run those externally -# (managed RDS, ElastiCache, etc.) and leave local_setup off. -# - The email service: its image pins UID 100 and keeps its own securityContext. +# (managed RDS, ElastiCache, etc.) and leave local_setup off. They cannot +# run under an assigned UID at all. +# - The email service's UID only. Its image's app-user is uid 100 and owns +# /opt/email, so it keeps its own `services.email_service.runAsUser`; it +# still takes runAsNonRoot / capabilities / seccomp from the block below. # -# Mirrors the kustomize `nonroot-security-context` component. Override runAsUser/ -# runAsGroup/fsGroup below if your environment pins a specific UID (e.g. 10001). +# Mirrors the kustomize `nonroot-security-context` component (and, for recipe 4, +# `openshift-security-context`). securityContext: enabled: false # Rendered at spec.template.spec.securityContext (pod level). @@ -177,6 +259,12 @@ services: opensearch: local_setup: false + # The OpenSearch image needs uid/gid 1000 to own its data dir. Set both to + # null where the platform assigns them — but the image does not run under an + # arbitrary UID either, so on OpenShift use an external OpenSearch instead + # and leave local_setup off. + runAsUser: 1000 + fsGroup: 1000 image: opensearchproject/opensearch:3.3.2 servicePort: 9200 volumeSize: 5Gi @@ -438,6 +526,10 @@ services: email_service: enabled: false + # The image's app-user is uid 100 and owns /opt/email, so this service pins + # its own UID rather than following the shared securityContext block above. + # Set to null where the platform assigns the UID (OpenShift restricted-v2). + runAsUser: 100 replicas: 1 memoryLimit: 1000Mi cpuLimit: 500m