Skip to content
Merged
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
210 changes: 210 additions & 0 deletions .github/workflows/helm-chart.yml
Original file line number Diff line number Diff line change
@@ -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"
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.
Loading
Loading