From 7ad565b6898ca7c122ae21f35c3dfd77836086bf Mon Sep 17 00:00:00 2001 From: Ali Yousefian Date: Mon, 17 Aug 2026 20:40:48 +0000 Subject: [PATCH 1/2] 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/2] 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"