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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions deployments/helm/plane/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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"
147 changes: 147 additions & 0 deletions deployments/helm/plane/README.md
Original file line number Diff line number Diff line change
@@ -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 `<component>.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.
103 changes: 103 additions & 0 deletions deployments/helm/plane/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -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 }}
71 changes: 71 additions & 0 deletions deployments/helm/plane/templates/admin.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading