From 7ad565b6898ca7c122ae21f35c3dfd77836086bf Mon Sep 17 00:00:00 2001 From: Ali Yousefian Date: Mon, 17 Aug 2026 20:40:48 +0000 Subject: [PATCH 1/4] feat(deploy): add a Helm chart for the Kubernetes deployment Upstream ships compose, swarm and AIO deployments plus a link to makeplane's chart on Artifact Hub, but nothing that fits how this fork is actually run: a shared RDS instance instead of a bundled Postgres, S3 with IRSA instead of MinIO, and ingress through a Cloudflare Tunnel instead of a load balancer. The chart deploys all six images this repository publishes to GHCR -- proxy, web, space, admin, live and backend (as api, worker, beat and a migration job) -- plus the Valkey and RabbitMQ they depend on. Because the images sit side by side under one namespace, image.repository is treated as a prefix that each component completes with its own suffix; a single repository/tag pair then configures all six, which is what lets a generic GitOps Application template drive a multi-image chart. Caddy keeps the path split from apps/proxy/Caddyfile.ce but drops the MinIO route and listens on 8080, so it needs no NET_BIND_SERVICE capability. The migration job runs as a pre-install/pre-upgrade hook, which Argo CD maps onto PreSync, so the schema is current before any new pod starts. Co-Authored-By: Claude Opus 5 --- deployments/helm/plane/Chart.yaml | 10 + deployments/helm/plane/README.md | 147 ++++++++ deployments/helm/plane/templates/_helpers.tpl | 103 ++++++ deployments/helm/plane/templates/admin.yaml | 71 ++++ deployments/helm/plane/templates/api.yaml | 91 +++++ deployments/helm/plane/templates/beat.yaml | 59 ++++ .../helm/plane/templates/configmap-proxy.yaml | 49 +++ .../helm/plane/templates/configmap.yaml | 79 +++++ deployments/helm/plane/templates/live.yaml | 90 +++++ .../helm/plane/templates/migrator.yaml | 49 +++ deployments/helm/plane/templates/proxy.yaml | 83 +++++ .../helm/plane/templates/rabbitmq.yaml | 121 +++++++ deployments/helm/plane/templates/redis.yaml | 111 ++++++ deployments/helm/plane/templates/secret.yaml | 23 ++ .../helm/plane/templates/serviceaccount.yaml | 12 + deployments/helm/plane/templates/space.yaml | 81 +++++ deployments/helm/plane/templates/web.yaml | 72 ++++ deployments/helm/plane/templates/worker.yaml | 58 ++++ deployments/helm/plane/values.yaml | 328 ++++++++++++++++++ 19 files changed, 1637 insertions(+) create mode 100644 deployments/helm/plane/Chart.yaml create mode 100644 deployments/helm/plane/README.md create mode 100644 deployments/helm/plane/templates/_helpers.tpl create mode 100644 deployments/helm/plane/templates/admin.yaml create mode 100644 deployments/helm/plane/templates/api.yaml create mode 100644 deployments/helm/plane/templates/beat.yaml create mode 100644 deployments/helm/plane/templates/configmap-proxy.yaml create mode 100644 deployments/helm/plane/templates/configmap.yaml create mode 100644 deployments/helm/plane/templates/live.yaml create mode 100644 deployments/helm/plane/templates/migrator.yaml create mode 100644 deployments/helm/plane/templates/proxy.yaml create mode 100644 deployments/helm/plane/templates/rabbitmq.yaml create mode 100644 deployments/helm/plane/templates/redis.yaml create mode 100644 deployments/helm/plane/templates/secret.yaml create mode 100644 deployments/helm/plane/templates/serviceaccount.yaml create mode 100644 deployments/helm/plane/templates/space.yaml create mode 100644 deployments/helm/plane/templates/web.yaml create mode 100644 deployments/helm/plane/templates/worker.yaml create mode 100644 deployments/helm/plane/values.yaml diff --git a/deployments/helm/plane/Chart.yaml b/deployments/helm/plane/Chart.yaml new file mode 100644 index 00000000000..36fe228975a --- /dev/null +++ b/deployments/helm/plane/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: plane +description: >- + Plane (community edition) for Kubernetes: the web, space, admin, live, api, + worker and beat components behind the Caddy proxy, plus the in-cluster Valkey + and RabbitMQ they depend on. +type: application +version: 0.1.0 +# Overridden by image.tag; the fork publishes a rolling `preview` tag. +appVersion: "preview" diff --git a/deployments/helm/plane/README.md b/deployments/helm/plane/README.md new file mode 100644 index 00000000000..082730a57b9 --- /dev/null +++ b/deployments/helm/plane/README.md @@ -0,0 +1,147 @@ +# Plane Helm chart + +Deploys Plane (community edition) onto Kubernetes from the images this fork +publishes to GHCR. It installs standalone with `helm install`, and is shaped so +a GitOps controller can drive it from a handful of injected values. + +Upstream's own chart lives at +[artifacthub.io/packages/helm/makeplane/plane-ce](https://artifacthub.io/packages/helm/makeplane/plane-ce); +this one exists because it models a deployment that chart does not: an external +managed Postgres rather than a bundled one, S3 reached through a workload +identity role rather than MinIO, and TLS terminated ahead of the release rather +than a bundled load balancer. + +## What it deploys + +| Component | Kind | Image suffix | Notes | +| --- | --- | --- | --- | +| `proxy` | Deployment | `-proxy` | Caddy. The single entry point; everything else stays inside the namespace. | +| `web` | Deployment | `-frontend` | Main app, static files on nginx. | +| `space` | Deployment | `-space` | Public "spaces" app, server-rendered, under `/spaces`. | +| `admin` | Deployment | `-admin` | God-mode admin, static files on nginx, under `/god-mode`. | +| `live` | Deployment | `-live` | Collaborative editing (Hocuspocus/WebSocket), under `/live`. | +| `api` | Deployment | `-backend` | Django ASGI app: `/api`, `/auth`, `/static`. | +| `worker` | Deployment | `-backend` | Celery worker. | +| `beat` | Deployment | `-backend` | Celery beat. Single replica by design. | +| `migrator` | Job | `-backend` | `manage.py migrate`, as a pre-install/pre-upgrade hook. | +| `redis` | StatefulSet | — | Valkey: Django's cache and the live server's presence store. | +| `rabbitmq` | StatefulSet | — | Celery's broker. | + +Postgres is **not** part of the chart — point `DATABASE_URL` at an existing +instance. + +### Images + +The six Plane images are published side by side under one namespace, so +`image.repository` is a **prefix** and each component appends its own suffix: +`ghcr.io/crewlet/plane` + `-backend` + `:` + `image.tag`. One +`image.repository`/`image.tag` pair therefore configures all six, which is what +lets a generic GitOps Application template -- one that knows only how to inject +a single image reference -- drive a multi-image chart. Set `.image` +to a full reference to pin one component elsewhere. + +### Request routing + +Caddy owns the path split, mirroring `apps/proxy/Caddyfile.ce`: + +``` +/spaces/* -> space /api/*, /auth/*, /static/* -> api +/god-mode/* -> admin /* -> web +/live/* -> live /_healthz -> Caddy itself +``` + +TLS is expected to terminate ahead of the proxy, so Caddy serves plain HTTP on +port 8080 (above 1024, so it needs no `NET_BIND_SERVICE` capability) and +requests no certificates. Django decides a request is secure from +`X-Forwarded-Proto`, and Caddy only forwards that header from a peer listed in +`proxy.trustedProxies` — if the ingress hop is not trusted, every request looks +like plain HTTP and CSRF checks start failing. + +## Configuration + +Non-secret settings live under `config` and are rendered into a ConfigMap that +the api, worker, beat, migrator and live components consume. Anything the chart +does not model goes in `config.extraEnv`. + +Secret material — `SECRET_KEY`, `DATABASE_URL`, `LIVE_SERVER_SECRET_KEY`, +`RABBITMQ_PASSWORD` — comes from one secret, either rendered by the chart +(`secrets.create: true`) or managed elsewhere (`secrets.create: false` plus an +`extraEnvFrom` entry). `extraEnvFrom` is layered after the ConfigMap, so it +wins on any key both define. + +See [`values.yaml`](values.yaml) for the full set; every key is commented. + +### Object storage + +Uploads go to S3 through presigned URLs the API hands to the browser, so the +bucket needs CORS rules that allow the public origin. Leave +`config.storage.accessKeyId`/`secretAccessKey` empty on EKS: the env vars are +then omitted entirely and boto3 falls back to the service account's +web-identity credentials (IRSA), with the role ARN on +`serviceAccount.annotations`. + +One consequence worth knowing: a presigned URL signed with temporary +credentials dies when that session does. Keep +`config.storage.signedUrlExpiration` well under the IAM role's session +duration, or links will expire earlier than the value suggests. + +### Security contexts + +The Plane images run as root and write inside their working directory +(collectstatic output, rotating logs, Caddy's data dir), so `runAsNonRoot` and +`readOnlyRootFilesystem` are not set — dropping capabilities and privilege +escalation is what they support without patching. + +Four components shed root themselves and get a context of their own: `web` and +`admin` (nginx hands its workers to the `nginx` user) keep `SETUID`/`SETGID`, +and `redis`/`rabbitmq` (entrypoints chown the data dir and `gosu` into the +service user) additionally keep `CHOWN`, `DAC_OVERRIDE` and `FOWNER`. Dropping +`ALL` on those four stops them booting. + +### Migrations + +`migrator` is a Helm `pre-install,pre-upgrade` hook, which Argo CD maps onto its +own PreSync phase — the schema is always current before a new api, worker or +beat pod starts. The job is kept after success (deleted only when the next one +is created) so its logs stay available. + +## Standalone install + +```bash +helm install plane deployments/helm/plane \ + --namespace plane --create-namespace \ + --set image.tag=preview \ + --set config.webUrl=https://plane.example.com \ + --set config.corsAllowedOrigins=https://plane.example.com \ + --set config.storage.bucket=my-plane-uploads \ + --set config.storage.region=us-east-2 \ + --set secrets.create=true \ + --set secrets.secretKey="$(openssl rand -hex 32)" \ + --set secrets.liveServerSecretKey="$(openssl rand -hex 32)" \ + --set secrets.rabbitmqPassword="$(openssl rand -hex 16)" \ + --set secrets.databaseUrl='postgres://user:pass@host:5432/plane?sslmode=require' +``` + +Then send traffic to the `plane-proxy` Service on port 80. + +The chart creates no Ingress. Point whatever terminates traffic — an Ingress, a +`LoadBalancer` Service, an outbound tunnel — at that Service, and make sure it +forwards `X-Forwarded-Proto`. Pod scheduling constraints (`nodeSelector`, +`affinity`, `tolerations`) are not modelled either. + +## GitOps install + +Under a GitOps controller the chart is usually driven entirely by injected +values, so nothing environment-specific and no secret material lands in git: + +| Value | What the controller supplies | +| --- | --- | +| `image.repository`, `image.tag` | registry namespace, and the tag to roll | +| `imagePullSecrets` | pull secret for the registry, in the release namespace | +| `serviceAccount.annotations` | workload identity role for the uploads bucket | +| `secrets.create: false` + `extraEnvFrom` | a secret synced from an external secret store | +| `config.webUrl`, `config.corsAllowedOrigins` | the environment's public hostname | +| `config.storage.bucket`, `config.storage.region` | the uploads bucket | + +Because `image.repository` is a prefix, that first row is a single injected +reference no matter how many component images the release actually pulls. diff --git a/deployments/helm/plane/templates/_helpers.tpl b/deployments/helm/plane/templates/_helpers.tpl new file mode 100644 index 00000000000..6c4dae9d147 --- /dev/null +++ b/deployments/helm/plane/templates/_helpers.tpl @@ -0,0 +1,103 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "plane.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified app name. +*/}} +{{- define "plane.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Name of one component's resources, e.g. plane-api. +Usage: {{ include "plane.componentName" (dict "root" $ "component" "api") }} +*/}} +{{- define "plane.componentName" -}} +{{- printf "%s-%s" (include "plane.fullname" .root) .component | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "plane.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{ include "plane.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. Shared by every component; each workload adds its own +app.kubernetes.io/component on top so the selectors stay disjoint. +*/}} +{{- define "plane.selectorLabels" -}} +app.kubernetes.io/name: {{ include "plane.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Service account name. +*/}} +{{- define "plane.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "plane.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Name of the secret holding SECRET_KEY, LIVE_SERVER_SECRET_KEY, DATABASE_URL and +RABBITMQ_PASSWORD -- either the one this chart renders or the external one. +*/}} +{{- define "plane.secretName" -}} +{{- if .Values.secrets.create }} +{{- printf "%s-secret" (include "plane.fullname" .) }} +{{- else }} +{{- required "secrets.create is false, so secrets.existingSecret must name the secret to consume" .Values.secrets.existingSecret }} +{{- end }} +{{- end }} + +{{/* +Image reference for one component. image.repository is a prefix; the component's +own suffix completes it. +Usage: {{ include "plane.image" (dict "root" $ "suffix" "backend" "override" .Values.api.image) }} +*/}} +{{- define "plane.image" -}} +{{- if .override }} +{{- .override }} +{{- else }} +{{- $tag := .root.Values.image.tag | default .root.Chart.AppVersion }} +{{- printf "%s-%s:%s" .root.Values.image.repository .suffix $tag }} +{{- end }} +{{- end }} + +{{/* +Environment sources for the components that run Plane application code. The +shared ConfigMap comes first so anything supplied through extraEnvFrom (the +externally managed secret) overrides it. +*/}} +{{- define "plane.envFrom" -}} +- configMapRef: + name: {{ include "plane.fullname" . }}-config +{{- if .Values.secrets.create }} +- secretRef: + name: {{ include "plane.secretName" . }} +{{- end }} +{{- with .Values.extraEnvFrom }} +{{ toYaml . }} +{{- end }} +{{- end }} diff --git a/deployments/helm/plane/templates/admin.yaml b/deployments/helm/plane/templates/admin.yaml new file mode 100644 index 00000000000..51dff71af25 --- /dev/null +++ b/deployments/helm/plane/templates/admin.yaml @@ -0,0 +1,71 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "admin") -}} +{{- /* + God-mode admin: a static bundle served by nginx from /god-mode. Like the web + app it is built with relative base URLs and needs no runtime configuration. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: admin +spec: + replicas: {{ .Values.admin.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: admin + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: admin + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: admin + image: {{ include "plane.image" (dict "root" $ "suffix" "admin" "override" .Values.admin.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.admin.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.admin.port }} + protocol: TCP + readinessProbe: + httpGet: + path: /god-mode/ + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /god-mode/ + port: http + periodSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.admin.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: admin +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.admin.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: admin diff --git a/deployments/helm/plane/templates/api.yaml b/deployments/helm/plane/templates/api.yaml new file mode 100644 index 00000000000..268533372f8 --- /dev/null +++ b/deployments/helm/plane/templates/api.yaml @@ -0,0 +1,91 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "api") -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: api + spec: + serviceAccountName: {{ include "plane.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: api + image: {{ include "plane.image" (dict "root" $ "suffix" "backend" "override" .Values.api.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["./bin/docker-entrypoint-api.sh"] + ports: + - name: http + containerPort: {{ .Values.api.port }} + protocol: TCP + envFrom: + {{- include "plane.envFrom" . | nindent 12 }} + env: + - name: PORT + value: {{ .Values.api.port | quote }} + {{- /* + The entrypoint registers the instance, seeds its configuration, + checks the bucket and runs collectstatic before gunicorn binds, and + it blocks on pending migrations. The startup probe carries that + whole stretch (up to five minutes) so the liveness probe only ever + sees a serving process. + */}} + startupProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + failureThreshold: 30 + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: / + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.api.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: api diff --git a/deployments/helm/plane/templates/beat.yaml b/deployments/helm/plane/templates/beat.yaml new file mode 100644 index 00000000000..8e8043f2d33 --- /dev/null +++ b/deployments/helm/plane/templates/beat.yaml @@ -0,0 +1,59 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "beat") -}} +{{- /* + Celery beat. Exactly one replica by design -- a second scheduler would fire + every periodic task twice -- so the old pod is torn down before the new one + starts rather than overlapping. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: beat +spec: + replicas: {{ .Values.beat.replicaCount }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: beat + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: beat + spec: + serviceAccountName: {{ include "plane.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: beat + image: {{ include "plane.image" (dict "root" $ "suffix" "backend" "override" .Values.beat.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["./bin/docker-entrypoint-beat.sh"] + envFrom: + {{- include "plane.envFrom" . | nindent 12 }} + {{- /* + beat answers no ping of its own (it only publishes), so liveness is + just "the scheduler process is still there". It exits on a broker + failure, which the restart policy then handles. + */}} + livenessProbe: + exec: + command: ["bash", "-c", "pgrep -f 'celery.*beat' >/dev/null"] + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + resources: + {{- toYaml .Values.beat.resources | nindent 12 }} diff --git a/deployments/helm/plane/templates/configmap-proxy.yaml b/deployments/helm/plane/templates/configmap-proxy.yaml new file mode 100644 index 00000000000..56014942e14 --- /dev/null +++ b/deployments/helm/plane/templates/configmap-proxy.yaml @@ -0,0 +1,49 @@ +{{- $fullName := include "plane.fullname" . -}} +{{- /* + Kubernetes flavour of apps/proxy/Caddyfile.ce. Two things differ from the + compose one: upstreams are Services rather than compose service names, and + there is no MinIO route -- uploads go straight to S3 through presigned URLs. + TLS terminates at the ingress hop, so Caddy serves plain HTTP and requests no + certificates. +*/ -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $fullName }}-proxy + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: proxy +data: + Caddyfile: | + { + admin off + auto_https off + servers { + max_header_size 25MB + client_ip_headers X-Forwarded-For X-Real-IP + trusted_proxies static {{ .Values.proxy.trustedProxies }} + } + } + + :{{ .Values.proxy.containerPort }} { + request_body { + max_size {{ .Values.config.fileSizeLimit }} + } + + # Caddy's own liveness/readiness, answered without touching a backend. + respond /_healthz 200 + + redir /spaces /spaces/ permanent + reverse_proxy /spaces/* {{ $fullName }}-space:{{ .Values.space.port }} + + redir /god-mode /god-mode/ permanent + reverse_proxy /god-mode/* {{ $fullName }}-admin:{{ .Values.admin.port }} + + reverse_proxy {{ .Values.live.basePath | trimSuffix "/" }}/* {{ $fullName }}-live:{{ .Values.live.port }} + + reverse_proxy /api/* {{ $fullName }}-api:{{ .Values.api.port }} + reverse_proxy /auth/* {{ $fullName }}-api:{{ .Values.api.port }} + reverse_proxy /static/* {{ $fullName }}-api:{{ .Values.api.port }} + + reverse_proxy /* {{ $fullName }}-web:{{ .Values.web.port }} + } diff --git a/deployments/helm/plane/templates/configmap.yaml b/deployments/helm/plane/templates/configmap.yaml new file mode 100644 index 00000000000..34bc3e601cf --- /dev/null +++ b/deployments/helm/plane/templates/configmap.yaml @@ -0,0 +1,79 @@ +{{- $fullName := include "plane.fullname" . -}} +{{- $storage := .Values.config.storage -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $fullName }}-config + labels: + {{- include "plane.labels" . | nindent 4 }} +data: + DEBUG: {{ .Values.config.debug | quote }} + GUNICORN_WORKERS: {{ .Values.config.gunicornWorkers | quote }} + + # Every component is served from one origin through the proxy, so the app, + # admin, space and live base URLs are all the public URL; only the paths + # differ, and they match the routes in the proxy's Caddyfile. + WEB_URL: {{ .Values.config.webUrl | quote }} + APP_BASE_URL: {{ .Values.config.webUrl | quote }} + APP_BASE_PATH: "/" + ADMIN_BASE_URL: {{ .Values.config.webUrl | quote }} + ADMIN_BASE_PATH: "/god-mode/" + SPACE_BASE_URL: {{ .Values.config.webUrl | quote }} + SPACE_BASE_PATH: "/spaces/" + LIVE_BASE_URL: {{ .Values.config.webUrl | quote }} + LIVE_BASE_PATH: {{ printf "%s/" (.Values.live.basePath | trimSuffix "/") | quote }} + + CORS_ALLOWED_ORIGINS: {{ .Values.config.corsAllowedOrigins | quote }} + + # In-cluster address of the API, for the live server's server-to-server calls. + API_BASE_URL: {{ printf "http://%s-api:%v" $fullName .Values.api.port | quote }} + + {{- if .Values.redis.enabled }} + REDIS_URL: {{ printf "redis://%s-redis:%v/" $fullName .Values.redis.port | quote }} + REDIS_HOST: {{ printf "%s-redis" $fullName | quote }} + REDIS_PORT: {{ .Values.redis.port | quote }} + {{- else }} + REDIS_URL: {{ required "redis.enabled is false, so externalRedis.url is required" .Values.externalRedis.url | quote }} + {{- end }} + + {{- /* + AMQP_URL is deliberately not set: it would have to embed the broker + password, which lives in the secret. Plane assembles the broker URL from + these five variables instead, so only RABBITMQ_PASSWORD stays secret. + */}} + {{- if .Values.rabbitmq.enabled }} + RABBITMQ_HOST: {{ printf "%s-rabbitmq" $fullName | quote }} + RABBITMQ_PORT: {{ .Values.rabbitmq.port | quote }} + {{- else }} + RABBITMQ_HOST: {{ required "rabbitmq.enabled is false, so externalRabbitmq.host is required" .Values.externalRabbitmq.host | quote }} + RABBITMQ_PORT: {{ .Values.externalRabbitmq.port | quote }} + {{- end }} + RABBITMQ_USER: {{ .Values.rabbitmq.username | quote }} + RABBITMQ_VHOST: {{ .Values.rabbitmq.vhost | quote }} + + USE_MINIO: {{ $storage.useMinio | quote }} + AWS_S3_BUCKET_NAME: {{ $storage.bucket | quote }} + AWS_REGION: {{ $storage.region | quote }} + SIGNED_URL_EXPIRATION: {{ $storage.signedUrlExpiration | quote }} + {{- with $storage.endpointUrl }} + AWS_S3_ENDPOINT_URL: {{ . | quote }} + {{- end }} + {{- /* + Omitted entirely when empty so boto3 falls through to the service account's + web-identity credentials (IRSA) instead of being handed a blank key. + */}} + {{- with $storage.accessKeyId }} + AWS_ACCESS_KEY_ID: {{ . | quote }} + {{- end }} + {{- with $storage.secretAccessKey }} + AWS_SECRET_ACCESS_KEY: {{ . | quote }} + {{- end }} + + FILE_SIZE_LIMIT: {{ .Values.config.fileSizeLimit | quote }} + API_KEY_RATE_LIMIT: {{ .Values.config.apiKeyRateLimit | quote }} + AUTHENTICATION_RATE_LIMIT: {{ .Values.config.authenticationRateLimit | quote }} + WEBHOOK_ALLOWED_IPS: {{ .Values.config.webhookAllowedIps | quote }} + WEBHOOK_ALLOWED_HOSTS: {{ .Values.config.webhookAllowedHosts | quote }} + {{- range $key, $value := .Values.config.extraEnv }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deployments/helm/plane/templates/live.yaml b/deployments/helm/plane/templates/live.yaml new file mode 100644 index 00000000000..6622c4ff814 --- /dev/null +++ b/deployments/helm/plane/templates/live.yaml @@ -0,0 +1,90 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "live") -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: live +spec: + replicas: {{ .Values.live.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: live + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: live + spec: + serviceAccountName: {{ include "plane.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: live + image: {{ include "plane.image" (dict "root" $ "suffix" "live" "override" .Values.live.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.live.port }} + protocol: TCP + envFrom: + {{- include "plane.envFrom" . | nindent 12 }} + env: + - name: PORT + value: {{ .Values.live.port | quote }} + {{- /* + The shared ConfigMap carries the trailing-slash form Django wants + for LIVE_BASE_PATH; the express router needs it without, so it is + overridden here (env beats envFrom). + */}} + - name: LIVE_BASE_PATH + value: {{ .Values.live.basePath | trimSuffix "/" | quote }} + startupProbe: + httpGet: + path: {{ printf "%s/health" (.Values.live.basePath | trimSuffix "/") }} + port: http + periodSeconds: 5 + failureThreshold: 24 + readinessProbe: + httpGet: + path: {{ printf "%s/health" (.Values.live.basePath | trimSuffix "/") }} + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: {{ printf "%s/health" (.Values.live.basePath | trimSuffix "/") }} + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + {{- toYaml .Values.live.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: live +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.live.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: live diff --git a/deployments/helm/plane/templates/migrator.yaml b/deployments/helm/plane/templates/migrator.yaml new file mode 100644 index 00000000000..5a05d772a8a --- /dev/null +++ b/deployments/helm/plane/templates/migrator.yaml @@ -0,0 +1,49 @@ +{{- if .Values.migrator.enabled }} +{{- /* + Schema migrations. Runs as a Helm pre-install/pre-upgrade hook, which Argo CD + maps onto its own PreSync phase, so the database is always migrated before a + single new api, worker or beat pod starts. + + The job is kept after it succeeds (deleted only when the next one is created) + so its logs stay available for the run that just happened. +*/ -}} +{{- $name := include "plane.componentName" (dict "root" . "component" "migrator") -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: migrator + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + backoffLimit: {{ .Values.migrator.backoffLimit }} + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrator + spec: + restartPolicy: Never + serviceAccountName: {{ include "plane.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: migrator + image: {{ include "plane.image" (dict "root" $ "suffix" "backend" "override" .Values.migrator.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["./bin/docker-entrypoint-migrator.sh"] + envFrom: + {{- include "plane.envFrom" . | nindent 12 }} + resources: + {{- toYaml .Values.migrator.resources | nindent 12 }} +{{- end }} diff --git a/deployments/helm/plane/templates/proxy.yaml b/deployments/helm/plane/templates/proxy.yaml new file mode 100644 index 00000000000..784d3716ffa --- /dev/null +++ b/deployments/helm/plane/templates/proxy.yaml @@ -0,0 +1,83 @@ +{{- $fullName := include "plane.fullname" . -}} +{{- $name := include "plane.componentName" (dict "root" . "component" "proxy") -}} +{{- /* + The single entry point. Point whatever terminates traffic at this Service, and + nothing else in the release needs to be reachable from outside the namespace. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: proxy +spec: + replicas: {{ .Values.proxy.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: proxy + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-proxy.yaml") . | sha256sum }} + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: proxy + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: proxy + image: {{ include "plane.image" (dict "root" $ "suffix" "proxy" "override" .Values.proxy.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.proxy.containerPort }} + protocol: TCP + readinessProbe: + httpGet: + path: /_healthz + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /_healthz + port: http + periodSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.proxy.resources | nindent 12 }} + volumeMounts: + - name: caddyfile + mountPath: /etc/caddy/Caddyfile + subPath: Caddyfile + readOnly: true + volumes: + - name: caddyfile + configMap: + name: {{ $fullName }}-proxy +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: proxy +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.proxy.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: proxy diff --git a/deployments/helm/plane/templates/rabbitmq.yaml b/deployments/helm/plane/templates/rabbitmq.yaml new file mode 100644 index 00000000000..0b624669500 --- /dev/null +++ b/deployments/helm/plane/templates/rabbitmq.yaml @@ -0,0 +1,121 @@ +{{- if .Values.rabbitmq.enabled }} +{{- $name := include "plane.componentName" (dict "root" . "component" "rabbitmq") -}} +{{- /* + Celery's broker. A StatefulSet rather than a Deployment because RabbitMQ keys + its Mnesia directory by node name, and the node name derives from the pod + hostname -- only a StatefulSet keeps that stable across restarts. + + RABBITMQ_DEFAULT_USER/PASS/VHOST seed the broker on its FIRST boot only. Once + the data volume exists RabbitMQ ignores them, so rotating the password in the + secret does not rotate it in the broker; change it with rabbitmqctl (or wipe + the volume) as well. +*/ -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: rabbitmq +spec: + clusterIP: None + ports: + - name: amqp + port: {{ .Values.rabbitmq.port }} + targetPort: amqp + protocol: TCP + - name: management + port: {{ .Values.rabbitmq.managementPort }} + targetPort: management + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: rabbitmq +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: rabbitmq +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: rabbitmq + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: rabbitmq + spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: rabbitmq + image: "{{ .Values.rabbitmq.image.repository }}:{{ .Values.rabbitmq.image.tag }}" + imagePullPolicy: {{ .Values.rabbitmq.image.pullPolicy }} + securityContext: + {{- toYaml .Values.rabbitmq.containerSecurityContext | nindent 12 }} + ports: + - name: amqp + containerPort: {{ .Values.rabbitmq.port }} + protocol: TCP + - name: management + containerPort: {{ .Values.rabbitmq.managementPort }} + protocol: TCP + env: + - name: RABBITMQ_DEFAULT_USER + value: {{ .Values.rabbitmq.username | quote }} + - name: RABBITMQ_DEFAULT_VHOST + value: {{ .Values.rabbitmq.vhost | quote }} + - name: RABBITMQ_DEFAULT_PASS + valueFrom: + secretKeyRef: + name: {{ include "plane.secretName" . }} + key: RABBITMQ_PASSWORD + startupProbe: + exec: + command: ["rabbitmq-diagnostics", "-q", "ping"] + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 30 + readinessProbe: + exec: + command: ["rabbitmq-diagnostics", "-q", "check_port_connectivity"] + periodSeconds: 15 + timeoutSeconds: 10 + livenessProbe: + exec: + command: ["rabbitmq-diagnostics", "-q", "ping"] + periodSeconds: 30 + timeoutSeconds: 15 + failureThreshold: 3 + resources: + {{- toYaml .Values.rabbitmq.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/rabbitmq + {{- if not .Values.rabbitmq.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.rabbitmq.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- with .Values.rabbitmq.persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.rabbitmq.persistence.size }} + {{- end }} +{{- end }} diff --git a/deployments/helm/plane/templates/redis.yaml b/deployments/helm/plane/templates/redis.yaml new file mode 100644 index 00000000000..ba3798acae3 --- /dev/null +++ b/deployments/helm/plane/templates/redis.yaml @@ -0,0 +1,111 @@ +{{- if .Values.redis.enabled }} +{{- $name := include "plane.componentName" (dict "root" . "component" "redis") -}} +{{- /* + Django's cache and the live server's presence store. A headless Service backs + the StatefulSet and is what clients resolve: with a single replica its A + record is the pod itself, so no extra ClusterIP is needed. +*/ -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + clusterIP: None + ports: + - name: redis + port: {{ .Values.redis.port }} + targetPort: redis + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.redis.image.pullPolicy }} + securityContext: + {{- toYaml .Values.redis.containerSecurityContext | nindent 12 }} + {{- /* + args, not command: the image's entrypoint prepares /data and drops to + the unprivileged user before exec'ing this. + */}} + args: + - valkey-server + - --dir + - /data + {{- if .Values.redis.persistence.enabled }} + - --save + - "60" + - "1" + {{- else }} + - --save + - "" + {{- end }} + - --appendonly + - "no" + ports: + - name: redis + containerPort: {{ .Values.redis.port }} + protocol: TCP + readinessProbe: + exec: + command: ["valkey-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["valkey-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /data + {{- if not .Values.redis.persistence.enabled }} + volumes: + - name: data + emptyDir: {} + {{- end }} + {{- if .Values.redis.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + {{- with .Values.redis.persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.redis.persistence.size }} + {{- end }} +{{- end }} diff --git a/deployments/helm/plane/templates/secret.yaml b/deployments/helm/plane/templates/secret.yaml new file mode 100644 index 00000000000..d7b17cf292a --- /dev/null +++ b/deployments/helm/plane/templates/secret.yaml @@ -0,0 +1,23 @@ +{{- if .Values.secrets.create }} +{{- /* + Only for installs that manage their own secret material. Deployments backed by + an external secret store leave create=false and consume the secret it syncs + instead (see extraEnvFrom). +*/ -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "plane.secretName" . }} + labels: + {{- include "plane.labels" . | nindent 4 }} +type: Opaque +stringData: + SECRET_KEY: {{ required "secrets.create is true, so secrets.secretKey is required" .Values.secrets.secretKey | quote }} + DATABASE_URL: {{ required "secrets.create is true, so secrets.databaseUrl is required" .Values.secrets.databaseUrl | quote }} + LIVE_SERVER_SECRET_KEY: {{ required "secrets.create is true, so secrets.liveServerSecretKey is required" .Values.secrets.liveServerSecretKey | quote }} + {{- if .Values.rabbitmq.enabled }} + RABBITMQ_PASSWORD: {{ required "secrets.create is true and rabbitmq is enabled, so secrets.rabbitmqPassword is required" .Values.secrets.rabbitmqPassword | quote }} + {{- else }} + RABBITMQ_PASSWORD: {{ .Values.secrets.rabbitmqPassword | quote }} + {{- end }} +{{- end }} diff --git a/deployments/helm/plane/templates/serviceaccount.yaml b/deployments/helm/plane/templates/serviceaccount.yaml new file mode 100644 index 00000000000..0264bebaa1a --- /dev/null +++ b/deployments/helm/plane/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "plane.serviceAccountName" . }} + labels: + {{- include "plane.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deployments/helm/plane/templates/space.yaml b/deployments/helm/plane/templates/space.yaml new file mode 100644 index 00000000000..27952d1d817 --- /dev/null +++ b/deployments/helm/plane/templates/space.yaml @@ -0,0 +1,81 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "space") -}} +{{- /* + Public "spaces" app, server-rendered under /spaces. Like the other frontends + it is built with relative base URLs, so upstream's compose file gives it no + environment either. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: space +spec: + replicas: {{ .Values.space.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: space + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: space + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: space + image: {{ include "plane.image" (dict "root" $ "suffix" "space" "override" .Values.space.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.space.port }} + protocol: TCP + env: + - name: PORT + value: {{ .Values.space.port | quote }} + startupProbe: + httpGet: + path: /spaces/ + port: http + periodSeconds: 5 + failureThreshold: 24 + readinessProbe: + httpGet: + path: /spaces/ + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /spaces/ + port: http + periodSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.space.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: space +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.space.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: space diff --git a/deployments/helm/plane/templates/web.yaml b/deployments/helm/plane/templates/web.yaml new file mode 100644 index 00000000000..fadd5f59e6e --- /dev/null +++ b/deployments/helm/plane/templates/web.yaml @@ -0,0 +1,72 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "web") -}} +{{- /* + The main app: a static bundle served by nginx. It is built with relative API + and asset base URLs, so it needs no runtime configuration -- everything it + calls is same-origin through the proxy. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + replicas: {{ .Values.web.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: web + template: + metadata: + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: web + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: web + image: {{ include "plane.image" (dict "root" $ "suffix" "frontend" "override" .Values.web.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.web.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.web.port }} + protocol: TCP + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + periodSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.web.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.web.port }} + targetPort: http + protocol: TCP + selector: + {{- include "plane.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: web diff --git a/deployments/helm/plane/templates/worker.yaml b/deployments/helm/plane/templates/worker.yaml new file mode 100644 index 00000000000..84d7bb25ca8 --- /dev/null +++ b/deployments/helm/plane/templates/worker.yaml @@ -0,0 +1,58 @@ +{{- $name := include "plane.componentName" (dict "root" . "component" "worker") -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + labels: + {{- include "plane.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + {{- include "plane.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "plane.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + serviceAccountName: {{ include "plane.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: worker + image: {{ include "plane.image" (dict "root" $ "suffix" "backend" "override" .Values.worker.image) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + command: ["./bin/docker-entrypoint-worker.sh"] + envFrom: + {{- include "plane.envFrom" . | nindent 12 }} + {{- /* + Celery has no HTTP surface; `celery inspect ping` round-trips + through the broker, which is the dependency actually worth + watching. The generous timeouts keep a busy worker from being + killed mid-task. + */}} + startupProbe: + exec: + command: ["bash", "-c", 'celery -A plane inspect ping -d "celery@$(hostname)"'] + periodSeconds: 15 + timeoutSeconds: 15 + failureThreshold: 20 + livenessProbe: + exec: + command: ["bash", "-c", 'celery -A plane inspect ping -d "celery@$(hostname)"'] + periodSeconds: 60 + timeoutSeconds: 20 + failureThreshold: 3 + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} diff --git a/deployments/helm/plane/values.yaml b/deployments/helm/plane/values.yaml new file mode 100644 index 00000000000..eb9c1d94ec1 --- /dev/null +++ b/deployments/helm/plane/values.yaml @@ -0,0 +1,328 @@ +# Plane (community edition) on Kubernetes. +# +# Six images make up a Plane install, all published side by side under one +# registry namespace. `image.repository` is therefore a PREFIX, not a complete +# reference -- each component appends its own suffix (-frontend, -space, -admin, +# -live, -backend, -proxy). That way a GitOps layer can inject a single +# image.repository/image.tag pair -- one image reference for the whole release, +# which is all a generic Application template knows how to supply -- and still +# resolve six distinct images. Set .image to pin one component to an +# unrelated reference. +image: + repository: ghcr.io/crewlet/plane + tag: "" + pullPolicy: IfNotPresent + +# Pull secrets for the private GHCR images. Create a docker-registry secret in +# the release namespace (e.g. "ghcr-pull") and reference it here by name. +imagePullSecrets: [] +# - name: ghcr-pull + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + name: "" + # IRSA: the api/worker/beat pods reach the uploads bucket through the + # service account rather than static keys, so AWS_ACCESS_KEY_ID and + # AWS_SECRET_ACCESS_KEY below stay empty and boto3 falls back to the + # web-identity credentials. Empty by default because a GitOps deploy + # normally injects the role ARN; set it here for a standalone + # `helm install`. + annotations: {} + # eks.amazonaws.com/role-arn: "arn:aws:iam:::role/" + +# Non-secret runtime configuration. Rendered into the shared ConfigMap and +# handed to the api, worker, beat, migrator and live components. +config: + # Public origin Plane is served on, scheme included. Every component is served + # from this single origin through the proxy, so it doubles as the app, admin, + # space and live base URL. + webUrl: "http://localhost" + # Comma-separated origins allowed to call the API. Django derives its CSRF + # trusted origins from the same list, and marks session/CSRF cookies Secure + # when every entry is https. + corsAllowedOrigins: "http://localhost" + debug: "0" + gunicornWorkers: "2" + # Maximum accepted upload size in bytes (5 MiB). Enforced by both Django and + # the proxy's request body limit. + fileSizeLimit: "5242880" + apiKeyRateLimit: "60/minute" + authenticationRateLimit: "10/minute" + # Comma-separated IPs/CIDRs and hostnames that may be webhook targets even + # when they resolve to private networks. Leave empty to keep the SSRF guard + # fully closed. + webhookAllowedIps: "" + webhookAllowedHosts: "" + + storage: + # 0 selects real S3; the chart deploys no MinIO. + useMinio: "0" + bucket: "" + region: "" + # Leave empty for AWS S3 so boto3 resolves the regional endpoint itself. + # Set it for an S3-compatible store (MinIO, R2, Ceph). + endpointUrl: "" + # Lifetime of the presigned upload/download URLs the API hands to browsers. + # Under IRSA a presigned URL also dies with the session that signed it, so + # keep this well under the role's session duration. + signedUrlExpiration: "3600" + # Static credentials for stores that have no IRSA equivalent. Leave both + # empty on EKS: the env vars are then omitted entirely and boto3 picks up + # the service account's web-identity credentials. + accessKeyId: "" + secretAccessKey: "" + + # Free-form extra environment merged into the shared ConfigMap. Use for the + # settings this chart does not model (SMTP, Unsplash, PostHog, ...). + extraEnv: {} + # POSTHOG_API_KEY: "phc_..." + +# Secret material. Leave create=false to consume a secret managed out of band +# (External Secrets, SealedSecrets, ...) named by existingSecret; that secret +# must carry SECRET_KEY, LIVE_SERVER_SECRET_KEY, RABBITMQ_PASSWORD and +# DATABASE_URL, and the extraEnvFrom entry below is what hands it to the pods. +# Set create=true only when the release manages its own secret material. +secrets: + create: false + # Consulted whenever a component needs one specific key rather than the whole + # secret (RabbitMQ's default password), so it must name the same secret the + # extraEnvFrom entry below references. + existingSecret: "plane-secrets" + secretKey: "" # Django SECRET_KEY + liveServerSecretKey: "" # shared secret between the api and the live server + rabbitmqPassword: "" # also seeds the in-cluster broker's default user + databaseUrl: "" # postgres://user:pass@host:5432/db?sslmode=require + +# Additional env references layered on top of the shared ConfigMap (e.g. an +# externally managed secret). Later entries win over earlier ones. +extraEnvFrom: [] +# - secretRef: +# name: plane-secrets + +# Applied to every container unless a component overrides it below. The upstream +# Plane images run as root and write inside their working directory +# (collectstatic output, rotating logs, Caddy's data dir), so runAsNonRoot and +# readOnlyRootFilesystem are deliberately not set -- dropping capabilities and +# privilege escalation is what these images support without patching. +# +# The four components that DO shed root themselves (web and admin, whose nginx +# master hands workers to the `nginx` user; redis and rabbitmq, whose entrypoints +# chown the data dir and gosu into the service user) add back exactly the +# capabilities that transition needs. Dropping ALL there would leave nginx unable +# to start its workers and gosu unable to call setuid. +podSecurityContext: {} +containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + +# -------------------------------------------------------------------------- +# Components +# -------------------------------------------------------------------------- + +# Django ASGI application. Serves /api, /auth and /static. +api: + replicaCount: 2 + port: 8000 + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1Gi + +# Celery worker draining the RabbitMQ queues (emails, exports, notifications). +worker: + replicaCount: 1 + resources: + requests: + cpu: 100m + memory: 384Mi + limits: + memory: 768Mi + +# Celery beat scheduler. Exactly one replica: a second would double-fire every +# periodic task. +beat: + replicaCount: 1 + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + memory: 512Mi + +# `manage.py migrate`, run as a pre-install/pre-upgrade hook. Argo CD maps those +# Helm hooks onto its own PreSync phase, so the schema is always current before +# any new api pod starts. +migrator: + enabled: true + backoffLimit: 6 + resources: + requests: + cpu: 100m + memory: 384Mi + limits: + memory: 768Mi + +# Collaborative editing server (Hocuspocus over WebSocket). +live: + replicaCount: 1 + port: 3000 + basePath: "/live" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + +# Main React app, served as static files by nginx. +web: + replicaCount: 2 + port: 3000 + # nginx starts as root and drops its worker processes to the `nginx` user. + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - SETUID + - SETGID + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + memory: 128Mi + +# Public "spaces" app (server-rendered). +space: + replicaCount: 1 + port: 3000 + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + memory: 512Mi + +# God-mode admin app, served as static files by nginx. +admin: + replicaCount: 1 + port: 3000 + # Same nginx privilege drop as the web app. + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - SETUID + - SETGID + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + memory: 128Mi + +# Caddy front door. The single entry point: it fans /spaces, /god-mode, /live, +# /api, /auth and /static out to the components and serves everything else from +# the web app. +proxy: + replicaCount: 2 + # Service port. TLS terminates ahead of the release, so this is plain HTTP. + port: 80 + # Container port. Deliberately above 1024 so Caddy needs no NET_BIND_SERVICE + # capability and the drop-ALL security context holds. + containerPort: 8080 + # Caddy only honours X-Forwarded-Proto/For from a trusted peer. Django marks a + # request secure from that header, so the ingress hop must be trusted or every + # request looks like plain HTTP and CSRF checks fail. + trustedProxies: "0.0.0.0/0" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + +# Cache and the live server's presence store. +redis: + enabled: true + image: + repository: valkey/valkey + tag: "7.2.11-alpine" + pullPolicy: IfNotPresent + port: 6379 + persistence: + enabled: true + size: 8Gi + storageClassName: "gp3" + # The image's entrypoint chowns /data and gosu's into the `valkey` user. + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETUID + - SETGID + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 256Mi + +# Celery broker. +rabbitmq: + enabled: true + image: + repository: rabbitmq + tag: "3.13.6-management-alpine" + pullPolicy: IfNotPresent + port: 5672 + managementPort: 15672 + username: "plane" + vhost: "plane" + persistence: + enabled: true + size: 8Gi + storageClassName: "gp3" + # The image's entrypoint writes the generated config, chowns + # /var/lib/rabbitmq and gosu's into the `rabbitmq` user. + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETUID + - SETGID + resources: + requests: + cpu: 100m + memory: 384Mi + limits: + memory: 768Mi + +# Set redis.enabled/rabbitmq.enabled to false and point these at an external +# endpoint (ElastiCache, Amazon MQ) to run without the in-cluster statefulsets. +externalRedis: + url: "" +externalRabbitmq: + host: "" + port: "5672" From 6138b3b723cc7845e473f3be501c16f0d928cb34 Mon Sep 17 00:00:00 2001 From: Ali Yousefian Date: Mon, 17 Aug 2026 20:40:48 +0000 Subject: [PATCH 2/4] fix(api): let the AWS credential settings fall through to the role AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY defaulted to the literal strings "access-key" and "secret-key". Anything reading them through django settings -- the workspace export task and the expired-export cleanup task both build their own boto3 client that way -- therefore handed S3 a placeholder key whenever the variables were unset, and got InvalidAccessKeyId back instead of falling through to boto3's credential chain. That is exactly how a deployment on an instance profile or an EKS service account role is configured: no keys in the environment at all. S3Storage already got this right by reading os.environ directly with no default, so uploads worked while exports did not. The placeholders only ever fitted the bundled MinIO, and both compose files set the pair explicitly, so nothing that relied on them loses it. Co-Authored-By: Claude Opus 5 --- apps/api/plane/settings/common.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index bdd073b10ef..7ea8aaa17ee 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -312,8 +312,14 @@ STORAGES = {"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}} STORAGES["default"] = {"BACKEND": "plane.settings.storage.S3Storage"} -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") +# Use `or None` so an unset (or explicitly empty) variable falls through to +# boto3's own credential chain -- an instance profile, or the web-identity +# token an EKS service account is bound to -- rather than handing boto3 a +# placeholder key it will send to S3 and get InvalidAccessKeyId back for. The +# placeholders only ever fitted the bundled MinIO, which sets both explicitly +# anyway; a real S3 deployment on a role has nothing to put here. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") or None +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") or None AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") AWS_REGION = os.environ.get("AWS_REGION", "") AWS_DEFAULT_ACL = "public-read" From 6b9271eb141f9464ec272b400bd75c60eb9df6af Mon Sep 17 00:00:00 2001 From: Ali Yousefian Date: Mon, 17 Aug 2026 21:54:11 +0000 Subject: [PATCH 3/4] feat(deploy): let the cache and broker come from a secret The chart offered two ways to reach Redis and RabbitMQ: its own statefulsets, or a plaintext endpoint in values. A managed cache or broker fits neither -- its URL carries credentials, so it belongs in a secret, and requiring externalRedis.url in values failed the render for a deployment that supplies REDIS_URL through extraEnvFrom instead. Disabling a statefulset without naming a replacement in values now simply omits those keys from the ConfigMap, leaving the URL to arrive from the secret. Plane already prefers AMQP_URL over the assembled RABBITMQ_* parts, so the broker needs nothing further, and both TLS schemes these endpoints use are understood already: django-redis and redis-py branch on rediss://, ioredis turns TLS on from the scheme alone, and kombu speaks amqps://. The other two paths are unchanged. Co-Authored-By: Claude Opus 5 --- .../helm/plane/templates/configmap.yaml | 25 +++++++++++++------ deployments/helm/plane/values.yaml | 16 ++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/deployments/helm/plane/templates/configmap.yaml b/deployments/helm/plane/templates/configmap.yaml index 34bc3e601cf..05e4ecbadde 100644 --- a/deployments/helm/plane/templates/configmap.yaml +++ b/deployments/helm/plane/templates/configmap.yaml @@ -28,28 +28,37 @@ data: # In-cluster address of the API, for the live server's server-to-server calls. API_BASE_URL: {{ printf "http://%s-api:%v" $fullName .Values.api.port | quote }} + {{- /* + Three ways to reach the cache, in order: the statefulset this chart runs, + a plaintext endpoint given in values, or -- when neither is set -- REDIS_URL + supplied through extraEnvFrom. That last one is the norm for a managed cache, + whose URL carries an auth token and so belongs in a secret, not a ConfigMap. + */}} {{- if .Values.redis.enabled }} REDIS_URL: {{ printf "redis://%s-redis:%v/" $fullName .Values.redis.port | quote }} REDIS_HOST: {{ printf "%s-redis" $fullName | quote }} REDIS_PORT: {{ .Values.redis.port | quote }} - {{- else }} - REDIS_URL: {{ required "redis.enabled is false, so externalRedis.url is required" .Values.externalRedis.url | quote }} + {{- else if .Values.externalRedis.url }} + REDIS_URL: {{ .Values.externalRedis.url | quote }} {{- end }} {{- /* - AMQP_URL is deliberately not set: it would have to embed the broker - password, which lives in the secret. Plane assembles the broker URL from - these five variables instead, so only RABBITMQ_PASSWORD stays secret. + Same three ways for the broker. With the statefulset or a plaintext endpoint + Plane assembles the URL from these parts, keeping only RABBITMQ_PASSWORD + secret; a managed broker instead supplies a complete AMQP_URL through + extraEnvFrom, which Plane prefers over the parts when both are present. */}} {{- if .Values.rabbitmq.enabled }} RABBITMQ_HOST: {{ printf "%s-rabbitmq" $fullName | quote }} RABBITMQ_PORT: {{ .Values.rabbitmq.port | quote }} - {{- else }} - RABBITMQ_HOST: {{ required "rabbitmq.enabled is false, so externalRabbitmq.host is required" .Values.externalRabbitmq.host | quote }} + RABBITMQ_USER: {{ .Values.rabbitmq.username | quote }} + RABBITMQ_VHOST: {{ .Values.rabbitmq.vhost | quote }} + {{- else if .Values.externalRabbitmq.host }} + RABBITMQ_HOST: {{ .Values.externalRabbitmq.host | quote }} RABBITMQ_PORT: {{ .Values.externalRabbitmq.port | quote }} - {{- end }} RABBITMQ_USER: {{ .Values.rabbitmq.username | quote }} RABBITMQ_VHOST: {{ .Values.rabbitmq.vhost | quote }} + {{- end }} USE_MINIO: {{ $storage.useMinio | quote }} AWS_S3_BUCKET_NAME: {{ $storage.bucket | quote }} diff --git a/deployments/helm/plane/values.yaml b/deployments/helm/plane/values.yaml index eb9c1d94ec1..7d4910861f8 100644 --- a/deployments/helm/plane/values.yaml +++ b/deployments/helm/plane/values.yaml @@ -319,8 +319,20 @@ rabbitmq: limits: memory: 768Mi -# Set redis.enabled/rabbitmq.enabled to false and point these at an external -# endpoint (ElastiCache, Amazon MQ) to run without the in-cluster statefulsets. +# Set redis.enabled/rabbitmq.enabled to false to run against endpoints outside +# the release. There are then two ways to supply them: +# +# 1. Through extraEnvFrom, as REDIS_URL and AMQP_URL on the secret. Leave the +# values below empty. This is the right choice for a managed cache or +# broker, whose URLs carry credentials (and are usually TLS -- rediss:// and +# amqps://, both of which Plane and the live server handle) and so belong in +# a secret rather than a ConfigMap. +# 2. Here, for an endpoint that needs no credentials in its URL. The broker +# password still comes from the secret as RABBITMQ_PASSWORD, since Plane +# assembles the broker URL from the parts below plus that password. +# +# Setting neither, with the statefulsets disabled and nothing in extraEnvFrom, +# leaves the components with no cache or broker to reach. externalRedis: url: "" externalRabbitmq: From 9f044fe9d9f6bbbeddc0159cf914411bf86000f1 Mon Sep 17 00:00:00 2001 From: Ali Yousefian Date: Thu, 20 Aug 2026 21:11:46 +0000 Subject: [PATCH 4/4] ci: validate and publish the Helm chart The chart had no CI at all: a broken template, a bad indent or a guard that stopped firing would reach preview, and the first thing to notice would be whatever tried to install it. The validate job runs helm lint, renders the four supported values permutations -- defaults, a GitOps overlay, external endpoints in values, and chart-managed secrets -- and checks all of it against real Kubernetes schemas with kubeconform in strict mode, which catches the typos helm template renders happily. It also asserts the negative case: rendering with secrets.create=true and no values must FAIL, so the guards cannot silently stop protecting anything. The publish job packages the chart to ghcr.io//charts as an OCI artifact, so consumers can install it without cloning: helm install plane oci://ghcr.io/crewlet/charts/plane --version X.Y.Z Publishing is driven by version: in Chart.yaml, bumped by hand, and the job refuses to overwrite a version already in the registry. That refusal is what makes an unbumped version a silent no-op, so the pull request job fails when chart files change without a version bump rather than letting the mistake through. Co-Authored-By: Claude Opus 5 --- .github/workflows/helm-chart.yml | 210 +++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .github/workflows/helm-chart.yml diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml new file mode 100644 index 00000000000..645253127bd --- /dev/null +++ b/.github/workflows/helm-chart.yml @@ -0,0 +1,210 @@ +name: Helm chart + +# Validates the Plane Helm chart on every pull request that touches it, and +# publishes it to the GitHub Container Registry as an OCI artifact when a new +# version lands on `preview`. +# +# Publishing is driven by `version:` in Chart.yaml, bumped by hand. The publish +# step refuses to overwrite a version that already exists in the registry, so a +# chart change shipped without a version bump would silently never reach +# consumers -- which is why the pull request job fails when the version is +# unchanged. +# +# Consumers install it with: +# helm install plane oci://ghcr.io/crewlet/charts/plane --version X.Y.Z + +on: + pull_request: + branches: + - preview + paths: + - "deployments/helm/**" + - ".github/workflows/helm-chart.yml" + push: + branches: + - preview + paths: + - "deployments/helm/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + CHART_DIR: deployments/helm/plane + CHART_NAME: plane + +jobs: + validate: + name: Validate chart + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: helm lint + run: helm lint "$CHART_DIR" + + - name: Render every supported values permutation + run: | + set -euo pipefail + mkdir -p /tmp/rendered + + render() { + local name="$1"; shift + echo "==> $name" + helm template plane "$CHART_DIR" "$@" > "/tmp/rendered/${name}.yaml" + } + + # 1. Chart defaults: in-cluster Valkey and RabbitMQ statefulsets. + render defaults + + # 2. GitOps overlay: managed cache and broker, with REDIS_URL and + # AMQP_URL arriving through extraEnvFrom rather than values. + render gitops \ + --set image.repository=ghcr.io/crewlet/plane \ + --set image.tag=preview \ + --set imagePullSecrets[0].name=ghcr-pull \ + --set redis.enabled=false \ + --set rabbitmq.enabled=false \ + --set secrets.create=false \ + --set extraEnvFrom[0].secretRef.name=plane-secrets \ + --set config.webUrl=https://plane.example.com \ + --set config.corsAllowedOrigins=https://plane.example.com \ + --set config.storage.bucket=example-uploads \ + --set config.storage.region=us-east-2 + + # 3. External endpoints supplied in values rather than a secret. + render external \ + --set redis.enabled=false \ + --set rabbitmq.enabled=false \ + --set externalRedis.url=redis://cache:6379/ \ + --set externalRabbitmq.host=mq.internal + + # 4. Chart-managed secret material. + render self-secrets \ + --set secrets.create=true \ + --set secrets.secretKey=x \ + --set secrets.liveServerSecretKey=y \ + --set secrets.rabbitmqPassword=z \ + --set secrets.databaseUrl=postgres://u:p@h:5432/db + + - name: Guards must reject an incomplete secret + run: | + set -euo pipefail + # secrets.create=true with no values must fail the render, not emit an + # empty SECRET_KEY into a cluster. + if helm template plane "$CHART_DIR" --set secrets.create=true >/dev/null 2>&1; then + echo "::error::secrets.create=true rendered without the required values; the guards are not firing." + exit 1 + fi + echo "Guards fired as expected." + + - name: Validate manifests against Kubernetes schemas + run: | + set -euo pipefail + curl -sSLo /tmp/kubeconform.tar.gz \ + https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + # -strict rejects unknown fields, which is what catches a typo that + # helm template alone renders happily. + /tmp/kubeconform -strict -summary -kubernetes-version 1.31.0 /tmp/rendered/*.yaml + + - name: Require a chart version bump + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + chart_version() { awk '/^version:/ { print $2; exit }' "$1"; } + + git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + base=$(git show "FETCH_HEAD:$CHART_DIR/Chart.yaml" 2>/dev/null | awk '/^version:/ { print $2; exit }' || true) + head=$(chart_version "$CHART_DIR/Chart.yaml") + + if [ -z "$base" ]; then + echo "No chart on ${{ github.base_ref }} yet; $head will be the first published version." + exit 0 + fi + + if [ "$base" = "$head" ]; then + echo "::error file=$CHART_DIR/Chart.yaml::Chart files changed but version is still $head." + echo "The publish job never overwrites an existing version, so this change would not reach the registry." + echo "Bump 'version:' in $CHART_DIR/Chart.yaml." + exit 1 + fi + + echo "Chart version $base -> $head" + + publish: + name: Publish chart + needs: validate + if: github.event_name != 'pull_request' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + + - uses: azure/setup-helm@v4 + with: + version: v3.16.3 + + - name: Resolve version and registry + id: meta + run: | + set -euo pipefail + # GHCR rejects uppercase in image paths; org names may contain them. + owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + version=$(awk '/^version:/ { print $2; exit }' "$CHART_DIR/Chart.yaml") + echo "registry=ghcr.io/${owner}/charts" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Chart ${CHART_NAME} ${version} -> oci://ghcr.io/${owner}/charts" + + - name: Log in to GHCR + run: | + echo "${{ secrets.GITHUB_TOKEN }}" \ + | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Skip if this version is already published + id: exists + run: | + set -euo pipefail + if helm show chart \ + "oci://${{ steps.meta.outputs.registry }}/$CHART_NAME" \ + --version "${{ steps.meta.outputs.version }}" >/dev/null 2>&1; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "Version ${{ steps.meta.outputs.version }} is already in the registry; nothing to publish." + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi + + - name: Package and push + if: steps.exists.outputs.already == 'false' + run: | + set -euo pipefail + helm package "$CHART_DIR" --destination /tmp/chart + helm push "/tmp/chart/${CHART_NAME}-${{ steps.meta.outputs.version }}.tgz" \ + "oci://${{ steps.meta.outputs.registry }}" + + - name: Summary + run: | + { + echo "### Helm chart" + echo + if [ "${{ steps.exists.outputs.already }}" = "true" ]; then + echo "\`${{ steps.meta.outputs.version }}\` was already published — no change." + else + echo "Published \`${CHART_NAME}\` **${{ steps.meta.outputs.version }}**." + fi + echo + echo '```' + echo "helm install plane oci://${{ steps.meta.outputs.registry }}/${CHART_NAME} --version ${{ steps.meta.outputs.version }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY"