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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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