From 2321bbb5fe4736b27b7ff63a8e3d520292fbad4c Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 17 Aug 2026 17:35:26 +0530 Subject: [PATCH 1/5] feat(plane-enterprise): support OpenShift's restricted-v2 SCC and Route ingress OpenShift ignores the image's USER, assigns an arbitrary UID from the namespace's range, and validates the pod's own request with MustRunAsRange -- so a manifest asking for a specific runAsUser or fsGroup outside that range is rejected at admission. Today `securityContext.enabled=true` renders runAsUser/runAsGroup/ fsGroup 1000 on every workload, so nothing schedules; and neither ingress branch produces anything OpenShift's router can serve. securityContext needs no template change: setting a key to null in a values file removes it during Helm's coalescing, so the rendered context keeps runAsNonRoot, seccompProfile and the dropped capabilities while carrying no UID. That is now shipped as examples/values-openshift.yaml, which also un-pins the email service's uid 100, selects the OpenShift ingress path and forces the bundled datastores off. Also in this change: - templates/ingress-openshift.yaml: one route.openshift.io/v1 Route per path when ingressClass is "openshift", carrying haproxy.router.openshift.io/timeout (the router's 30s default severs /live/ WebSockets and /pi/ streaming). Declaring the Routes directly avoids depending on whether annotations survive the ingress-to-route conversion, which varies by OCP version. - ingress.yaml: fix `len of nil pointer` on ingress_annotations, which made ingressClass=nginx fail to render at all unless the key was set explicitly; and widen the gate from `eq "nginx"` to any non-traefik, non-openshift class, which is what the README already claimed and what "openshift-default" needs. - email and opensearch: their hardcoded UIDs are now nullable values, defaulting to the current literals. Every existing configuration renders byte-identically to master (default, traefik, securityContext.enabled, local datastores, air-gapped) -- verified with only the render timestamp and the chart-version label normalized. Not covered here: SCC admission and Route behaviour cannot be exercised on EKS and still need a CRC / OpenShift Local run. Requires the images from plane-ee #9018, which grant group 0 write access to their runtime paths. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/README.md | 62 +++++++++++- .../examples/values-openshift.yaml | 98 +++++++++++++++++++ .../templates/ingress-openshift.yaml | 95 ++++++++++++++++++ .../plane-enterprise/templates/ingress.yaml | 13 ++- .../templates/workloads/email.deployment.yaml | 9 +- .../workloads/opensearch.stateful.yaml | 13 ++- charts/plane-enterprise/values.yaml | 31 ++++++ 8 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 charts/plane-enterprise/examples/values-openshift.yaml create mode 100644 charts/plane-enterprise/templates/ingress-openshift.yaml 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..d51d2e23 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) | +| Any other value (e.g. `nginx`, `openshift-default`) | `templates/ingress.yaml` | `networking.k8s.io/v1 Ingress` | + +On OpenShift you have both options: `openshift` declares the Routes directly (so the +per-route HAProxy annotations Plane needs are guaranteed to land), while +`openshift-default` emits a plain `Ingress` and lets OpenShift's ingress-to-route +controller convert it. Note that the controller only converts an `Ingress` whose class +maps to `openshift.io/ingress-to-route` — `nginx` will not be picked up. + +> **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. @@ -91,6 +102,10 @@ The default value is `"traefik"`. If you are switching to a standard ingress con | `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.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,43 @@ 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 with `MustRunAsRange` — so a manifest asking for a *specific* +`runAsUser` or `fsGroup` outside that range is **rejected at admission**. 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 is safe.** Moving a running install from the + pinned-uid-1000 posture to this one does not require a data migration: kubelet + re-applies `fsGroup` to PVC contents on mount, so data written by the old + deployment stays readable and writable by the new UID. + ### 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..af1c2b81 --- /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. The alternative is 'openshift-default', which emits a +# plain Ingress for OpenShift's ingress-to-route controller to convert — simpler, +# but whether the per-route annotations survive the conversion varies by version. +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..1016ea8c --- /dev/null +++ b/charts/plane-enterprise/templates/ingress-openshift.yaml @@ -0,0 +1,95 @@ +{{/* +================================================================================ +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, and that path still works — set ingressClass to +"openshift-default" instead and templates/ingress.yaml renders it. But 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 the conversion varies by OCP version. Plane needs two of +them (see `timeout` below), so declaring the Routes directly removes the +guesswork. + +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 }} + +{{/* 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) }} + +{{- 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: {{ $host }} + path: {{ $r.path }} + to: + kind: Service + name: {{ $r.svc }} + weight: 100 + port: + targetPort: {{ $r.port }} + tls: + termination: {{ $oc.termination | default "edge" }} + 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..97cf4513 100644 --- a/charts/plane-enterprise/templates/ingress.yaml +++ b/charts/plane-enterprise/templates/ingress.yaml @@ -1,4 +1,11 @@ -{{- if and .Values.ingress.enabled (eq .Values.ingress.ingressClass "nginx") .Values.license.licenseDomain }} +{{/* +Standard networking.k8s.io/v1 Ingress. Rendered for every ingressClass EXCEPT: + - "traefik*" -> templates/ingress-traefik.yaml (Traefik IngressRoute CRD) + - "openshift" -> templates/ingress-openshift.yaml (OpenShift Route) +Previously this was gated on `eq ... "nginx"`, which silently rendered nothing for +any other controller even though the README documented otherwise. +*/}} +{{- if and .Values.ingress.enabled (not (hasPrefix "traefik" .Values.ingress.ingressClass)) (ne .Values.ingress.ingressClass "openshift") .Values.license.licenseDomain }} apiVersion: networking.k8s.io/v1 kind: Ingress @@ -7,9 +14,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..261cf3d0 100644 --- a/charts/plane-enterprise/templates/workloads/email.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/email.deployment.yaml @@ -54,8 +54,15 @@ 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 }} + {{- /* The email image's app-user really is uid 100 and owns /opt/email, so + this stays pinned rather than following the shared securityContext + block. 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). */}} + {{- with .Values.services.email_service.runAsUser }} securityContext: - runAsUser: 100 + runAsUser: {{ . }} + {{- 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..6a2c4e6c 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -38,6 +38,12 @@ 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) + # anything else -> networking.k8s.io/v1 Ingress (templates/ingress.yaml), + # e.g. 'nginx', or 'openshift-default' to let OpenShift's + # ingress-to-route controller do the conversion instead. 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 @@ -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 @@ -177,6 +198,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 +465,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 From 743a25b12f6a7b5a5f9e3e2ec32d813a2bfb4d80 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 17 Aug 2026 17:59:56 +0530 Subject: [PATCH 2/5] fix(plane-enterprise): address CodeRabbit review on the OpenShift support PR Three findings, all valid: - email.deployment.yaml: with services.email_service.runAsUser set to null (the OpenShift path), the `with` block emitted no securityContext at all, so the email container silently lost runAsNonRoot, dropped capabilities and seccompProfile -- the one unhardened workload, and a PSA "restricted" reject on a non-OpenShift cluster. It now takes everything except runAsUser from the shared containerSecurityContext when securityContext.enabled is set, and overrides runAsUser only when a value is given. Those controls are not UID-specific, so there was never a reason to exclude them. - ingress-openshift.yaml: added the minioHost and rabbitmqHost Routes that templates/ingress.yaml renders for the bundled MinIO console (9090) and RabbitMQ management UI (15672), via an optional per-route `host`. Both stay gated on the corresponding local_setup, so neither renders in the recommended OpenShift configuration where the bundled datastores are off. (The Traefik template omits these too -- that pre-existing gap is left alone here.) - values.yaml / README.md: the ingress_annotations comment still claimed they are "ONLY rendered when ingressClass is 'nginx'", which this PR's widened gate made wrong. Restated as "every class except traefik* and openshift", and noted that Routes take ingress.openshift.route_annotations instead. Render check re-run against master. default, traefik+silo+pi+email, local datastores (now also with minioHost/rabbitmqHost set) and air-gapped are still byte-identical. The single intentional difference is securityContext.enabled=true, where the email container now gains allowPrivilegeEscalation: false, capabilities.drop [ALL], runAsNonRoot and seccompProfile alongside its uid 100. That is strictly additive hardening and safe: the service binds 10025/10465/10587, so it needs no capability. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/README.md | 2 +- .../templates/ingress-openshift.yaml | 15 ++++++++++++- .../templates/workloads/email.deployment.yaml | 22 ++++++++++++++----- charts/plane-enterprise/values.yaml | 9 +++++--- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index d51d2e23..6a7073a0 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -101,7 +101,7 @@ 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. Ignored when `ingressClass` starts with `traefik` or is `openshift` (Routes use `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+. | diff --git a/charts/plane-enterprise/templates/ingress-openshift.yaml b/charts/plane-enterprise/templates/ingress-openshift.yaml index 1016ea8c..29190c90 100644 --- a/charts/plane-enterprise/templates/ingress-openshift.yaml +++ b/charts/plane-enterprise/templates/ingress-openshift.yaml @@ -54,6 +54,19 @@ Differences from the Traefik IngressRoute this mirrors: {{/* 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 @@ -73,7 +86,7 @@ metadata: {{- end }} {{- end }} spec: - host: {{ $host }} + host: {{ $r.host | default $host }} path: {{ $r.path }} to: kind: Service diff --git a/charts/plane-enterprise/templates/workloads/email.deployment.yaml b/charts/plane-enterprise/templates/workloads/email.deployment.yaml index 261cf3d0..4485a540 100644 --- a/charts/plane-enterprise/templates/workloads/email.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/email.deployment.yaml @@ -55,13 +55,23 @@ spec: imagePullPolicy: {{ .Values.services.email_service.pullPolicy | default "Always" }} image: {{ .Values.services.email_service.image | default "makeplane/email-commercial" }}:{{ .Values.planeVersion }} {{- /* The email image's app-user really is uid 100 and owns /opt/email, so - this stays pinned rather than following the shared securityContext - block. 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). */}} + 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 }} - securityContext: - runAsUser: {{ . }} + {{- $emailCtx = merge (dict "runAsUser" .) $emailCtx }} + {{- end }} + {{- with $emailCtx }} + securityContext: {{- toYaml . | nindent 10 }} {{- end }} stdin: true tty: true diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 6a2c4e6c..dc897a73 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -45,9 +45,12 @@ ingress: # e.g. 'nginx', or 'openshift-default' to let OpenShift's # ingress-to-route controller do the conversion instead. 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. Rendered whenever templates/ingress.yaml is the active + # template, i.e. for every ingressClass EXCEPT a 'traefik*' one (which uses + # ingress-traefik.yaml) and 'openshift' (which uses ingress-openshift.yaml and + # 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. From 177772031c81d00ed1e719ad211447ecea6aae59 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 17 Aug 2026 18:17:49 +0530 Subject: [PATCH 3/5] revert(plane-enterprise): restore the nginx-only gate on templates/ingress.yaml Backs out only the widened ingressClass condition. Rendering the standard Ingress for every non-traefik class changes behaviour for existing installs that set a class the chart never served, so it needs its own consideration rather than riding along with the OpenShift work. Tracked separately. templates/ingress.yaml goes back to `eq .Values.ingress.ingressClass "nginx"` verbatim. The nil-pointer fix on the same file STAYS: `gt (len .Values.ingress.ingress_annotations) 0` still aborts the whole render with `len of nil pointer` whenever ingressClass is nginx and the key is left commented out in values.yaml, which is unrelated to which classes are served. The OpenShift Route path is unaffected -- templates/ingress-openshift.yaml is its own template gated on `eq ... "openshift"`, so it never depended on the widened condition. What the revert does remove is the "openshift-default" alternative (letting OpenShift's ingress-to-route controller convert a plain Ingress); that class now renders nothing again, so every doc offering it as an option is corrected: values.yaml, README (both the template-selection table and the ingress_annotations row), examples/values-openshift.yaml and the header of ingress-openshift.yaml. A note on ingress.yaml records the README-vs-code mismatch for whoever picks this up. Verified against master: openshift-default, alb, haproxy and contour all render byte-identically again, alongside the existing default / traefik / local datastores / air-gapped cases. The single intentional difference remains securityContext.enabled=true, where the email container gains the shared hardening. nginx with no annotations still renders instead of erroring, and the openshift class still emits its Routes. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/README.md | 24 +++++++++---------- .../examples/values-openshift.yaml | 6 ++--- .../templates/ingress-openshift.yaml | 10 ++++---- .../plane-enterprise/templates/ingress.yaml | 16 ++++++++----- charts/plane-enterprise/values.yaml | 17 ++++++------- 5 files changed, 37 insertions(+), 36 deletions(-) diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 6a7073a0..6b74a977 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -29,17 +29,17 @@ If you plan to use Traefik as your ingress controller, install it before deployi 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` | -| `openshift` | `templates/ingress-openshift.yaml` | `route.openshift.io/v1 Route` (one per path) | -| Any other value (e.g. `nginx`, `openshift-default`) | `templates/ingress.yaml` | `networking.k8s.io/v1 Ingress` | - -On OpenShift you have both options: `openshift` declares the Routes directly (so the -per-route HAProxy annotations Plane needs are guaranteed to land), while -`openshift-default` emits a plain `Ingress` and lets OpenShift's ingress-to-route -controller convert it. Note that the controller only converts an `Ingress` whose class -maps to `openshift.io/ingress-to-route` — `nginx` will not be picked up. +| `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 @@ -101,7 +101,7 @@ 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` or is `openshift` (Routes use `ingress.openshift.route_annotations`). | +| `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+. | diff --git a/charts/plane-enterprise/examples/values-openshift.yaml b/charts/plane-enterprise/examples/values-openshift.yaml index af1c2b81..e6870edf 100644 --- a/charts/plane-enterprise/examples/values-openshift.yaml +++ b/charts/plane-enterprise/examples/values-openshift.yaml @@ -69,9 +69,9 @@ services: # Ingress # ----------------------------------------------------------------------------- # 'openshift' renders one route.openshift.io/v1 Route per path, with the HAProxy -# timeout set explicitly. The alternative is 'openshift-default', which emits a -# plain Ingress for OpenShift's ingress-to-route controller to convert — simpler, -# but whether the per-route annotations survive the conversion varies by version. +# 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' diff --git a/charts/plane-enterprise/templates/ingress-openshift.yaml b/charts/plane-enterprise/templates/ingress-openshift.yaml index 29190c90..b70c1219 100644 --- a/charts/plane-enterprise/templates/ingress-openshift.yaml +++ b/charts/plane-enterprise/templates/ingress-openshift.yaml @@ -7,13 +7,13 @@ 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, and that path still works — set ingressClass to -"openshift-default" instead and templates/ingress.yaml renders it. But the +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 the conversion varies by OCP version. Plane needs two of -them (see `timeout` below), so declaring the Routes directly removes the -guesswork. +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: diff --git a/charts/plane-enterprise/templates/ingress.yaml b/charts/plane-enterprise/templates/ingress.yaml index 97cf4513..e439bf6c 100644 --- a/charts/plane-enterprise/templates/ingress.yaml +++ b/charts/plane-enterprise/templates/ingress.yaml @@ -1,11 +1,15 @@ {{/* -Standard networking.k8s.io/v1 Ingress. Rendered for every ingressClass EXCEPT: - - "traefik*" -> templates/ingress-traefik.yaml (Traefik IngressRoute CRD) - - "openshift" -> templates/ingress-openshift.yaml (OpenShift Route) -Previously this was gated on `eq ... "nginx"`, which silently rendered nothing for -any other controller even though the README documented otherwise. +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 (not (hasPrefix "traefik" .Values.ingress.ingressClass)) (ne .Values.ingress.ingressClass "openshift") .Values.license.licenseDomain }} +{{- if and .Values.ingress.enabled (eq .Values.ingress.ingressClass "nginx") .Values.license.licenseDomain }} apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index dc897a73..5664445e 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -39,18 +39,15 @@ ingress: 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) - # anything else -> networking.k8s.io/v1 Ingress (templates/ingress.yaml), - # e.g. 'nginx', or 'openshift-default' to let OpenShift's - # ingress-to-route controller do the conversion instead. + # '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' # Annotations for the standard Ingress — e.g. to set the proxy body size limit on - # the nginx controller. Rendered whenever templates/ingress.yaml is the active - # template, i.e. for every ingressClass EXCEPT a 'traefik*' one (which uses - # ingress-traefik.yaml) and 'openshift' (which uses ingress-openshift.yaml and - # takes its annotations from ingress.openshift.route_annotations instead). - # Example: + # 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. From 3e5f702dd84a6ea05d14b1a148933a7ced646c41 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Mon, 17 Aug 2026 21:38:10 +0530 Subject: [PATCH 4/5] docs(plane-enterprise): make securityContext self-documenting in values.yaml The securityContext block told you what it does but not how to configure it for your platform, so the OpenShift case in particular was only discoverable by reading the README -- and the mechanism there (setting the id fields to `null`) is not something anyone guesses. values.yaml now carries four copy-pasteable recipes at the point of use: 1. off (the default; entrypoints drop privileges themselves) 2. non-root uid 1000 -- just `enabled: true` 3. a platform-pinned uid, e.g. 10001 4. OpenShift / any platform that assigns the uid -- set runAsUser, runAsGroup and fsGroup to null Recipe 4 explains WHY null works (a null in a user values file removes the key during Helm's merge, so the rendered context keeps runAsNonRoot, seccompProfile and dropped capabilities but names no uid) and, more usefully, why the obvious alternative fails: restricted-v2 validates with MustRunAsRange and rejects any pod requesting an id outside the namespace's range, so leaving the 1000s in place means nothing schedules at all. It also states the image requirement, since older images crash under an assigned uid. Also corrects the exclusions list: the email service no longer opts out of the whole block, only of its uid -- it takes runAsNonRoot/capabilities/seccomp from the shared settings as of the CodeRabbit fix earlier in this branch. Comments only. All four recipes verified by rendering, and the default output is still byte-identical to master. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/values.yaml | 71 +++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 5664445e..f8175eef 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -119,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). From cb4c72afd0dd4a869f40570a01b9c51732172c88 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 20 Aug 2026 15:27:58 +0530 Subject: [PATCH 5/5] fix(plane-enterprise): address the remaining CodeRabbit review on OpenShift support Four findings were still open after the earlier review pass; three others (minio/rabbitmq Routes, the email securityContext, the ingress_annotations scope comment) were verified as already fixed and left alone. templates/ingress-openshift.yaml: reject a termination other than edge or reencrypt. Every Route the template emits carries a spec.path, and OpenShift rejects a Route combining a path with passthrough -- the router cannot inspect the URL of a connection it never decrypts. The constraint was documented in a header comment but not enforced, so `termination: passthrough` rendered happily and failed at admission. A `fail` turns that into a render error naming the bad value. Also catches typos, which previously reached the cluster verbatim. README: three corrections. - The SCC section claimed restricted-v2 validates both runAsUser and fsGroup with MustRunAsRange. Only runAsUser uses that; fsGroup uses MustRunAs against the supplemental-groups annotation, falling back to the UID range. Split into one bullet each. - "Upgrading an existing deployment is safe" was an unconditional claim resting on kubelet relabelling PVC contents via fsGroup. That is driver-dependent: the default ReadWriteOnceWithFSType policy skips RWX volumes entirely, `None` disables it, and a driver advertising VOLUME_MOUNT_GROUP overrides both policies. A PVC left owned by uid/gid 1000 is unwritable by the SCC-assigned identity, so the old wording could cost someone their data access on NFS/EFS. Now states the three driver cases, gives the csidriver query to check, asks for a rehearsal against a snapshot or clone, and gives the chown fallback. - The ingress-migration snippet said `# or whichever class your controller exposes`, but only nginx, traefik* and openshift render anything -- anything else installs cleanly with no ingress at all. Replaced with the supported set. Verified: passthrough and an invalid value both fail the render with the value named; edge, reencrypt and unset render 10 Routes; the guard does not fire for the traefik or nginx classes. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/README.md | 38 +++++++++++++++---- .../templates/ingress-openshift.yaml | 11 +++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/charts/plane-enterprise/README.md b/charts/plane-enterprise/README.md index 6b74a977..8ee2abc9 100644 --- a/charts/plane-enterprise/README.md +++ b/charts/plane-enterprise/README.md @@ -55,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`**: @@ -456,9 +456,17 @@ securityContext: 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 with `MustRunAsRange` — so a manifest asking for a *specific* -`runAsUser` or `fsGroup` outside that range is **rejected at admission**. Enabling -the block above with its defaults means nothing schedules. +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`, @@ -483,10 +491,24 @@ the bundled datastores off. Three things to know before you use it: 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 is safe.** Moving a running install from the - pinned-uid-1000 posture to this one does not require a data migration: kubelet - re-applies `fsGroup` to PVC contents on mount, so data written by the old - deployment stays readable and writable by the new UID. +- **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 diff --git a/charts/plane-enterprise/templates/ingress-openshift.yaml b/charts/plane-enterprise/templates/ingress-openshift.yaml index b70c1219..8304905d 100644 --- a/charts/plane-enterprise/templates/ingress-openshift.yaml +++ b/charts/plane-enterprise/templates/ingress-openshift.yaml @@ -31,6 +31,15 @@ Differences from the Traefik IngressRoute this mirrors: {{- $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 @@ -95,7 +104,7 @@ spec: port: targetPort: {{ $r.port }} tls: - termination: {{ $oc.termination | default "edge" }} + termination: {{ $termination }} insecureEdgeTerminationPolicy: {{ $oc.insecureEdgeTerminationPolicy | default "Redirect" }} {{- if $oc.externalCertificate }} {{/* Serve a cert from a namespace Secret instead of the router's wildcard.