Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion charts/plane-enterprise/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
88 changes: 81 additions & 7 deletions charts/plane-enterprise/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

> **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.

Expand All @@ -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`**:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <driver> -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 |
Expand Down
98 changes: 98 additions & 0 deletions charts/plane-enterprise/examples/values-openshift.yaml
Original file line number Diff line number Diff line change
@@ -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 <serviceaccount>`), 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.
117 changes: 117 additions & 0 deletions charts/plane-enterprise/templates/ingress-openshift.yaml
Original file line number Diff line number Diff line change
@@ -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 `<release>-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 }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{{/* 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) }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{{/* 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 }}
15 changes: 13 additions & 2 deletions charts/plane-enterprise/templates/ingress.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }}
Expand Down
Loading