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" 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" 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..05e4ecbadde --- /dev/null +++ b/deployments/helm/plane/templates/configmap.yaml @@ -0,0 +1,88 @@ +{{- $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 }} + + {{- /* + 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 if .Values.externalRedis.url }} + REDIS_URL: {{ .Values.externalRedis.url | quote }} + {{- end }} + + {{- /* + 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 }} + 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 }} + 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 }} + 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..7d4910861f8 --- /dev/null +++ b/deployments/helm/plane/values.yaml @@ -0,0 +1,340 @@ +# 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 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: + host: "" + port: "5672"