From 5087b3c9c08d058b74590cc12df21ab483409848 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:27:38 -0600 Subject: [PATCH 1/8] init calcom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted Cal.com scheduling and booking pages on the existing `postgres` (or `postgres-highly-available`) template, pinned to calcom/cal.com:v6.2.0. - App tier is `standard` and volumeless; all state is in Postgres. - A second small workload drives Cal.com's seven scheduled endpoints, which nothing inside the container calls — without it the task queue, calendar sync and credential refresh silently never run while every health surface is green. - Private by default: /auth/setup grants instance-admin to the first caller and self-service signup cannot be disabled by env (NEXT_PUBLIC_*, inlined at build), so the README's first run is install private -> cpln port-forward -> claim admin -> then decide about public access. - nextAuthSecret, encryptionKey, cronSecret and cronApiKey are one required prerequisite dictionary secret, never values. Co-Authored-By: Claude Opus 5 --- briefings/calcom.md | 107 ++++++++ calcom/icon.png | Bin 0 -> 16104 bytes calcom/versions/1.0.0/Chart.yaml | 26 ++ calcom/versions/1.0.0/README.md | 225 +++++++++++++++++ calcom/versions/1.0.0/templates/_helpers.tpl | 233 ++++++++++++++++++ calcom/versions/1.0.0/templates/identity.yaml | 5 + calcom/versions/1.0.0/templates/policy.yaml | 20 ++ .../versions/1.0.0/templates/secret-db.yaml | 21 ++ .../1.0.0/templates/workload-cron.yaml | 123 +++++++++ calcom/versions/1.0.0/templates/workload.yaml | 196 +++++++++++++++ calcom/versions/1.0.0/values.yaml | 178 +++++++++++++ 11 files changed, 1134 insertions(+) create mode 100644 briefings/calcom.md create mode 100644 calcom/icon.png create mode 100644 calcom/versions/1.0.0/Chart.yaml create mode 100644 calcom/versions/1.0.0/README.md create mode 100644 calcom/versions/1.0.0/templates/_helpers.tpl create mode 100644 calcom/versions/1.0.0/templates/identity.yaml create mode 100644 calcom/versions/1.0.0/templates/policy.yaml create mode 100644 calcom/versions/1.0.0/templates/secret-db.yaml create mode 100644 calcom/versions/1.0.0/templates/workload-cron.yaml create mode 100644 calcom/versions/1.0.0/templates/workload.yaml create mode 100644 calcom/versions/1.0.0/values.yaml diff --git a/briefings/calcom.md b/briefings/calcom.md new file mode 100644 index 00000000..e0395289 --- /dev/null +++ b/briefings/calcom.md @@ -0,0 +1,107 @@ +# calcom — maintainer briefing + +## What it is + +- **Cal.com** — self-hosted scheduling and booking pages; the open-source Calendly alternative. +- **AGPL-3.0** (strong copyleft: anyone offering a *modified* version as a service must publish + their changes). Free to self-host, nothing to register. We ship the unmodified upstream image, + so the obligation does not attach to us. +- Shipped on `calcom/cal.com:v6.2.0` — the newest published non-`latest`, non-arm tag, matching + upstream's newest GitHub release. + +## Common use cases + +- Team booking pages on your own domain, with your own data, instead of Calendly/SavvyCal. +- Round-robin and collective scheduling for sales/support rotas. +- Embedding a booking flow into an existing product via Cal.com's embed script. +- Keeping attendee PII (names, emails, meeting notes) inside the customer's own org rather than + a third-party SaaS. + +## Architecture on cpln + +| Resource | Purpose | +|---|---| +| workload `{release}-calcom` (`standard`) | The whole app — UI, booking pages, `/api/*`. Port 3000, HTTP. Runs Prisma migrations at boot | +| workload `{release}-calcom-cron` (`standard`, 1 replica) | Drives Cal.com's scheduled endpoints on a 60 s `wget` loop. Same image, entrypoint overridden | +| subchart `postgres` 3.4.1 (default) / `postgres-highly-available` 2.7.0 (flag) | All state. Nothing else is persisted | +| secret (dictionary, **user-created**) `my-calcom-auth` | `nextAuthSecret`, `encryptionKey`, `cronSecret`, `cronApiKey` | +| secret (dictionary, chart-created) `my-calcom-db-credentials` | Bundled DB `username`/`password`/`database` | +| identity + policy | One identity for both workloads; `reveal` on exactly those secrets (plus the SMTP secret when configured) | + +- **No volumeset on the app** — it is genuinely stateless (upstream's compose mounts no volume; + avatars live in Postgres), which is what makes `replicas: 2+` work. +- **Private by default.** First run is: install → `cpln port-forward` → `/auth/setup` → + `helm upgrade --set publicAccess.enabled=true`. + +## Key knobs + +| Knob | Default | Note | +|---|---|---| +| `calcom.image` | `calcom/cal.com:v6.2.0` | Official image | +| `calcom.replicas` | `1` | `2+` for zero-downtime rolling restarts; no clustering to form | +| `calcom.appUrl` | `""` | Empty = derived; set only for a custom domain, **with** the scheme | +| `calcom.auth.secretName` | `my-calcom-auth` | **Must exist before install** | +| `cron.enabled` | `true` | Turning it off silently stops every scheduled job | +| `email.enabled` | `false` | Off = no confirmations, invites or password resets | +| `publicAccess.enabled` | `false` | Flip only after claiming the admin account | +| `postgres.enabled` / `postgresHA.enabled` | `true` / `false` | Exactly one; the chart fails on both or neither | + +## Troubleshooting / considerations + +- **A missing `my-calcom-auth` secret wedges the install almost invisibly.** `cpln logs` returns + *zero* lines. The only place the missing secret is named is + `cpln workload get-deployments {workload} --gvc {gvc} -o yaml` → `status.versions[].message`. + It self-heals ~5.5–10.5 minutes after the secret is created, or immediately with + `cpln workload force-redeployment`. +- **`encryptionKey` must be exactly 32 characters** (`openssl rand -base64 24` — verified to + produce exactly 32) and is effectively unrotatable: it encrypts every stored calendar and app + credential, so changing it orphans them all. Treat it as write-once for the life of the install. +- **`cronSecret` is a real security control, not decoration — measured, not inferred.** Running + the pinned image with `CRON_SECRET` unset: `GET /api/tasks/cron` with no header returns **401**, + and with `Authorization: Bearer undefined` returns **400** — i.e. it got *past* the auth check + and failed later. Any unauthenticated caller sending that header can trigger `tasker.cleanup()` + and queue processing (upstream issue #29565, unfixed at v6.2.0). The template always sets it + from the prerequisite secret; there is no valid unset path. Do not let anyone "simplify" it away. +- **Self-service signup cannot be turned off with an environment variable.** Cal.com reads + `NEXT_PUBLIC_DISABLE_SIGNUP`, and every `NEXT_PUBLIC_*` value is compiled into the app when the + image is *built*, so setting it at runtime does nothing. The working path is the database-backed + feature flag `disable-signup` in Settings → Admin → Features, after the admin account exists. + This is why public access defaults to off. +- **Whoever reaches `/auth/setup` first becomes the instance admin**, and there is no way to + pre-create the owner from a secret (unlike keycloak or langfuse). Publishing the endpoint before + finishing the wizard hands out admin. +- **Without the cron workload, nothing scheduled ever runs** — queued outbound email, calendar + sync, credential refresh — and every health surface still reads green. This is the failure users + will report as "reminders stopped working": check that `{release}-calcom-cron` exists and that + its log shows `calcom-cron /api/tasks/cron -> 200` roughly every 60 s. `cron.enabled` with + `internalAccess.type: none` is refused at render, because the loop's calls arrive on the app's + internal inbound. +- **`HOSTNAME: "0.0.0.0"` on the app container is load-bearing insurance, not dead config.** The + image builds a Next.js *standalone* bundle (`/calcom/apps/web/.next/standalone` exists at this + tag) and a standalone server binds `process.env.HOSTNAME`, which the platform sets to the replica + name — the `langfuse` `502 Unable to connect to upstream workload` failure. At v6.2.0 the + container runs `next start` instead, which ignores `HOSTNAME`: verified by running the pinned + image with `HOSTNAME` set to a replica-shaped name and getting `Local: http://localhost:3000` + plus a 200 on `127.0.0.1:3000/api/version`. The variable is a no-op today and one upstream line + away from being essential, and the whole private-first story depends on loopback. The API accepts + and stores it verbatim (probed 2026-08-31; only `CPLN_`-prefixed names are reserved). +- **First boot is slow by design** — the container rewrites built assets when the public URL + differs from the baked one, waits for the database, runs `prisma migrate deploy`, and seeds the + app store, all before the HTTP server starts. Probes are budgeted to ~330 s to ready; do not read + a slow first boot as a fault before that. The private default is the *fast* path: the baked + `BUILT_NEXT_PUBLIC_WEBAPP_URL` is `http://localhost:3000` (read out of the pinned image), so + `replace-placeholder.sh` prints "Nothing to replace" and skips the rewrite entirely. +- **Enterprise-gated / not shipped**: organizations, SAML/SSO and the v2 API. The first two are + build-ARG-only in the official prebuilt image or licence-gated; the v2 API is a separate image + with its own Redis dependency. Core scheduling, teams, workflows and booking pages are not gated. +- **A firewall flip takes ~30 s to ~10 minutes to propagate.** After + `--set publicAccess.enabled=true`, keep re-polling; the catalog's measured high-water mark is + 559 s. +- **The first `helm upgrade` after an install may re-apply the bundled Postgres** and bounce it for + a minute or two; at `replicas: 1` that is a visible app outage. It is probabilistic, not a chart + defect. +- **Upstream is mid-rebrand.** `cal.com/docs/self-hosting/docker` now redirects to `cal.diy/docker` + and names `calcom/cal.diy` — a Docker Hub repository with **zero tags and zero pulls**, and + `github.com/calcom/cal.com` redirects to `calcom/cal.diy`. Designing from the docs would have + pinned an image that does not exist. We ship `calcom/cal.com:v6.2.0` (4.7M pulls); expect the + image line to move, and re-check the repository name at the next version bump. diff --git a/calcom/icon.png b/calcom/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..196f6573b58c845b2ca3ab5361d105da9207be40 GIT binary patch literal 16104 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 J!kZ>f{tw20KMMc= literal 0 HcmV?d00001 diff --git a/calcom/versions/1.0.0/Chart.yaml b/calcom/versions/1.0.0/Chart.yaml new file mode 100644 index 00000000..f056376d --- /dev/null +++ b/calcom/versions/1.0.0/Chart.yaml @@ -0,0 +1,26 @@ +apiVersion: v2 +name: calcom +description: Self-hosted Cal.com scheduling and booking pages with a PostgreSQL backing store +type: application +version: 1.0.0 +appVersion: "6.2.0" + +dependencies: + - name: cpln-common + version: 1.0.0 + repository: "oci://ghcr.io/controlplane-com/templates" + - name: postgres + version: 3.4.1 + repository: "oci://ghcr.io/controlplane-com/templates" + condition: postgres.enabled + - name: postgres-highly-available + alias: postgresHA + version: 2.7.0 + repository: "oci://ghcr.io/controlplane-com/templates" + condition: postgresHA.enabled + +annotations: + created: "2026-08-31" + lastModified: "2026-08-31" + category: "scheduling" + createsGvc: false diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md new file mode 100644 index 00000000..eb67716b --- /dev/null +++ b/calcom/versions/1.0.0/README.md @@ -0,0 +1,225 @@ +# Cal.com + +This app deploys [Cal.com](https://cal.com/) — self-hosted scheduling and booking pages, the open-source Calendly alternative (AGPL-3.0). It runs the stateless Cal.com app tier on PostgreSQL, plus a small always-on caller for Cal.com's scheduled jobs, which nothing inside the container drives. The install is **private by default**: you claim the admin account over a port-forward first, then publish. + +## Architecture + +- **Cal.com app**: Stateless `standard` workload on port 3000 — UI, booking pages and the whole `/api/*` surface. Applies its Prisma migrations at boot, before it starts serving. `replicas: 1` by default; `≥2` is an always-on scaled tier for zero-downtime rolling restarts (replicas share only the database and the auth secret — there is no clustering). +- **Scheduled-job caller**: A second `standard` workload, 1 replica, running the same image with the entrypoint replaced by a bounded `wget` loop that drives the seven cron paths Cal.com's own cloud runs. Optional (`cron.enabled`), on by default. +- **PostgreSQL (single-instance, default)**: The `postgres` template — every piece of Cal.com's state, including avatars. +- **PostgreSQL (HA, optional)**: The `postgres-highly-available` template instead — 3 Patroni replicas with automatic failover behind an HAProxy leader endpoint. +- **Database credentials secret**: A `dictionary` secret holding the bundled database's `username`, `password` and `database`, built by this template from `postgres.database` and `postgres.credentials.*` and handed to the store by name. Nothing for you to create. +- **Identity and policy**: One identity for both workloads, and a least-privilege policy granting `reveal` on exactly the secrets they mount — your auth secret, the database credentials, and the SMTP secret only if you configure one. + +No volume is attached to the app: upstream's own compose file mounts none, and all state is in Postgres. + +## Prerequisites + +**One `dictionary` secret must exist BEFORE you install.** Its values never pass through Helm values, so they never land in the release. A missing secret wedges the deployment almost silently — see Important Notes. + +**Auth secret** (`calcom.auth.secretName`) — four keys: + +```bash +cpln secret create-dictionary --name my-calcom-auth \ + --entry nextAuthSecret="$(openssl rand -base64 32)" \ + --entry encryptionKey="$(openssl rand -base64 24)" \ + --entry cronSecret="$(openssl rand -hex 32)" \ + --entry cronApiKey="$(openssl rand -hex 32)" +``` + +| Key | What it is | +|---|---| +| `nextAuthSecret` | Signs every session token — anyone holding it can forge a login | +| `encryptionKey` | `CALENDSO_ENCRYPTION_KEY`; encrypts stored calendar and app credentials. **Must be exactly 32 characters** (`openssl rand -base64 24` produces exactly that) and cannot be changed later without orphaning every connected calendar | +| `cronSecret` | Guards the scheduled-job endpoints. Not optional — see Important Notes | +| `cronApiKey` | The raw-header form Cal.com's legacy cron routes accept | + +Nothing else is required for a default install. **The database password is not a prerequisite** — it is bundled plumbing, so this template creates that secret for you from `postgres.database` and `postgres.credentials.*`. + +Optional: a `dictionary` secret with `EMAIL_SERVER_USER` and `EMAIL_SERVER_PASSWORD` if your SMTP relay needs authentication; a cloud account + bucket (AWS/GCP) or a MinIO credentials secret if you turn backups on (see **Backup setup**). + +## Configuration + +### Application + +```yaml +calcom: + image: calcom/cal.com:v6.2.0 + replicas: 1 # 1 = proven single-instance; 2+ = always-on, zero-downtime restarts + # Public base URL for booking links, emails and auth redirects. Empty = derived + # (canonical endpoint when public, otherwise http://localhost:3000 for the + # port-forward first run). Set WITH a scheme only for a custom domain. + appUrl: "" + resources: + minCpu: 500m + maxCpu: 2 + minMemory: 1Gi + maxMemory: 4Gi # boot runs migrations + the app-store seed + auth: + # dictionary secret with nextAuthSecret, encryptionKey, cronSecret, cronApiKey. + # MUST EXIST BEFORE INSTALL — see Prerequisites. + secretName: my-calcom-auth +``` + +### Scheduled jobs + +```yaml +cron: + enabled: true # false = nothing drives Cal.com's scheduled endpoints + resources: + cpu: 100m + memory: 128Mi +``` + +Cal.com ships no in-process scheduler; its cloud drives seven HTTP paths on a timer. Leave this on unless you drive those paths yourself — see Important Notes. + +### Email + +```yaml +email: + enabled: false # true = Cal.com can actually send mail + fromAddress: no-reply@example.com # EMAIL_FROM + fromName: Cal.com # EMAIL_FROM_NAME + host: smtp.example.com # EMAIL_SERVER_HOST + port: 587 # EMAIL_SERVER_PORT + auth: + # optional dictionary secret with EMAIL_SERVER_USER and EMAIL_SERVER_PASSWORD; + # "" = unauthenticated relay + secretName: "" +``` + +### Access + +```yaml +publicAccess: + enabled: false # true = booking pages and UI on the canonical *.cpln.app endpoint + +internalAccess: + type: same-gvc # options: none, same-gvc, same-org, workload-list + workloads: [] # only for workload-list; this release's own workloads are added for you +``` + +### Backing store + +Exactly one of the two stores must be enabled (the chart enforces this at render). Set `postgresHA.enabled: true` for near-zero-downtime failover. + +```yaml +postgres: # default: single-instance PostgreSQL + enabled: true + image: postgres:18 + database: calcom # database name (not a credential) + credentials: # this template builds the DB credential secret from these + username: calcom + password: change-me-calcom-db # change before installing + config: + # name of the dictionary secret this template CREATES and Postgres reads; + # secret names are org-wide, so give each calcom release its own + credentialsSecretName: my-calcom-db-credentials + volumeset: + capacity: 10 # GiB + backup: + enabled: false # true = scheduled backups to object storage + provider: aws # aws | gcp | minio + aws: + bucket: my-calcom-bucket + region: us-east-1 + cloudAccountName: my-s3-cloud-account + policyName: my-calcom-backup-policy +``` + +```yaml +postgres: + enabled: false +postgresHA: # 3-replica Patroni store with an HAProxy leader endpoint + enabled: true + config: + credentialsSecretName: my-calcom-db-credentials # keep equal to postgres.config.credentialsSecretName + replicas: 3 + volumeset: + capacity: 10 # GiB per replica + backup: + enabled: false + mode: logical # logical | wal-g + provider: aws # aws | gcp | minio +``` + +## First run + +A fresh Cal.com database has no owner, and `/auth/setup` grants instance-admin to whoever completes it first. So the install starts closed: + +1. Install with the defaults (`publicAccess.enabled: false`). +2. Open a tunnel — it goes through Control Plane infrastructure and is independent of the firewall: + ```bash + cpln port-forward {release}-calcom 3000:3000 --gvc {gvc} + ``` +3. Visit `http://localhost:3000/auth/setup` and create your admin user. Use `http://localhost:3000`, not an https name: the install advertises exactly that origin while private, so session cookies work. +4. Only then publish, if you want public booking pages: + ```bash + cpln helm upgrade {release} ./calcom/versions/1.0.0 --gvc {gvc} --set publicAccess.enabled=true + ``` + +A firewall change takes anywhere from ~30 s to ~10 minutes to propagate — re-poll the public URL rather than trusting the first response. + +## Connecting + +| What | Value | +|---|---| +| Local access (public access off) | `cpln port-forward {release}-calcom 3000:3000 --gvc {gvc}`, then `http://localhost:3000` | +| Public URL | `status.canonicalEndpoint` from `cpln workload get {release}-calcom --gvc {gvc} -o yaml` — only when `publicAccess.enabled: true` | +| First-run wizard | `/auth/setup` | +| Login | `/auth/login` | +| Booking page | `/{username}/{event-type}` | +| Health / version | `/api/version` — unauthenticated, returns `{"version":"6.2.0"}` | +| In-GVC (internal) | `http://{release}-calcom.{gvc}.cpln.local:3000` — subject to `internalAccess.type` | +| Auth secrets | the four keys of the secret named by `calcom.auth.secretName`; never in the Helm release | +| Database credentials | the `username` / `password` / `database` keys of the secret named by `postgres.config.credentialsSecretName` | + +## Backup setup (only if you enable backups) + +Backups are off by default and need no cloud account. To turn them on, set `.backup.enabled: true` (where `` is `postgres` or `postgresHA`) and configure a provider. The backup runs inside the backing Postgres store, so this is that template's own setup. + +**AWS S3** — create the bucket, a Control Plane [cloud account](https://docs.controlplane.com/guides/create-cloud-account), and an IAM policy scoped to that bucket: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::my-calcom-bucket" }, + { "Effect": "Allow", "Action": ["s3:PutObject","s3:GetObject","s3:DeleteObject"], "Resource": "arn:aws:s3:::my-calcom-bucket/*" } + ] +} +``` + +Then set `provider: aws` and `aws.{bucket,region,cloudAccountName,policyName}`. + +**GCP Cloud Storage** — create the bucket and a cloud account, grant its service account **Storage Object Admin** (`roles/storage.objectAdmin`) on the bucket, then set `provider: gcp` and `gcp.{bucket,cloudAccountName}`. + +**MinIO / S3-compatible** — no cloud account needed, but the keys are a prerequisite secret: + +```bash +cpln secret create-dictionary --name my-calcom-minio-credentials \ + --entry accessKey=MINIO_ACCESS_KEY \ + --entry secretKey=MINIO_SECRET_KEY +``` + +Then set `provider: minio`, `minio.{endpoint,bucket}` and `minio.credentialsSecretName`. + +## Important Notes + +- **A missing `calcom.auth.secretName` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines, because the container never starts. The only place the missing secret is named is `cpln workload get-deployments {release}-calcom --gvc {gvc} -o yaml` → `status.versions[].message`. Create the secret and it recovers on its own in roughly 6–10 minutes, or immediately with `cpln workload force-redeployment {release}-calcom --gvc {gvc}`. +- **`encryptionKey` is write-once.** It encrypts every stored calendar and app credential, so changing it orphans all of them. Rotating any key in that secret also requires a forced redeployment — a `cpln://` reference is resolved when the replica starts and nothing re-resolves it while the replica lives, so the old value keeps working with no error until you redeploy. +- **Do not remove the cron workload unless you drive its endpoints yourself.** Cal.com has no in-process scheduler: with nothing calling `/api/tasks/cron` and friends, the task queue (which carries queued outbound email), calendar-subscription sync and credential refresh never run — and every health surface still reads green. `cron.enabled` also requires `internalAccess.type` other than `none`; the chart refuses that combination. +- **Self-service signup cannot be turned off with a values setting.** Cal.com reads `NEXT_PUBLIC_DISABLE_SIGNUP`, and every `NEXT_PUBLIC_*` value is compiled in when the image is *built*, so setting it at runtime does nothing. The working path is the database-backed feature flag `disable-signup` under **Settings → Admin → Features**, after your admin account exists. Claim the admin account before turning public access on. +- **Turn `email.enabled` on before you invite anyone.** With no SMTP server Cal.com falls back to a local `sendmail` binary that does not exist in the image, so booking confirmations, invites and password resets are dropped silently. +- **Give each calcom release its own `postgres.config.credentialsSecretName`.** Secret names are org-wide, so a second release left on the default name is **refused at install** — `cannot be updated because it is being managed by a different release`. Nothing is shared or overwritten; you simply cannot install the second until you rename. +- **Organizations, SAML/SSO and the v2 API are not shipped.** The first two are build-time-only in the official image or licence-gated; the v2 API is a separate image with its own Redis dependency. Core scheduling, teams, workflows and booking pages are not gated. +- **The first `helm upgrade` after an install may re-apply the bundled Postgres** and bounce it for a minute or two — including the upgrade that turns public access on. At `replicas: 1` that is a visible outage; later upgrades do not do this. +- **Database volumes survive reinstalls under the same release name; uninstalling deletes them** and every booking with them. Use `postgresHA` and/or the backup pass-through for production data. + +## Links + +- [Cal.com](https://cal.com/) +- [Self-hosting with Docker](https://cal.diy/docker) +- [Source and releases](https://github.com/calcom/cal.com/releases) +- [`.env.example` at v6.2.0 — every supported variable](https://github.com/calcom/cal.com/blob/v6.2.0/.env.example) +- [Published image](https://hub.docker.com/r/calcom/cal.com) diff --git a/calcom/versions/1.0.0/templates/_helpers.tpl b/calcom/versions/1.0.0/templates/_helpers.tpl new file mode 100644 index 00000000..d0eb6689 --- /dev/null +++ b/calcom/versions/1.0.0/templates/_helpers.tpl @@ -0,0 +1,233 @@ +{{/* Resource Naming */}} + +{{/* +Cal.com app workload name +*/}} +{{- define "calcom.name" -}} +{{- printf "%s-calcom" .Release.Name }} +{{- end }} + +{{/* +Cal.com scheduled-job caller workload name +*/}} +{{- define "calcom.cron.name" -}} +{{- printf "%s-calcom-cron" .Release.Name }} +{{- end }} + +{{/* +Cal.com identity name — shared by the app and the cron caller. Both need +`reveal` on exactly the same secrets, so a second identity would buy nothing but +a second policy target. Same shape as docmost and langfuse. +*/}} +{{- define "calcom.identity.name" -}} +{{- printf "%s-calcom-identity" .Release.Name }} +{{- end }} + +{{/* +Cal.com policy name +*/}} +{{- define "calcom.policy.name" -}} +{{- printf "%s-calcom-policy" .Release.Name }} +{{- end }} + + +{{/* Mode-aware Database Helpers */}} + +{{/* +Postgres host: the HAProxy leader-routing endpoint (HA mode) or the single +postgres workload (default). Names must match the dependency charts' own helpers +(pg-ha.proxy.name / postgres.name). + +ALWAYS the fully-qualified `.{gvc}.cpln.local` form. The bare short name is +workload-type dependent — it resolves for a `stateful` workload and NXDOMAINs +for a `standard` one — and this chart's app tier is standard. +*/}} +{{- define "calcom.db.host" -}} +{{- if .Values.postgresHA.enabled -}} +{{- printf "%s-postgres-ha-proxy.%s.cpln.local" .Release.Name .Values.global.cpln.gvc }} +{{- else -}} +{{- printf "%s-postgres.%s.cpln.local" .Release.Name .Values.global.cpln.gvc }} +{{- end }} +{{- end }} + +{{/* +Database name for the active backing store. +*/}} +{{- define "calcom.db.database" -}} +{{- .Values.postgres.database }} +{{- end }} + +{{/* +Name of the bundled database's credential secret in the DEFAULT (single-instance) +path. THIS chart creates that secret (secret-db.yaml) and the postgres subchart +receives only its NAME — since postgres 3.4.0 the subchart creates no secret of +its own. A subchart value cannot be templated by its parent, which is why the +name is a plain value both sides read rather than something derived from the +release name. +*/}} +{{- define "calcom.secret.db.name" -}} +{{- .Values.postgres.config.credentialsSecretName }} +{{- end }} + +{{/* +Credentials secret of the ACTIVE backing store. This chart creates it in both +paths: postgres 3.4.0+ and postgres-highly-available 2.5.0+ both stopped +creating a credentials secret and now take only its name, so the two stores are +fed the same secret, built from postgres.credentials.* — which stay values +because a bundled database serving only Cal.com is internal plumbing no human +types elsewhere. +*/}} +{{- define "calcom.db.secretName" -}} +{{- if .Values.postgresHA.enabled -}} +{{- .Values.postgresHA.config.credentialsSecretName }} +{{- else -}} +{{- include "calcom.secret.db.name" . }} +{{- end }} +{{- end }} + + +{{/* URLs */}} + +{{/* +The public base URL Cal.com advertises — NEXT_PUBLIC_WEBAPP_URL and NEXTAUTH_URL +both take this one value. + +NEVER assembled from parts: there is no CPLN_ORG_ALIAS and the canonical +hostname's shape is not stable between GVCs, so any hand-built name is a coin +flip. The three branches: + + appUrl set -> that value verbatim (a custom domain; scheme required, + and calcom.validate rejects one without a scheme — + prepending https:// to an already-absolute URL is how a + template once shipped `https://https://`). + publicAccess on -> "$(CPLN_GLOBAL_ENDPOINT)", the platform's own env + interpolation. It is ALREADY a full https:// URL. + otherwise -> http://localhost:3000. + +The private branch is deliberate and is not a placeholder. In the private state +the only route to the UI is `cpln port-forward`, so the browser's origin really +is http://localhost:3000; pointing NEXTAUTH_URL at an https:// name there would +make NextAuth set `Secure` session cookies that a browser on plain http refuses +to store, and /auth/setup would appear to accept the form and then fail to log +the user in. It is also the value baked into the image +(BUILT_NEXT_PUBLIC_WEBAPP_URL, verified in the pinned tag), so start.sh's +replace-placeholder.sh prints "Nothing to replace" and the private install skips +a full rewrite of the built assets. +*/}} +{{- define "calcom.appUrl" -}} +{{- if .Values.calcom.appUrl -}} +{{- .Values.calcom.appUrl -}} +{{- else if .Values.publicAccess.enabled -}} +$(CPLN_GLOBAL_ENDPOINT) +{{- else -}} +http://localhost:3000 +{{- end }} +{{- end }} + +{{/* +Where the cron caller reaches the app: internal service DNS on the CONTAINER +port, never the canonical endpoint. + +CPLN_GLOBAL_ENDPOINT is PER-WORKLOAD, so a non-primary tier reading it raw +advertises its own inbound-less endpoint — the shipped dead-link defect in +chatwoot and twenty. Fully qualified for the reason in calcom.db.host. +*/}} +{{- define "calcom.internalUrl" -}} +{{- printf "http://%s.%s.cpln.local:3000" (include "calcom.name" .) .Values.global.cpln.gvc }} +{{- end }} + + +{{/* Topology — internal firewall roster */}} + +{{/* +Every workload THIS RELEASE creates, as firewall links. + +The internal firewall list governs all inbound internal traffic including +tier-to-tier, so a `workload-list` naming only the user's clients would cut the +cron caller off from the app — and the symptom is the exact green-but-broken +state the cron workload exists to prevent. Written once, here, and included at +every call site: hand-listing per workload file is how that has drifted twice +elsewhere in the catalog. +*/}} +{{- define "calcom.ownWorkloadLinks" -}} +{{- $gvc := .Values.global.cpln.gvc -}} +- //gvc/{{ $gvc }}/workload/{{ include "calcom.name" . }} +{{- if .Values.cron.enabled }} +- //gvc/{{ $gvc }}/workload/{{ include "calcom.cron.name" . }} +{{- end }} +{{- end -}} + +{{/* +The complete `internal` firewall block. Emitting exactly ONE +`inboundAllowWorkload` key is the point — tidb shipped a duplicate key and the +trailing `[]` silently discarded the user's whole list. The key is ALWAYS +emitted, including as `[]` on the non-list types, because a PARTIAL `internal` +block makes the API complete it and the workload drifts from its own manifest +from creation. +*/}} +{{- define "calcom.internalFirewall" -}} +inboundAllowType: {{ .Values.internalAccess.type }} +{{- if eq .Values.internalAccess.type "workload-list" }} +{{- $own := splitList "\n" (trim (include "calcom.ownWorkloadLinks" .)) }} +inboundAllowWorkload: + {{- include "calcom.ownWorkloadLinks" . | nindent 2 }} + {{- range .Values.internalAccess.workloads }} + {{- if not (has (printf "- %s" .) $own) }} + - {{ . }} + {{- end }} + {{- end }} +{{- else }} +inboundAllowWorkload: [] +{{- end }} +{{- end -}} + + +{{/* Validation */}} + +{{- define "calcom.validate" -}} +{{- $replicas := int .Values.calcom.replicas -}} +{{- if lt $replicas 1 -}} +{{- fail "calcom: calcom.replicas must be at least 1" -}} +{{- end -}} +{{- if and .Values.postgres.enabled .Values.postgresHA.enabled -}} +{{- fail "calcom: enable exactly one backing store — set either postgres.enabled or postgresHA.enabled to true, not both" -}} +{{- end -}} +{{- if and (not .Values.postgres.enabled) (not .Values.postgresHA.enabled) -}} +{{- fail "calcom: enable exactly one backing store — postgres.enabled (default) or postgresHA.enabled (Patroni HA)" -}} +{{- end -}} +{{- if and .Values.postgresHA.enabled (not (dig "proxy" "enabled" true .Values.postgresHA)) -}} +{{- fail "calcom: postgresHA.proxy.enabled must remain true — Cal.com reaches the leader through the HAProxy endpoint for writes and for its boot-time Prisma migrations" -}} +{{- end -}} +{{- if not .Values.calcom.auth.secretName -}} +{{- fail "calcom: calcom.auth.secretName is required — the name of a `dictionary` secret that must EXIST BEFORE INSTALL, holding nextAuthSecret, encryptionKey, cronSecret and cronApiKey. Create it with: cpln secret create-dictionary --name my-calcom-auth --entry nextAuthSecret=\"$(openssl rand -base64 32)\" --entry encryptionKey=\"$(openssl rand -base64 24)\" --entry cronSecret=\"$(openssl rand -hex 32)\" --entry cronApiKey=\"$(openssl rand -hex 32)\"" -}} +{{- end -}} +{{- if not (has .Values.internalAccess.type (list "none" "same-gvc" "same-org" "workload-list")) -}} +{{- fail (printf "calcom: internalAccess.type must be one of none, same-gvc, same-org, workload-list (got %q)" .Values.internalAccess.type) -}} +{{- end -}} +{{- if and .Values.cron.enabled (eq .Values.internalAccess.type "none") -}} +{{- fail "calcom: cron.enabled requires internalAccess.type other than `none` — the cron caller's requests arrive on the app's internal inbound and would be dropped, so every scheduled job would silently never run while the app still reported healthy. Set internalAccess.type (same-gvc is the default) or set cron.enabled: false and drive the endpoints yourself" -}} +{{- end -}} +{{- if .Values.email.enabled -}} +{{- if not .Values.email.host -}} +{{- fail "calcom: email.host is required when email.enabled is true" -}} +{{- end -}} +{{- if not .Values.email.fromAddress -}} +{{- fail "calcom: email.fromAddress is required when email.enabled is true — Cal.com sends nothing without EMAIL_FROM" -}} +{{- end -}} +{{- end -}} +{{- if .Values.calcom.appUrl -}} +{{- if not (or (hasPrefix "http://" .Values.calcom.appUrl) (hasPrefix "https://" .Values.calcom.appUrl)) -}} +{{- fail (printf "calcom: calcom.appUrl must include a scheme, e.g. https://cal.example.com (got %q)" .Values.calcom.appUrl) -}} +{{- end -}} +{{- end -}} +{{- end }} + + +{{/* Labeling */}} + +{{/* +Common tags +*/}} +{{- define "calcom.tags" -}} +{{- include "cpln-common.tags" . }} +{{- end }} diff --git a/calcom/versions/1.0.0/templates/identity.yaml b/calcom/versions/1.0.0/templates/identity.yaml new file mode 100644 index 00000000..f690028c --- /dev/null +++ b/calcom/versions/1.0.0/templates/identity.yaml @@ -0,0 +1,5 @@ +kind: identity +name: {{ include "calcom.identity.name" . }} +description: Cal.com workload identity +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "calcom.tags" . | nindent 4 }} diff --git a/calcom/versions/1.0.0/templates/policy.yaml b/calcom/versions/1.0.0/templates/policy.yaml new file mode 100644 index 00000000..c3a71d9c --- /dev/null +++ b/calcom/versions/1.0.0/templates/policy.yaml @@ -0,0 +1,20 @@ +kind: policy +name: {{ include "calcom.policy.name" . }} +description: Cal.com policy +tags: {{- include "calcom.tags" . | nindent 4 }} +bindings: + - permissions: + - reveal + principalLinks: + - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "calcom.identity.name" . }} +targetKind: secret +targetLinks: + {{- /* Exactly the secrets this release's workloads mount — the user's auth + secret, the chart-created database credentials, and the SMTP secret only + when that optional feature names one. No cloud-account link, no + `target: all`. */}} + - //secret/{{ .Values.calcom.auth.secretName }} + - //secret/{{ include "calcom.db.secretName" . }} + {{- if and .Values.email.enabled .Values.email.auth.secretName }} + - //secret/{{ .Values.email.auth.secretName }} + {{- end }} diff --git a/calcom/versions/1.0.0/templates/secret-db.yaml b/calcom/versions/1.0.0/templates/secret-db.yaml new file mode 100644 index 00000000..46b074d4 --- /dev/null +++ b/calcom/versions/1.0.0/templates/secret-db.yaml @@ -0,0 +1,21 @@ +{{- if or .Values.postgres.enabled .Values.postgresHA.enabled }} +{{- /* + Credentials for the BUNDLED database. Internal plumbing: no human ever types + this password — it exists only so Cal.com can reach its own Postgres — so the + bundled-plumbing exception applies and it stays a template-created secret + rather than a prerequisite the user has to create. postgres 3.4.0 and + postgres-highly-available 2.5.0 both stopped creating this secret and now take + only its NAME, which is why the chart on this side of the dependency owns it. + + The three keys are named exactly as both subcharts read them. +*/}} +kind: secret +name: {{ include "calcom.db.secretName" . }} +description: Cal.com bundled PostgreSQL credentials +tags: {{- include "calcom.tags" . | nindent 4 }} +type: dictionary +data: + username: {{ .Values.postgres.credentials.username | quote }} + password: {{ .Values.postgres.credentials.password | quote }} + database: {{ .Values.postgres.database | quote }} +{{- end }} diff --git a/calcom/versions/1.0.0/templates/workload-cron.yaml b/calcom/versions/1.0.0/templates/workload-cron.yaml new file mode 100644 index 00000000..55b18a9b --- /dev/null +++ b/calcom/versions/1.0.0/templates/workload-cron.yaml @@ -0,0 +1,123 @@ +{{- if .Values.cron.enabled }} +{{- include "calcom.validate" . }} +kind: workload +name: {{ include "calcom.cron.name" . }} +description: Cal.com scheduled-job caller +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "calcom.tags" . | nindent 4 }} +spec: + {{- /* Cal.com runs NO scheduler inside the container: upstream's own cloud + drives seven HTTP paths from apps/web/vercel.json. A self-hosted + instance with no caller never processes its task queue (which carries + queued outbound email), never refreshes calendar credentials and never + syncs calendar subscriptions — while every health surface reads green. + That is why this ships enabled rather than as an opt-in extra. + + Deliberately ONE replica: two callers would double every scheduled run + and these endpoints are not all idempotent under concurrency. */}} + type: standard + identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "calcom.identity.name" . }} + containers: + - name: cron + {{- /* The SAME image as the app, with the entrypoint overridden so none of + start.sh runs — no second Prisma migration racer, no app-store seed. + The runner stage installs wget explicitly, so the one binary this + loop depends on is verified at the pinned tag (GNU Wget 1.21.3, + probed 2026-08-31); a second image would add a registry, a tag and a + review surface for a sleep+wget loop. Depending on the binary rather + than the image's own entrypoint follows the pocketbase precedent. */}} + image: {{ .Values.calcom.image }} + inheritEnv: false + cpu: {{ .Values.cron.resources.cpu | quote }} + memory: {{ .Values.cron.resources.memory | quote }} + command: /bin/sh + args: + - -c + - | + # `set -u` only. NOT `set -e`: `expr N % 5` exits 1 whenever the result + # is 0, which is exactly the tick we act on. + set -u + + # Every call is bounded. GNU wget's retry machinery is invisible against + # an NXDOMAIN control and costs ~200 s per call against a hanging server + # (measured on clickhouse), so an unbounded loop here would stall the + # whole schedule silently. + call() { + out=`wget -O /dev/null -S -T 30 -t 1 --header="Authorization: Bearer $CRON_SECRET" "$CALCOM_URL$1" 2>&1` + code=`echo "$out" | awk '/HTTP\/1/ {c=$2} END {print c}'` + echo "calcom-cron $1 -> ${code:-unreachable}" + } + + # One 60 s tick drives every cadence in apps/web/vercel.json@v6.2.0: + # every tick -> /api/tasks/cron (* * * * *) + # every 5 -> calendar-subscriptions, credentials, + # selected-calendars (*/5 * * * *) + # every 720 -> queuedFormResponseCleanup (0 */12 * * *) + # every 1440 -> tasks/cleanup, calendar-subscriptions-cleanup (daily) + tick=0 + while true; do + tick=`expr $tick + 1` + call /api/tasks/cron + if [ `expr $tick % 5` -eq 0 ]; then + call /api/cron/calendar-subscriptions + call /api/cron/credentials + call /api/cron/selected-calendars + fi + if [ `expr $tick % 720` -eq 0 ]; then + call /api/cron/queuedFormResponseCleanup + fi + if [ $tick -ge 1440 ]; then + call /api/tasks/cleanup + call /api/cron/calendar-subscriptions-cleanup + tick=0 + fi + sleep 60 + done + env: + {{- /* Internal service DNS on the container port. NOT + CPLN_GLOBAL_ENDPOINT: that variable is per-workload, so this tier + would advertise its own inbound-less endpoint (the shipped + dead-link defect in chatwoot and twenty). */}} + - name: CALCOM_URL + value: {{ include "calcom.internalUrl" . | quote }} + {{- /* One `Authorization: Bearer $CRON_SECRET` header satisfies both + families: /api/tasks/* accepts only that form, /api/cron/* accepts + it or the raw key. CRON_API_KEY is provisioned so an operator can + drive the legacy routes by hand. */}} + - name: CRON_SECRET + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.cronSecret' + - name: CRON_API_KEY + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.cronApiKey' + {{- /* No probes: there is no port and no endpoint to probe. A dead loop + shows up as a gap in the app's own access log for /api/tasks/cron. */}} + defaultOptions: + autoscaling: + maxConcurrency: 0 + maxScale: 1 + metric: disabled + minScale: 1 + scaleToZeroDelay: 300 + target: 100 + capacityAI: false + debug: false + suspend: false + timeoutSeconds: 30 + firewallConfig: + external: + {{- /* Outbound to the internet is not needed — every call this workload + makes stays on the internal mesh — and nothing may reach it. */}} + inboundAllowCIDR: [] + inboundBlockedCIDR: [] + outboundAllowCIDR: [] + outboundAllowHostname: [] + outboundAllowPort: [] + outboundBlockedCIDR: [] + internal: + inboundAllowType: none + inboundAllowWorkload: [] + {{- /* rolloutOptions and loadBalancer are omitted deliberately: measured + 2026-08-31, the API backfills NEITHER when the block is absent, so + omitting them is drift-free. supportDynamicTags IS backfilled, so it is + declared. */}} + supportDynamicTags: false +{{- end }} diff --git a/calcom/versions/1.0.0/templates/workload.yaml b/calcom/versions/1.0.0/templates/workload.yaml new file mode 100644 index 00000000..297b293e --- /dev/null +++ b/calcom/versions/1.0.0/templates/workload.yaml @@ -0,0 +1,196 @@ +{{- include "calcom.validate" . }} +kind: workload +name: {{ include "calcom.name" . }} +description: Cal.com scheduling application +gvc: {{ .Values.global.cpln.gvc }} +tags: {{- include "calcom.tags" . | nindent 4 }} +spec: + {{- /* `standard`, not `stateful`: the app writes nothing to disk (upstream's + own compose file mounts no volume on this service and avatars live in + Postgres), needs no stable replica index, and a standard tier both + RETAINS rolloutOptions.maxUnavailableReplicas and escapes the 4:1 + cpu:minCpu cap that applies to stateful workloads. */}} + type: standard + identityLink: //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "calcom.identity.name" . }} + containers: + - name: calcom + image: {{ .Values.calcom.image }} + inheritEnv: false + minCpu: {{ .Values.calcom.resources.minCpu | quote }} + minMemory: {{ .Values.calcom.resources.minMemory | quote }} + cpu: {{ .Values.calcom.resources.maxCpu | quote }} + memory: {{ .Values.calcom.resources.maxMemory | quote }} + env: + {{- /* DO NOT REMOVE AS REDUNDANT. + + A Next.js STANDALONE server binds `process.env.HOSTNAME || + "0.0.0.0"`, and Control Plane sets HOSTNAME to the replica name — + which makes the app listen on the pod IP only and returns + `502 Unable to connect to upstream workload` through + `cpln port-forward` (measured on langfuse). + + At calcom/cal.com:v6.2.0 that trap does NOT fire: scripts/start.sh + ends with `yarn start` -> `next start`, and next 16.1.5's + packages/next/src/cli/next-start.ts never reads process.env.HOSTNAME. + Confirmed by running the pinned image with HOSTNAME set to a + replica-shaped name: it logged `Local: http://localhost:3000` and + served 200 on 127.0.0.1:3000/api/version. + + But the image DOES build a standalone bundle + (/calcom/apps/web/.next/standalone exists at this tag), so upstream + is one line away from arming it — and this template's whole first-run + story is `publicAccess: false` + port-forward, which depends on + loopback. The variable is a no-op under `next start` and costs + nothing. The API accepts and stores it verbatim (probed 2026-08-31; + only CPLN_-prefixed names are reserved). */}} + - name: HOSTNAME + value: "0.0.0.0" + {{- /* The username and password land in their OWN env vars so the + platform resolves the cpln:// reference; DATABASE_URL is then + composed with $(VAR), the platform's own interpolation, which is + applied after the references resolve. A cpln:// reference placed + directly inside a Helm-assembled string is literal text — the + nocodb/polaris defect — so the host and database NAME, which are + not secret, are interpolated at render time instead. */}} + - name: DB_USER + value: 'cpln://secret/{{ include "calcom.db.secretName" . }}.username' + - name: DB_PASSWORD + value: 'cpln://secret/{{ include "calcom.db.secretName" . }}.password' + - name: DATABASE_URL + value: postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "calcom.db.host" . }}:5432/{{ include "calcom.db.database" . }} + {{- /* Prisma uses the DIRECT url for migrations (it must bypass a + connection pooler). Same target here — neither backing store puts + PgBouncer in this path by default. */}} + - name: DATABASE_DIRECT_URL + value: postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "calcom.db.host" . }}:5432/{{ include "calcom.db.database" . }} + {{- /* host:port, consumed by start.sh's wait-for-it.sh before migrations. + Left empty it prints "you need to provide a host and port to test" + and — because start.sh has `set -x` but not `set -e` — the boot + continues without the wait, turning a clear message into a + confusing Prisma error. Verified in the pinned image. */}} + - name: DATABASE_HOST + value: {{ printf "%s:5432" (include "calcom.db.host" .) | quote }} + - name: NEXT_PUBLIC_WEBAPP_URL + value: {{ include "calcom.appUrl" . | quote }} + - name: NEXTAUTH_URL + value: {{ include "calcom.appUrl" . | quote }} + - name: NEXTAUTH_SECRET + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.nextAuthSecret' + - name: CALENDSO_ENCRYPTION_KEY + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.encryptionKey' + {{- /* NOT optional. At v6.2.0, with CRON_SECRET unset the endpoints + compare the Authorization header against the literal string + "Bearer undefined", so any unauthenticated caller sending exactly + that header can trigger tasker cleanup and queue processing + (upstream issue #29565, unfixed at this tag). Measured on the + pinned image: no header -> 401, `Bearer undefined` -> 400, i.e. it + got past the auth check. There is no valid unset path. */}} + - name: CRON_SECRET + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.cronSecret' + - name: CRON_API_KEY + value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.cronApiKey' + {{- /* Read at runtime by packages/lib/telemetry.ts in the shipped image + ("1" drops the jitsu driver that posts to t.calendso.com). It is a + plain server-side read, not a NEXT_PUBLIC_* value, so unlike most + of Cal.com's build ARGs it genuinely takes effect at run time. */}} + - name: CALCOM_TELEMETRY_DISABLED + value: "1" + {{- if .Values.email.enabled }} + - name: EMAIL_FROM + value: {{ .Values.email.fromAddress | quote }} + - name: EMAIL_FROM_NAME + value: {{ .Values.email.fromName | quote }} + - name: EMAIL_SERVER_HOST + value: {{ .Values.email.host | quote }} + - name: EMAIL_SERVER_PORT + value: {{ .Values.email.port | quote }} + {{- if .Values.email.auth.secretName }} + - name: EMAIL_SERVER_USER + value: 'cpln://secret/{{ .Values.email.auth.secretName }}.EMAIL_SERVER_USER' + - name: EMAIL_SERVER_PASSWORD + value: 'cpln://secret/{{ .Values.email.auth.secretName }}.EMAIL_SERVER_PASSWORD' + {{- end }} + {{- end }} + ports: + - number: 3000 + protocol: http + {{- /* /api/version is unauthenticated, touches no database, and returns + {"version":"6.2.0"} — so the probe body doubles as proof the running + image is the tag the chart claims. There is NO /api/health route at + this tag; do not copy one from another template. + + start.sh only reaches `yarn start` after the asset rewrite, the + database wait, `prisma migrate deploy` and the app-store seed, so + "HTTP is listening" already implies "migrations completed". + + The long first boot is absorbed with periodSeconds, never by raising + failureThreshold past the platform cap of 20. */}} + readinessProbe: + httpGet: + path: /api/version + port: 3000 + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 15 + failureThreshold: 20 + successThreshold: 1 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/version + port: 3000 + scheme: HTTP + initialDelaySeconds: 300 + periodSeconds: 30 + failureThreshold: 10 + successThreshold: 1 + timeoutSeconds: 5 + defaultOptions: + autoscaling: + maxConcurrency: 0 + maxScale: {{ .Values.calcom.replicas }} + metric: disabled + minScale: {{ .Values.calcom.replicas }} + scaleToZeroDelay: 300 + target: 100 + capacityAI: false + debug: false + suspend: false + timeoutSeconds: 30 + firewallConfig: + {{- /* Fully declared, including the empty lists the API backfills: a partial + block is completed on apply and shows as drift from creation. */}} + external: + outboundAllowCIDR: + - 0.0.0.0/0 + {{- if .Values.publicAccess.enabled }} + inboundAllowCIDR: + - 0.0.0.0/0 + {{- else }} + inboundAllowCIDR: [] + {{- end }} + inboundBlockedCIDR: [] + outboundAllowHostname: [] + outboundAllowPort: [] + outboundBlockedCIDR: [] + internal: + {{- include "calcom.internalFirewall" . | nindent 6 }} + {{- /* No loadBalancer.direct block is needed: the only protocol is HTTP and + the platform's canonical endpoint serves it. Declared explicitly so the + rendered manifest matches what the API stores. */}} + loadBalancer: + direct: + enabled: false + ports: [] + replicaDirect: false + supportDynamicTags: false + rolloutOptions: + {{- /* maxUnavailableReplicas is RETAINED on a standard workload (the API + drops it silently on stateful ones), so it genuinely serializes the + rollout. terminationGracePeriodSeconds is declared because the API + backfills 90. */}} + maxSurgeReplicas: 0% + maxUnavailableReplicas: "1" + minReadySeconds: 10 + scalingPolicy: OrderedReady + terminationGracePeriodSeconds: 90 diff --git a/calcom/versions/1.0.0/values.yaml b/calcom/versions/1.0.0/values.yaml new file mode 100644 index 00000000..5bca4f94 --- /dev/null +++ b/calcom/versions/1.0.0/values.yaml @@ -0,0 +1,178 @@ +# ─── Cal.com application ────────────────────────────────────────────────────── +calcom: + image: calcom/cal.com:v6.2.0 + # 1 = the proven single-instance shape. 2+ = an always-on scaled tier for + # zero-downtime rolling restarts. Replicas never talk to each other; they share + # only the database and the auth secret below. + replicas: 1 + # Public base URL used for booking links, emails and auth redirects. Empty = + # derived: the platform canonical endpoint when publicAccess is on, otherwise + # http://localhost:3000 so the `cpln port-forward` first run works. Set WITH a + # scheme only for a custom domain, e.g. https://cal.example.com + appUrl: "" + resources: + minCpu: 500m + maxCpu: 2 + minMemory: 1Gi + maxMemory: 4Gi # boot runs migrations + app-store seed; an OOM kill is silent + auth: + # REQUIRED PREREQUISITE SECRET — CREATE IT BEFORE YOU INSTALL. If it does not + # exist the deployment WEDGES silently and `cpln logs` returns nothing; the + # missing secret is named only in `status.versions[].message`. + # A `dictionary` secret with exactly four keys: + # nextAuthSecret — signs session tokens (openssl rand -base64 32) + # encryptionKey — CALENDSO_ENCRYPTION_KEY, encrypts stored calendar and + # app credentials; MUST be exactly 32 characters and + # CANNOT be rotated without orphaning them all + # (openssl rand -base64 24) + # cronSecret — guards the scheduled-job endpoints (openssl rand -hex 32) + # cronApiKey — legacy cron header form (openssl rand -hex 32) + # See README Prerequisites for the exact command. + secretName: my-calcom-auth + +# ─── Scheduled jobs ─────────────────────────────────────────────────────────── +# Cal.com runs NO scheduler of its own: without this workload the task queue +# (which carries queued outbound email), calendar-subscription sync and +# credential refresh never run, and every health surface still reads green. +# Requires internalAccess.type other than `none`. +cron: + enabled: true + resources: + cpu: 100m + memory: 128Mi + +# ─── Email (optional) ───────────────────────────────────────────────────────── +# Off by default. With no SMTP server Cal.com falls back to a local sendmail +# binary that does not exist in the image, so booking confirmations, invites and +# password resets are silently dropped. Configure before inviting anyone. +email: + enabled: false + fromAddress: no-reply@example.com # EMAIL_FROM + fromName: Cal.com # EMAIL_FROM_NAME + host: smtp.example.com # EMAIL_SERVER_HOST + port: 587 # EMAIL_SERVER_PORT + auth: + # OPTIONAL prerequisite `dictionary` secret holding EMAIL_SERVER_USER and + # EMAIL_SERVER_PASSWORD, e.g. my-calcom-smtp. Empty = unauthenticated relay. + secretName: "" + +# ─── Access ─────────────────────────────────────────────────────────────────── +# A firewall change takes ~30 s to ~10 minutes to propagate — re-poll rather than +# concluding a knob is broken. +publicAccess: + # false = nothing reaches Cal.com from the internet. A fresh instance grants + # instance-admin to whoever completes /auth/setup first, and self-service + # signup cannot be turned off by configuration, so claim the admin account + # over `cpln port-forward` first and only then set this true. + enabled: false + +internalAccess: + type: same-gvc # options: none, same-gvc, same-org, workload-list + # Only used when type is workload-list. This release's OWN workloads (the app + # and, when it renders, the cron caller) are added automatically — the cron + # workload's calls arrive on this list, so one naming only your clients would + # silently stop every scheduled job. List your clients here; do not list this + # release's workloads. + workloads: [] + # workloads: + # - //gvc/GVC_NAME/workload/WORKLOAD_NAME + +# ─── Backing store: single-instance PostgreSQL (default) ────────────────────── +# set postgresHA.enabled: true (and postgres.enabled: false) for near-zero-downtime +# failover. Every knob of the `postgres` template is available under this key, +# including scheduled backups — see "Backup setup" in the README. +postgres: + enabled: true + image: postgres:18 + # Name of the database Cal.com uses. Not a credential: it is also interpolated + # into DATABASE_URL at render time, which a secret reference cannot be. + database: calcom + # Credentials for the bundled database — internal plumbing no human types + # elsewhere. This chart CREATES the dictionary secret named by + # config.credentialsSecretName out of these and postgres.database, so there is + # nothing for you to create before installing. + credentials: + username: calcom + password: change-me-calcom-db # used as-is — change before installing + config: + # Name of the dictionary secret this chart creates and the bundled postgres + # reads. Secret names are org-wide: give each calcom release its own name. A + # second release left on this name is REFUSED at install (it is owned by the + # first release) — nothing is shared, overwritten or deleted. + credentialsSecretName: my-calcom-db-credentials + resources: + minCpu: 250m + maxCpu: 1 + minMemory: 512Mi + maxMemory: 1Gi + volumeset: + capacity: 10 # initial capacity in GiB (minimum is 10) + # Scheduled backups of the Cal.com database to object storage (off by default). + # Pass-through to the postgres template's native backup — see README "Backup setup". + backup: + enabled: false + image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0 # 18.1.0 = PG18, 17.1.0 = PG17 + schedule: "0 2 * * *" # daily at 2am UTC + resources: + cpu: 100m + memory: 128Mi + provider: aws # aws, gcp, or minio + aws: + bucket: my-calcom-bucket + region: us-east-1 + cloudAccountName: my-s3-cloud-account + policyName: my-calcom-backup-policy + prefix: calcom/backups + gcp: + bucket: my-calcom-bucket + cloudAccountName: my-gcs-cloud-account + prefix: calcom/backups + minio: # any S3-compatible endpoint (e.g. an internal MinIO template) + endpoint: http://my-minio-workload:9000 + bucket: my-calcom-bucket + # REQUIRED prerequisite dictionary secret holding `accessKey` and `secretKey`, + # only when backup is enabled with provider: minio. See README "Backup setup". + credentialsSecretName: my-calcom-minio-credentials + prefix: calcom/backups + +# ─── Backing store: highly available PostgreSQL (optional) ──────────────────── +# Enable this AND set postgres.enabled: false. A Patroni-managed 3-replica store +# with automatic failover behind an HAProxy leader endpoint. +postgresHA: + enabled: false + config: + # Both stores are fed the SAME secret this chart builds from + # postgres.credentials.* above — keep this equal to + # postgres.config.credentialsSecretName. + credentialsSecretName: my-calcom-db-credentials + replicas: 3 + volumeset: + capacity: 10 # initial capacity in GiB per replica (minimum is 10) + # Pass-through to the postgres-highly-available template's native backup. + backup: + enabled: false + mode: logical # logical or wal-g + resources: + cpu: 100m + memory: 512Mi # 128Mi OOMKills wal-g mid-backup + logical: + image: ghcr.io/controlplane-com/backup-images/postgres-backup:17.1.0 + schedule: "0 2 * * *" # daily at 2am UTC + walg: + intervalSeconds: 21600 # every 6 hours + provider: aws # aws, gcp, or minio + aws: + bucket: my-calcom-bucket + region: us-east-1 + cloudAccountName: my-s3-cloud-account + policyName: my-calcom-backup-policy + prefix: calcom/backups + gcp: + bucket: my-calcom-bucket + cloudAccountName: my-gcs-cloud-account + prefix: calcom/backups + minio: + endpoint: http://my-minio-workload:9000 + bucket: my-calcom-bucket + credentialsSecretName: my-calcom-minio-credentials + prefix: calcom/backups From 5b5aec3d2afedf77053abca67a1c3e08ca9d750b Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:55:18 -0600 Subject: [PATCH 2/8] calcom: make the publish step installer-agnostic, guard the subchart firewall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, no blockers. 1. First run step 4 told the user to run an upgrade against a local `./calcom/versions/1.0.0` path, which does not exist for anyone who installed from the marketplace UI, and it omitted --dependency-update. The step is necessary — it is the only way to publish the app — so it stays, but it is now expressed as the values change it actually is: set publicAccess.enabled: true and upgrade the release, with the UI path and the CLI path both named. This follows the phrasing umami and open-webui already use for the same private- install-then-publish flow. Flags verified against `cpln helm upgrade --help`, not memory. 2. A user setting postgres.internalAccess.type to workload-list without listing this release's own workload cuts Cal.com off from its own database, and it fails as a boot hang (Prisma's migrate step blocks) rather than an error. A parent cannot inject into a subchart's list, so the chart now hard-fails at render, naming the exact link to add — mirroring the guard wordpress already has for its mariadb pass-through. type: none is refused outright. postgres-highly-available exposes no internalAccess knob, so that path needs no guard. Verified: bare `helm template --set global.cpln.gvc=test-gvc` renders byte-identical to before; both guard arms proven (bad list and type: none each fail with the new message, a list including the app still renders); lint carries the same two pre-existing R1 warnings about the abridged postgres README block and no new ones. Briefing updated with the new guard. --- briefings/calcom.md | 5 +++++ calcom/versions/1.0.0/README.md | 8 +++---- calcom/versions/1.0.0/templates/_helpers.tpl | 23 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/briefings/calcom.md b/briefings/calcom.md index e0395289..7c145a75 100644 --- a/briefings/calcom.md +++ b/briefings/calcom.md @@ -67,6 +67,11 @@ image is *built*, so setting it at runtime does nothing. The working path is the database-backed feature flag `disable-signup` in Settings → Admin → Features, after the admin account exists. This is why public access defaults to off. +- **Narrowing the bundled Postgres to a `workload-list` that omits the app is a boot hang, not an + error.** `postgres.internalAccess` is the subchart's own knob and a parent cannot inject into it, + so the chart hard-fails at render instead, naming the exact link to add; + `postgres.internalAccess.type: none` is refused outright. `postgres-highly-available` exposes no + such knob, so the HA path is unaffected. - **Whoever reaches `/auth/setup` first becomes the instance admin**, and there is no way to pre-create the owner from a secret (unlike keycloak or langfuse). Publishing the endpoint before finishing the wizard hands out admin. diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md index eb67716b..c5e1406b 100644 --- a/calcom/versions/1.0.0/README.md +++ b/calcom/versions/1.0.0/README.md @@ -153,10 +153,10 @@ A fresh Cal.com database has no owner, and `/auth/setup` grants instance-admin t cpln port-forward {release}-calcom 3000:3000 --gvc {gvc} ``` 3. Visit `http://localhost:3000/auth/setup` and create your admin user. Use `http://localhost:3000`, not an https name: the install advertises exactly that origin while private, so session cookies work. -4. Only then publish, if you want public booking pages: - ```bash - cpln helm upgrade {release} ./calcom/versions/1.0.0 --gvc {gvc} --set publicAccess.enabled=true - ``` +4. Only then publish, if you want public booking pages: set `publicAccess.enabled: true` and upgrade + the release. From the marketplace UI, edit the release's values and redeploy. From the CLI, run + `cpln helm upgrade` against the same chart you installed from, adding `--dependency-update` and + `--set publicAccess.enabled=true`. A firewall change takes anywhere from ~30 s to ~10 minutes to propagate — re-poll the public URL rather than trusting the first response. diff --git a/calcom/versions/1.0.0/templates/_helpers.tpl b/calcom/versions/1.0.0/templates/_helpers.tpl index d0eb6689..707914f0 100644 --- a/calcom/versions/1.0.0/templates/_helpers.tpl +++ b/calcom/versions/1.0.0/templates/_helpers.tpl @@ -207,6 +207,29 @@ inboundAllowWorkload: [] {{- if and .Values.cron.enabled (eq .Values.internalAccess.type "none") -}} {{- fail "calcom: cron.enabled requires internalAccess.type other than `none` — the cron caller's requests arrive on the app's internal inbound and would be dropped, so every scheduled job would silently never run while the app still reported healthy. Set internalAccess.type (same-gvc is the default) or set cron.enabled: false and drive the endpoints yourself" -}} {{- end -}} +{{- /* + Cal.com reaches PostgreSQL over the GVC's internal network, and the bundled + `postgres` subchart carries its OWN internalAccess knob that this chart cannot + inject into — a parent cannot template a subchart value. A workload-list that + omits this release's app workload cuts Cal.com off from its own database, and + it fails as a boot hang rather than an error: Prisma's migrate step blocks and + no surface names the firewall. A render-time fail is the only tool available, + so name the exact link the user has to add. Mirrors wordpress's mariadb guard. + (postgres-highly-available exposes no internalAccess knob, so the HA path has + nothing to guard.) +*/ -}} +{{- if and .Values.postgres.enabled .Values.postgres.internalAccess -}} +{{- $pg := .Values.postgres.internalAccess -}} +{{- $self := printf "//gvc/%s/workload/%s" .Values.global.cpln.gvc (include "calcom.name" .) -}} +{{- if eq ($pg.type | default "") "none" -}} +{{- fail "calcom: postgres.internalAccess.type must not be 'none' — Cal.com reaches the bundled database over the GVC internal network. Use 'same-gvc' (default) or 'workload-list' including this release's Cal.com workload" -}} +{{- end -}} +{{- if eq ($pg.type | default "") "workload-list" -}} +{{- if not (has $self ($pg.workloads | default list)) -}} +{{- fail (printf "calcom: postgres.internalAccess.type is 'workload-list' but the list does not include this release's Cal.com workload — add '%s', or the app cannot reach its own database" $self) -}} +{{- end -}} +{{- end -}} +{{- end -}} {{- if .Values.email.enabled -}} {{- if not .Values.email.host -}} {{- fail "calcom: email.host is required when email.enabled is true" -}} From 45ae2bec5882b8e1c6dbee76ec57235274a0347a Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 09:57:21 -0600 Subject: [PATCH 3/8] calcom: the backup IAM policy omitted the multipart actions The policy granted four actions; the `postgres` template's own README -- the same pass-through, the same backup image -- specifies ten. The three missing multipart actions (AbortMultipartUpload, ListBucketMultipartUploads, ListMultipartUploadParts) are the ones a large dump needs, so the policy works until a user's database grows past the multipart threshold and then fails. Failing only once there is real data to lose is the worst shape for a backup defect. Also adds GetBucketLocation and the two versioning actions, matching the authoritative list. Verified the block still parses as JSON. Co-Authored-By: Claude Opus 5 --- calcom/versions/1.0.0/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md index c5e1406b..009ecde4 100644 --- a/calcom/versions/1.0.0/README.md +++ b/calcom/versions/1.0.0/README.md @@ -184,8 +184,14 @@ Backups are off by default and need no cloud account. To turn them on, set ` Date: Mon, 31 Aug 2026 12:42:54 -0600 Subject: [PATCH 4/8] calcom 1.0.0: the DB wait never waited, and a multi-location GVC split the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found in live testing, both of which read GREEN on every status surface. Test-driven fixes on the unmerged 1.0.0, per CLAUDE.md. 1. BLOCKER — a fresh install could serve against an EMPTY database. The image's scripts/start.sh gates the boot on scripts/wait-for-it.sh, which at v6.2.0 is the eficode POSIX-sh variant testing readiness with `nc -w 1 -z`. The Control Plane mesh sidecar completes the TCP handshake for anything, so the wait returned success instantly in every case — proven in-container with three negative controls that all had to fail and did not: a dead port (9999), a nonexistent hostname, and the real port, all "up" in ~0 s. start.sh also runs `set -x` without `set -e`, so the `prisma migrate deploy` that then failed did not stop the boot; Cal.com served HTTP with ready: true, 200 on /api/version and 0 tables in information_schema. Intermittent by nature, and a fresh volumeset — the first-install case a new user hits — is what loses the race. Fix: mount our own /cpln/start.sh (opaque secret) and run it with /bin/bash, replacing the image entrypoint. It waits by sending a Postgres SSLRequest and requiring the one-byte S/N reply only a live backend produces, three times consecutively, for up to 600 s; then runs the migration under `set -e` so a failure crash-loops instead of serving. Follows keycloak 1.4.0 (protocol bytes, not a TCP connect) and grafana-multi-location 2.0.0 (consecutive successes). Verified against calcom/cal.com:v6.2.0 itself, not by inspection: - /bin/bash 5.2.15 present (/bin/sh is dash); /dev/tcp works; NO psql and NO pg_isready in the image (command -v exits 127); curl and python3 present. - gate: live Postgres -> READY; dead port -> NOT-READY; nonexistent host -> NOT-READY; accepts-but-never-replies (the sidecar's shape, and the arm that actually matters) -> NOT-READY, bounded at 6 s by `read -t 5`. - full boot with the new script: gate released after 3 consecutive OKs, migration applied 125 tables, `Ready in 1005ms`, /api/version -> 200. - negative control A: hanging server -> `[calcom] FATAL: ... did not answer the Postgres protocol`, container exit 1. - negative control B: gate passes, bad credentials -> `prisma migrate deploy` exits 1, container exit 1, `yarn start` never reached. /api/version stays the probe: v6.2.0 has no /api/health or equivalent under apps/web (checked against the full tag tree) and /auth/setup answers 307 against an empty database. The invariant is now enforced at boot instead, so "HTTP is listening implies migrations applied" is true by construction — it was an assumption before, and it was false. Liveness initialDelaySeconds 300 -> 780 so a slow first install is not killed mid-wait, which would hide the diagnostic. DATABASE_HOST is dropped: its only consumer in the whole image is start.sh (verified by grep), which no longer runs. 2. A multi-location GVC silently split Cal.com into N independent instances. A workload runs in every location its GVC has, and the chart declared no localOptions: a default install into a 3-location GVC produced three app replicas each bound by service DNS to its own local Postgres — three separate databases sharing one NEXTAUTH_SECRET, so a session validated against a database that did not contain the user. A table created in aws-us-east-1 did not exist in the other two. Fix: the CLAUDE.md confinement pattern — defaultOptions.minScale/maxScale 0 with a single localOptions entry for a new top-level `location` value (default aws-us-east-1), on both the app and the cron caller. Precedent: airflow 2.0.0. An unasked-for location now starts nothing by construction. The app also reads its own GVC at boot (new targetKind: gvc policy, scoped to the one install GVC) and warns when other locations exist, plus refuses to start if it ever finds itself outside `location`. NOT fully solved, and said so in the README, values and briefing: the bundled postgres / postgres-highly-available subcharts cannot be pinned — a parent cannot template a subchart's values and neither exposes a location knob (the same gap plane, docmost and metabase carry). In a multi-location GVC an empty database still starts in every location with its own volumeset: idle, never read, and billed. Data-splitting is fixed; cost sprawl is not. Verified: bare `helm template --set global.cpln.gvc=test-gvc` renders clean; multi-location and HA shapes render as intended; empty/plural `location` fail at render with actionable messages; template-lint --offline shows no FAIL (two pre-existing R1 WARNs on the how-to-switch-stores example blocks). Co-Authored-By: Claude Opus 5 --- briefings/calcom.md | 56 +++++- calcom/versions/1.0.0/README.md | 32 ++- calcom/versions/1.0.0/templates/_helpers.tpl | 76 +++++++ .../versions/1.0.0/templates/policy-gvc.yaml | 20 ++ calcom/versions/1.0.0/templates/policy.yaml | 1 + .../1.0.0/templates/secret-startup.yaml | 189 ++++++++++++++++++ .../1.0.0/templates/workload-cron.yaml | 15 +- calcom/versions/1.0.0/templates/workload.yaml | 80 ++++++-- calcom/versions/1.0.0/values.yaml | 22 ++ 9 files changed, 456 insertions(+), 35 deletions(-) create mode 100644 calcom/versions/1.0.0/templates/policy-gvc.yaml create mode 100644 calcom/versions/1.0.0/templates/secret-startup.yaml diff --git a/briefings/calcom.md b/briefings/calcom.md index 7c145a75..34c9b2c3 100644 --- a/briefings/calcom.md +++ b/briefings/calcom.md @@ -26,17 +26,21 @@ | subchart `postgres` 3.4.1 (default) / `postgres-highly-available` 2.7.0 (flag) | All state. Nothing else is persisted | | secret (dictionary, **user-created**) `my-calcom-auth` | `nextAuthSecret`, `encryptionKey`, `cronSecret`, `cronApiKey` | | secret (dictionary, chart-created) `my-calcom-db-credentials` | Bundled DB `username`/`password`/`database` | -| identity + policy | One identity for both workloads; `reveal` on exactly those secrets (plus the SMTP secret when configured) | +| secret (opaque, chart-created) `{release}-calcom-startup` | The app's boot script, mounted at `/cpln/start.sh` and run with `/bin/bash`. REPLACES the image entrypoint | +| identity + 2 policies | One identity for both workloads; `reveal` on exactly those secrets (plus the SMTP secret when configured), and `view` on the ONE install GVC for the boot-time location report | - **No volumeset on the app** — it is genuinely stateless (upstream's compose mounts no volume; avatars live in Postgres), which is what makes `replicas: 2+` work. - **Private by default.** First run is: install → `cpln port-forward` → `/auth/setup` → `helm upgrade --set publicAccess.enabled=true`. +- **Pinned to ONE location.** `defaultOptions.minScale/maxScale: 0` on both workloads, with a + single `localOptions` entry for `location`. An unasked-for GVC location starts nothing. ## Key knobs | Knob | Default | Note | |---|---|---| +| `location` | `aws-us-east-1` | The ONE GVC location Cal.com runs in. Must exist in the GVC or NOTHING starts | | `calcom.image` | `calcom/cal.com:v6.2.0` | Official image | | `calcom.replicas` | `1` | `2+` for zero-downtime rolling restarts; no clustering to form | | `calcom.appUrl` | `""` | Empty = derived; set only for a custom domain, **with** the scheme | @@ -48,6 +52,44 @@ ## Troubleshooting / considerations +- **The image's own `scripts/start.sh` CANNOT be used, and this is the defect that nearly shipped.** + It gates the boot on `scripts/wait-for-it.sh`, which at v6.2.0 is the eficode POSIX-sh variant + testing readiness with `nc -w 1 -z` — and the Control Plane mesh sidecar completes the TCP + handshake for anything. Proven in-container with three controls that all had to fail and did + not: a dead port (9999), a nonexistent hostname, and the real port, all "up" in ~0 s. `start.sh` + also runs `set -x` without `set -e`, so the `prisma migrate deploy` that then failed did not stop + the boot: Cal.com served HTTP against a schema-less database with `ready: true`, `200` on + `/api/version` and **0 tables**. It is a race, and a fresh volumeset — the first-install case — + is the one that loses it. The template now mounts its own `/cpln/start.sh`, which waits by + sending a **Postgres SSLRequest** (`\x00\x00\x00\x08\x04\xd2\x16\x2f`) and requiring the + one-byte `S`/`N` reply, three times consecutively, for up to 600 s, and runs under `set -e` so a + failed migration crash-loops instead of serving. Verified against the pinned image: live + Postgres → ready; dead port, nonexistent host and an accepts-but-never-replies server (the + sidecar's shape) → not ready, and the gate exits 1 with a named FATAL; a bad-credentials + migration exits 1 before `yarn start`. Do not "simplify" this back to the image entrypoint. + No probe can catch this instead: v6.2.0 has no `/api/health` or equivalent under `apps/web` and + `/auth/setup` answers 307 against an empty database, so "HTTP listening implies migrations + applied" is now true by construction rather than by assumption — it was false before. +- **Multi-location GVCs silently split Cal.com — half-fixed, and the half that is not is the + database.** A workload runs in EVERY location its GVC has, so before pinning, a default install + into a 3-location GVC produced three app replicas, each bound by service DNS to its own local + Postgres: three separate databases sharing one `NEXTAUTH_SECRET`, so a session validated against + a database that did not contain the user. Measured — a table created in `aws-us-east-1` did not + exist in the other two, and every status surface read green. The app and cron caller are now + pinned via `defaultOptions` 0/0 + `localOptions`. **The bundled `postgres` / + `postgres-highly-available` subcharts are NOT pinned**: a parent cannot template a subchart's + values and neither exposes a location knob (same gap as `plane`, `docmost`, `metabase`). So in a + multi-location GVC an empty database still starts in every location with its own volumeset — + idle, never read, and billed. The app reads its own GVC at boot and logs a warning naming them; + that warning is the only signal a user gets. **The honest statement is: data-splitting is fixed, + cost sprawl is not.** +- **`location` naming a location the GVC lacks starts NOTHING, with no failed deployment.** The + platform stores such a `localOptions` entry verbatim and it is simply inert. It cannot be caught + at boot either — no container runs to complain. README Prerequisites tells the user to check + `cpln gvc get` first; that is all the defence there is. +- **Liveness `initialDelaySeconds` is 780, deliberately above the 600 s database-wait budget.** + Lower it and a slow first install is killed mid-wait, and the script's own FATAL diagnostic never + prints — the operator sees an unexplained restart loop instead. - **A missing `my-calcom-auth` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines. The only place the missing secret is named is `cpln workload get-deployments {workload} --gvc {gvc} -o yaml` → `status.versions[].message`. @@ -90,12 +132,12 @@ plus a 200 on `127.0.0.1:3000/api/version`. The variable is a no-op today and one upstream line away from being essential, and the whole private-first story depends on loopback. The API accepts and stores it verbatim (probed 2026-08-31; only `CPLN_`-prefixed names are reserved). -- **First boot is slow by design** — the container rewrites built assets when the public URL - differs from the baked one, waits for the database, runs `prisma migrate deploy`, and seeds the - app store, all before the HTTP server starts. Probes are budgeted to ~330 s to ready; do not read - a slow first boot as a fault before that. The private default is the *fast* path: the baked - `BUILT_NEXT_PUBLIC_WEBAPP_URL` is `http://localhost:3000` (read out of the pinned image), so - `replace-placeholder.sh` prints "Nothing to replace" and skips the rewrite entirely. +- **First boot is slow by design** — the container waits for the database, rewrites built assets + when the public URL differs from the baked one, runs `prisma migrate deploy`, and seeds the app + store, all before the HTTP server starts. Readiness can exhaust its threshold during a slow + database wait; that is benign, it keeps probing. The private default is the *fast* path: the + baked `BUILT_NEXT_PUBLIC_WEBAPP_URL` is `http://localhost:3000` (read out of the pinned image), + so `replace-placeholder.sh` prints "Nothing to replace" and skips the rewrite entirely. - **Enterprise-gated / not shipped**: organizations, SAML/SSO and the v2 API. The first two are build-ARG-only in the official prebuilt image or licence-gated; the v2 API is a separate image with its own Redis dependency. Core scheduling, teams, workflows and booking pages are not gated. diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md index 009ecde4..f73eee1a 100644 --- a/calcom/versions/1.0.0/README.md +++ b/calcom/versions/1.0.0/README.md @@ -4,15 +4,18 @@ This app deploys [Cal.com](https://cal.com/) — self-hosted scheduling and book ## Architecture -- **Cal.com app**: Stateless `standard` workload on port 3000 — UI, booking pages and the whole `/api/*` surface. Applies its Prisma migrations at boot, before it starts serving. `replicas: 1` by default; `≥2` is an always-on scaled tier for zero-downtime rolling restarts (replicas share only the database and the auth secret — there is no clustering). +- **Cal.com app**: Stateless `standard` workload on port 3000 — UI, booking pages and the whole `/api/*` surface. Waits for PostgreSQL and applies its Prisma migrations at boot, before it starts serving; a failed migration crash-loops the container rather than serving an empty database. `replicas: 1` by default; `≥2` is an always-on scaled tier for zero-downtime rolling restarts (replicas share only the database and the auth secret — there is no clustering). - **Scheduled-job caller**: A second `standard` workload, 1 replica, running the same image with the entrypoint replaced by a bounded `wget` loop that drives the seven cron paths Cal.com's own cloud runs. Optional (`cron.enabled`), on by default. - **PostgreSQL (single-instance, default)**: The `postgres` template — every piece of Cal.com's state, including avatars. - **PostgreSQL (HA, optional)**: The `postgres-highly-available` template instead — 3 Patroni replicas with automatic failover behind an HAProxy leader endpoint. - **Database credentials secret**: A `dictionary` secret holding the bundled database's `username`, `password` and `database`, built by this template from `postgres.database` and `postgres.credentials.*` and handed to the store by name. Nothing for you to create. -- **Identity and policy**: One identity for both workloads, and a least-privilege policy granting `reveal` on exactly the secrets they mount — your auth secret, the database credentials, and the SMTP secret only if you configure one. +- **Startup-script secret**: An `opaque` secret holding the app's boot script, which replaces the image's own. It waits for PostgreSQL by speaking the Postgres protocol, hard-fails a failed migration, and reports the GVC's other locations. Nothing for you to create. +- **Identity and policies**: One identity for both workloads; a least-privilege policy granting `reveal` on exactly the secrets they mount — your auth secret, the database credentials, the startup script, and the SMTP secret only if you configure one — plus a second policy granting `view` on the one GVC you install into, so the app can read its own location list at boot. No volume is attached to the app: upstream's own compose file mounts none, and all state is in Postgres. +**Cal.com runs in exactly one location** — the one named by `location`. It is a single-instance app over a single database, so both workloads are pinned there and a GVC location this release did not ask for starts nothing. See **Location** below; the bundled database is the one piece this chart cannot pin. + ## Prerequisites **One `dictionary` secret must exist BEFORE you install.** Its values never pass through Helm values, so they never land in the release. A missing secret wedges the deployment almost silently — see Important Notes. @@ -34,12 +37,32 @@ cpln secret create-dictionary --name my-calcom-auth \ | `cronSecret` | Guards the scheduled-job endpoints. Not optional — see Important Notes | | `cronApiKey` | The raw-header form Cal.com's legacy cron routes accept | +**`location` must be a location your GVC already has.** Check with `cpln gvc get {gvc} -o yaml`. If it names a location the GVC does not have, the install succeeds and nothing starts — there is no failed deployment to look at. + Nothing else is required for a default install. **The database password is not a prerequisite** — it is bundled plumbing, so this template creates that secret for you from `postgres.database` and `postgres.credentials.*`. Optional: a `dictionary` secret with `EMAIL_SERVER_USER` and `EMAIL_SERVER_PASSWORD` if your SMTP relay needs authentication; a cloud account + bucket (AWS/GCP) or a MinIO credentials secret if you turn backups on (see **Backup setup**). ## Configuration +### Location + +```yaml +# This chart deploys into the GVC you install into — it does NOT create one. +# Cal.com runs in exactly ONE location, and this names it. It MUST already be a +# location of that GVC: a workload runs in every location its GVC has, and an +# unpinned Cal.com in a multi-location GVC becomes N independent instances, each +# on its own database, sharing one session secret. +# +# If this names a location the GVC does NOT have, the install succeeds and +# NOTHING starts — there is no failed deployment to see. Check with +# `cpln gvc get {gvc} -o yaml` before installing. +# +# NOTE: this pins Cal.com and its cron caller only. The bundled PostgreSQL +# subchart cannot be pinned by this chart — see the `postgres` section below. +location: aws-us-east-1 +``` + ### Application ```yaml @@ -103,6 +126,8 @@ internalAccess: Exactly one of the two stores must be enabled (the chart enforces this at render). Set `postgresHA.enabled: true` for near-zero-downtime failover. +**The database is not pinned to `location`.** A parent chart cannot template a subchart's values, and neither backing store exposes a location knob, so in a multi-location GVC an empty copy of the database starts in every location, each with its own volumeset. Cal.com only ever uses the one in `location`; the others hold no data and are never read, but they are billed. The app logs a warning naming them at boot. Prefer a single-location GVC — see Important Notes. + ```yaml postgres: # default: single-instance PostgreSQL enabled: true @@ -212,6 +237,9 @@ Then set `provider: minio`, `minio.{endpoint,bucket}` and `minio.credentialsSecr ## Important Notes +- **Install into a single-location GVC, or accept idle database copies.** Cal.com and its cron caller run only in `location`, but the bundled PostgreSQL cannot be pinned by this template — in a multi-location GVC an empty database starts in every location, each with its own volumeset, never read and still billed. The app names them in its log at boot (`GVC ... also has locations this release does not use`). Choose the GVC before you install — changing a GVC's locations affects every release in it, not just this one. +- **If `location` is not a location of your GVC, nothing starts and nothing reports it.** The install succeeds, the workloads exist, and `cpln workload get-deployments {release}-calcom --gvc {gvc}` shows no running location. Verify the name against `cpln gvc get {gvc} -o yaml` first. +- **A database that never becomes reachable crash-loops the app on purpose.** The app waits up to 600 s for PostgreSQL to answer the Postgres protocol, then exits with `[calcom] FATAL: PostgreSQL at ... did not answer`; a failed `prisma migrate deploy` exits too. That is deliberate — the alternative, which this template shipped before, is Cal.com serving 200s against an empty database with every health surface green. Read `cpln logs '{gvc="{gvc}", workload="{release}-calcom"}' --limit 50` and fix the backing store; the app recovers by itself once the database serves. - **A missing `calcom.auth.secretName` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines, because the container never starts. The only place the missing secret is named is `cpln workload get-deployments {release}-calcom --gvc {gvc} -o yaml` → `status.versions[].message`. Create the secret and it recovers on its own in roughly 6–10 minutes, or immediately with `cpln workload force-redeployment {release}-calcom --gvc {gvc}`. - **`encryptionKey` is write-once.** It encrypts every stored calendar and app credential, so changing it orphans all of them. Rotating any key in that secret also requires a forced redeployment — a `cpln://` reference is resolved when the replica starts and nothing re-resolves it while the replica lives, so the old value keeps working with no error until you redeploy. - **Do not remove the cron workload unless you drive its endpoints yourself.** Cal.com has no in-process scheduler: with nothing calling `/api/tasks/cron` and friends, the task queue (which carries queued outbound email), calendar-subscription sync and credential refresh never run — and every health surface still reads green. `cron.enabled` also requires `internalAccess.type` other than `none`; the chart refuses that combination. diff --git a/calcom/versions/1.0.0/templates/_helpers.tpl b/calcom/versions/1.0.0/templates/_helpers.tpl index 707914f0..25a08209 100644 --- a/calcom/versions/1.0.0/templates/_helpers.tpl +++ b/calcom/versions/1.0.0/templates/_helpers.tpl @@ -30,6 +30,22 @@ Cal.com policy name {{- printf "%s-calcom-policy" .Release.Name }} {{- end }} +{{/* +GVC read policy name — grants the identity `view` on the ONE install GVC so the +app can read its own location list at boot. +*/}} +{{- define "calcom.gvcPolicy.name" -}} +{{- printf "%s-calcom-gvc-policy" .Release.Name }} +{{- end }} + +{{/* +Startup-script secret name. The script replaces the image's own +`scripts/start.sh`; see secret-startup.yaml for why. +*/}} +{{- define "calcom.secret.startup.name" -}} +{{- printf "%s-calcom-startup" .Release.Name }} +{{- end }} + {{/* Mode-aware Database Helpers */}} @@ -185,6 +201,30 @@ inboundAllowWorkload: [] {{/* Validation */}} {{- define "calcom.validate" -}} +{{- /* + Cal.com runs in exactly ONE location, and `location` names it. + + A workload runs in EVERY location its GVC has. Before this was pinned, a + default install into a 3-location GVC produced three app replicas, each bound + by service DNS to its own local Postgres — three separate databases, with a + shared NEXTAUTH_SECRET, so a session minted in one location validated against + a database that did not contain the user. Every status surface read green. + (Measured on a 3-location test GVC: a table created in aws-us-east-1 did not + exist in the other two.) + + The confinement is `defaultOptions.minScale/maxScale: 0` plus a `localOptions` + entry for this one location — so a GVC location this release did not ask for + starts NOTHING, by construction. +*/ -}} +{{- if not .Values.location -}} +{{- fail "calcom: `location` is required — it names the ONE location of your GVC that Cal.com runs in. Cal.com is a single-instance app over a single database; a second location would be a second, independent Cal.com with its own database." -}} +{{- end -}} +{{- if not (kindIs "string" .Values.location) -}} +{{- fail "calcom: `location` must be a single location NAME, e.g. `location: aws-us-east-1`. Cal.com runs in exactly one location." -}} +{{- end -}} +{{- if hasKey .Values "locations" -}} +{{- fail "calcom: `locations` (plural) is not a key of this chart. Cal.com runs in exactly ONE location — use the singular `location`, e.g. `location: aws-us-east-1`." -}} +{{- end -}} {{- $replicas := int .Values.calcom.replicas -}} {{- if lt $replicas 1 -}} {{- fail "calcom: calcom.replicas must be at least 1" -}} @@ -246,6 +286,42 @@ inboundAllowWorkload: [] {{- end }} +{{/* Placement */}} + +{{/* +`autoscaling` for one options entry. `scale` is the replica count to pin to — 0 +for defaultOptions (so a GVC location this release did not ask for runs NOTHING) +and the real count for the one configured location. + +Every field defaultOptions carries is REPEATED in the localOptions entry. A +localOptions entry is not a patch onto defaultOptions: the API completes a +partial entry from its OWN platform defaults, so an omitted field falls through +to a platform value rather than to the 0/0 shape above. +*/}} +{{- define "calcom.autoscaling" -}} +maxConcurrency: 0 +maxScale: {{ .scale }} +metric: disabled +minScale: {{ .scale }} +scaleToZeroDelay: 300 +target: 100 +{{- end -}} + +{{/* +The single `localOptions` entry: the real replica count, in the one configured +location. +*/}} +{{- define "calcom.localOptions" -}} +- autoscaling: + {{- include "calcom.autoscaling" (dict "scale" .scale) | nindent 4 }} + capacityAI: false + debug: false + location: //location/{{ .root.Values.location }} + suspend: false + timeoutSeconds: 30 +{{- end -}} + + {{/* Labeling */}} {{/* diff --git a/calcom/versions/1.0.0/templates/policy-gvc.yaml b/calcom/versions/1.0.0/templates/policy-gvc.yaml new file mode 100644 index 00000000..913577ae --- /dev/null +++ b/calcom/versions/1.0.0/templates/policy-gvc.yaml @@ -0,0 +1,20 @@ +{{- /* + The app reads its OWN GVC at boot to report which other locations the GVC has + — the bundled PostgreSQL subchart runs in all of them and this chart cannot + pin it, so those extra databases are invisible cost that nothing else reports. + + Scoped to the ONE install GVC; never `target: all`, which would grant view on + every GVC in the org. +*/ -}} +kind: policy +name: {{ include "calcom.gvcPolicy.name" . }} +description: Cal.com GVC read policy +tags: {{- include "calcom.tags" . | nindent 4 }} +bindings: + - permissions: + - view + principalLinks: + - //gvc/{{ .Values.global.cpln.gvc }}/identity/{{ include "calcom.identity.name" . }} +targetKind: gvc +targetLinks: + - //gvc/{{ .Values.global.cpln.gvc }} diff --git a/calcom/versions/1.0.0/templates/policy.yaml b/calcom/versions/1.0.0/templates/policy.yaml index c3a71d9c..1fdfcd0d 100644 --- a/calcom/versions/1.0.0/templates/policy.yaml +++ b/calcom/versions/1.0.0/templates/policy.yaml @@ -15,6 +15,7 @@ targetLinks: `target: all`. */}} - //secret/{{ .Values.calcom.auth.secretName }} - //secret/{{ include "calcom.db.secretName" . }} + - //secret/{{ include "calcom.secret.startup.name" . }} {{- if and .Values.email.enabled .Values.email.auth.secretName }} - //secret/{{ .Values.email.auth.secretName }} {{- end }} diff --git a/calcom/versions/1.0.0/templates/secret-startup.yaml b/calcom/versions/1.0.0/templates/secret-startup.yaml new file mode 100644 index 00000000..c0c05866 --- /dev/null +++ b/calcom/versions/1.0.0/templates/secret-startup.yaml @@ -0,0 +1,189 @@ +{{- include "calcom.validate" . -}} +{{- /* + REPLACES the image's own `scripts/start.sh`, which cannot be used as shipped. + + 1. `start.sh` gates the boot on `scripts/wait-for-it.sh`, which at v6.2.0 is + the eficode POSIX-sh variant and tests readiness with `nc -w 1 -z`. The + Control Plane mesh sidecar COMPLETES THE TCP HANDSHAKE FOR ANYTHING, so + that wait returns success instantly in every case. Proven in-container with + three negative controls that all had to fail and did not: a dead port + (9999), a hostname that does not exist, and the real port — all three + "succeeded" in ~0 s. + 2. `start.sh` runs `set -x` but NOT `set -e`, so the `prisma migrate deploy` + that then fails does not stop the boot. Cal.com went on to serve HTTP + against a schema-less database, with `ready: true`, 200 on `/api/version` + and 0 tables in `information_schema`. It is a race, so it is intermittent — + and a fresh volumeset makes Postgres slow to accept connections, which is + exactly the first-install case a new user hits. + + This script therefore (a) waits by exchanging REAL PostgreSQL PROTOCOL BYTES, + and (b) runs under `set -e` so a failed migration crash-loops the container — + a visible failure instead of a silent empty database. + + Verified against `calcom/cal.com:v6.2.0` itself (2026-08-31): `/bin/bash` + 5.2.15 is present (`/bin/sh` is dash, so this MUST be run with bash), + `/dev/tcp` works, `curl`/`python3` are present, and there is NO `psql` or + `pg_isready` in the image (`command -v` exits 127) — which is why the wait + speaks the protocol by hand rather than shelling out to a client. +*/ -}} +kind: secret +name: {{ include "calcom.secret.startup.name" . }} +description: Cal.com startup script +tags: {{- include "calcom.tags" . | nindent 4 }} +type: opaque +data: + encoding: plain + payload: |- + #!/bin/bash + set -euo pipefail + + # Upstream's WORKDIR. `scripts/replace-placeholder.sh` below greps relative + # paths (apps/web/.next/, apps/web/public/), so the cwd is load-bearing. + cd /calcom + + CONFIGURED_LOCATION="{{ .Values.location }}" + # CPLN_LOCATION is a full resource link (/org/{org}/location/{name}). + LOCATION="${CPLN_LOCATION:-}" + LOCATION="${LOCATION##*/}" + GVC="${CPLN_GVC:-}" + DB_HOST="{{ include "calcom.db.host" . }}" + DB_PORT=5432 + # 120 x 5s = 600s. A fresh volumeset plus initdb, or a first Patroni + # election in the HA path, is the slow case; the liveness probe's + # initialDelaySeconds is set above this budget so THIS script's message is + # what an operator sees, not an unexplained restart. + MAX_ATTEMPTS=120 + SLEEP_SECONDS=5 + # Require CONSECUTIVE successes: the first byte back can precede the server + # actually being ready to serve (grafana-multi-location measured a connect + # 126 ms after its gate released dying on "connection reset by peer"). + REQUIRED_OK=3 + + # --- Guard: never run outside the one configured location --- + # defaultOptions.minScale/maxScale are 0 on both workloads of this release, + # so the platform should never place a replica anywhere else. This is the + # last line of defence if it does: a replica in another location reaches + # THAT location's Postgres and becomes a second, independent Cal.com sharing + # the same NEXTAUTH_SECRET. + if [ -z "${LOCATION}" ]; then + echo "[calcom] WARNING: CPLN_LOCATION is unset -- skipping the location guard." >&2 + elif [ "${LOCATION}" != "${CONFIGURED_LOCATION}" ]; then + echo "[calcom] FATAL: this replica is running in location '${LOCATION}', but the release is configured for '${CONFIGURED_LOCATION}'. Refusing to start: it would reach a different PostgreSQL and become a SECOND, independent Cal.com holding different users and bookings, while sharing the same session secret. Set 'location' in your values to the location you want." >&2 + exit 1 + fi + + # --- Report the GVC's other locations --- + # The bundled `postgres` / `postgres-highly-available` subcharts declare no + # localOptions and a parent chart cannot template a subchart's values, so + # the database is NOT confined and starts in every location the GVC has. + # Those extra copies hold no Cal.com data and are never read, but each one + # provisions a volumeset and bills for it. Nothing else reports this, so the + # one tier that always runs says it out loud. + # + # Bounded, and FAIL-OPEN in every arm: a control-plane hiccup or a missing + # `view` grant must never be why Cal.com refuses to start. `timeout` bounds + # the whole call because curl's --max-time is per attempt. + TIMEOUT_CMD="timeout 8" + command -v timeout >/dev/null 2>&1 || TIMEOUT_CMD="" + GVC_JSON="" + if [ -n "${GVC}" ]; then + for _try in 1 2 3; do + # -w writes the status AFTER the body so a non-2xx can be told apart + # from a successful read: a 403 body is valid, non-empty JSON and would + # otherwise parse to "your GVC has no locations". + if _RESP=$(${TIMEOUT_CMD} curl -sS --max-time 6 -w '\n%{http_code}' \ + -H "Authorization: ${CPLN_TOKEN:-}" \ + "${CPLN_ENDPOINT:-http://api.cpln.io}/org/${CPLN_ORG:-}/gvc/${GVC}" 2>/dev/null); then + _CODE="${_RESP##*$'\n'}" + GVC_JSON="${_RESP%$'\n'*}" + if [ "${_CODE}" = "200" ] && [ -n "${GVC_JSON}" ]; then break; fi + if [ -n "${_CODE}" ] && [ "${_CODE}" != "200" ]; then + echo "[calcom] GVC read returned HTTP ${_CODE} (attempt ${_try}/3)." >&2 + fi + fi + GVC_JSON="" + sleep 2 + done + fi + + if [ -z "${GVC_JSON}" ]; then + echo "[calcom] WARNING: could not read GVC '${GVC}' (no 200 response) -- skipping the extra-location report. If this persists, check that the identity has 'view' on the GVC via this chart's GVC policy." >&2 + else + GVC_LOCS="$(printf '%s' "${GVC_JSON}" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ".join(l.rsplit("/",1)[-1] for l in d.get("spec",{}).get("staticPlacement",{}).get("locationLinks",[])))' 2>/dev/null || true)" + EXTRA="" + for _loc in ${GVC_LOCS}; do + if [ "${_loc}" != "${CONFIGURED_LOCATION}" ]; then EXTRA="${EXTRA}${_loc} "; fi + done + if [ -n "${EXTRA}" ]; then + echo "[calcom] WARNING: GVC '${GVC}' also has locations this release does not use: ${EXTRA% }. Cal.com and its cron caller run ONLY in '${CONFIGURED_LOCATION}', but the bundled PostgreSQL subchart cannot be pinned by this chart -- a full, empty database with its own volumeset is running in each of those locations, is never read, and is billed for. Remove those locations from the GVC, or install Cal.com into a single-location GVC." >&2 + fi + fi + + # --- Wait for PostgreSQL, by speaking PostgreSQL --- + # Send an SSLRequest and require the one-byte 'S'/'N' reply that only a live + # backend produces. A TCP connect is NOT a readiness signal here: the mesh + # sidecar answers it for a dead port and for a nonexistent host alike. + # Measured in this exact image (2026-08-31): live Postgres -> READY in 0 s; + # dead port -> NOT-READY; nonexistent host -> NOT-READY; and an + # accepts-but-never-replies server (the shape the sidecar produces) -> + # NOT-READY, bounded at 6 s by the `read -t 5`. + pg_ready() { + local reply="" + exec 3<>"/dev/tcp/${DB_HOST}/${DB_PORT}" || return 1 + printf '\x00\x00\x00\x08\x04\xd2\x16\x2f' >&3 || { exec 3>&- 3<&- || true; return 1; } + IFS= read -r -t 5 -n 1 reply <&3 || true + exec 3>&- 3<&- || true + [ "$reply" = "S" ] || [ "$reply" = "N" ] + } + + echo "[calcom] waiting up to $((MAX_ATTEMPTS * SLEEP_SECONDS))s for PostgreSQL at ${DB_HOST}:${DB_PORT} to answer the Postgres protocol" + OK=0 + READY=false + for _i in $(seq 1 "${MAX_ATTEMPTS}"); do + if pg_ready 2>/dev/null; then + OK=$((OK + 1)) + if [ "${OK}" -ge "${REQUIRED_OK}" ]; then + READY=true + echo "[calcom] PostgreSQL is serving (attempt ${_i})" + break + fi + echo "[calcom] PostgreSQL answered; confirming stability (${OK}/${REQUIRED_OK})" + else + OK=0 + echo "[calcom] PostgreSQL not serving yet (attempt ${_i}/${MAX_ATTEMPTS})" + fi + sleep "${SLEEP_SECONDS}" + done + + if [ "${READY}" != "true" ]; then + echo "[calcom] FATAL: PostgreSQL at ${DB_HOST}:${DB_PORT} did not answer the Postgres protocol within $((MAX_ATTEMPTS * SLEEP_SECONDS))s. Refusing to start: continuing would serve Cal.com against a database it cannot migrate. Check the backing store's workload." >&2 + exit 1 + fi + + # --- Upstream's boot, with the migration made fatal --- + # Rewrite the statically built NEXT_PUBLIC_WEBAPP_URL, exactly as upstream's + # start.sh does. Guarded because an EMPTY "from" value would make the + # script's `sed s||TO|g` insert the URL at every position of every built + # asset. Skipped entirely when the two are equal, which is the private + # default (the baked value is http://localhost:3000). + if [ -n "${BUILT_NEXT_PUBLIC_WEBAPP_URL:-}" ] && [ -n "${NEXT_PUBLIC_WEBAPP_URL:-}" ]; then + scripts/replace-placeholder.sh "${BUILT_NEXT_PUBLIC_WEBAPP_URL}" "${NEXT_PUBLIC_WEBAPP_URL}" + else + echo "[calcom] WARNING: BUILT_NEXT_PUBLIC_WEBAPP_URL or NEXT_PUBLIC_WEBAPP_URL is empty -- skipping the built-asset URL rewrite." >&2 + fi + + # `set -e` makes this fatal. `prisma migrate deploy` exits 1 on P1001 + # (verified against the pinned image), so a database that is reachable but + # unusable crash-loops the container instead of serving an empty schema. + # Migrations take a Postgres advisory lock, so concurrent replica boots are + # safe: one applies, the rest wait and no-op. + npx prisma migrate deploy --schema /calcom/packages/prisma/schema.prisma + + # NOT fatal, deliberately: the seed upserts each app-store entry with its + # own error handling, and a partial failure is a degraded integrations list, + # not an unusable install. Loud enough to find in the log. + if ! npx ts-node --transpile-only /calcom/scripts/seed-app-store.ts; then + echo "[calcom] WARNING: the app-store seed did not complete. Cal.com will start, but some integrations may be missing from Settings -> Apps." >&2 + fi + + exec yarn start diff --git a/calcom/versions/1.0.0/templates/workload-cron.yaml b/calcom/versions/1.0.0/templates/workload-cron.yaml index 55b18a9b..f77624df 100644 --- a/calcom/versions/1.0.0/templates/workload-cron.yaml +++ b/calcom/versions/1.0.0/templates/workload-cron.yaml @@ -90,18 +90,21 @@ spec: value: 'cpln://secret/{{ .Values.calcom.auth.secretName }}.cronApiKey' {{- /* No probes: there is no port and no endpoint to probe. A dead loop shows up as a gap in the app's own access log for /api/tasks/cron. */}} + {{- /* Pinned to the one configured location, for the same reason the app is: + a workload runs in EVERY location its GVC has, and a caller in another + location would drive that location's Cal.com — which, before the app + was pinned, was a different Cal.com over a different database. 0/0 on + defaultOptions means an unasked-for location starts NOTHING; the single + localOptions entry supplies the one replica. */}} defaultOptions: autoscaling: - maxConcurrency: 0 - maxScale: 1 - metric: disabled - minScale: 1 - scaleToZeroDelay: 300 - target: 100 + {{- include "calcom.autoscaling" (dict "scale" 0) | nindent 6 }} capacityAI: false debug: false suspend: false timeoutSeconds: 30 + localOptions: + {{- include "calcom.localOptions" (dict "root" . "scale" 1) | nindent 4 }} firewallConfig: external: {{- /* Outbound to the internet is not needed — every call this workload diff --git a/calcom/versions/1.0.0/templates/workload.yaml b/calcom/versions/1.0.0/templates/workload.yaml index 297b293e..5e8d93a0 100644 --- a/calcom/versions/1.0.0/templates/workload.yaml +++ b/calcom/versions/1.0.0/templates/workload.yaml @@ -63,13 +63,11 @@ spec: PgBouncer in this path by default. */}} - name: DATABASE_DIRECT_URL value: postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "calcom.db.host" . }}:5432/{{ include "calcom.db.database" . }} - {{- /* host:port, consumed by start.sh's wait-for-it.sh before migrations. - Left empty it prints "you need to provide a host and port to test" - and — because start.sh has `set -x` but not `set -e` — the boot - continues without the wait, turning a clear message into a - confusing Prisma error. Verified in the pinned image. */}} - - name: DATABASE_HOST - value: {{ printf "%s:5432" (include "calcom.db.host" .) | quote }} + {{- /* DATABASE_HOST is deliberately NOT set. Its only consumer in the + whole image is `scripts/start.sh` (verified by grepping the pinned + image: one hit, that file), and this workload replaces start.sh — + see the startup-script secret. Setting it would advertise a wait + that no longer runs. */}} - name: NEXT_PUBLIC_WEBAPP_URL value: {{ include "calcom.appUrl" . | quote }} - name: NEXTAUTH_URL @@ -111,20 +109,53 @@ spec: value: 'cpln://secret/{{ .Values.email.auth.secretName }}.EMAIL_SERVER_PASSWORD' {{- end }} {{- end }} + {{- /* The image's entrypoint is REPLACED. `start.sh` gates the boot on + `wait-for-it.sh`, a bare TCP connect that the mesh sidecar answers + for anything, and it runs without `set -e` — so a failed + `prisma migrate deploy` did not stop the boot and Cal.com served + HTTP against an EMPTY database with every surface reading green. + The replacement waits by exchanging real Postgres protocol bytes + and hard-fails the migration. Full rationale, and the negative + controls, are in secret-startup.yaml. + + `/bin/bash` explicitly: `/bin/sh` in this image is dash, and the + script's readiness probe uses bash's `/dev/tcp` (both verified in + the pinned tag). Mounted under /cpln rather than into + /calcom/scripts so nothing in the image's own directory is + shadowed — the pattern postgres-multi-location uses for + /patroni/start.sh. */}} + command: /bin/bash + args: + - /cpln/start.sh + volumes: + - path: /cpln/start.sh + recoveryPolicy: retain # API backfill, declared so rendered == stored + uri: cpln://secret/{{ include "calcom.secret.startup.name" . }}.payload ports: - number: 3000 protocol: http {{- /* /api/version is unauthenticated, touches no database, and returns {"version":"6.2.0"} — so the probe body doubles as proof the running - image is the tag the chart claims. There is NO /api/health route at - this tag; do not copy one from another template. + image is the tag the chart claims. - start.sh only reaches `yarn start` after the asset rewrite, the - database wait, `prisma migrate deploy` and the app-store seed, so - "HTTP is listening" already implies "migrations completed". + It CANNOT detect a missing schema, and there is no endpoint at this + tag that can: the v6.2.0 tree carries no /api/health, /api/ready or + equivalent under apps/web (checked against the full tag tree), and + /auth/setup answers 307 against an empty database. So the schema + invariant is enforced at BOOT instead — the startup script only + reaches `yarn start` after `prisma migrate deploy` has exited 0 + under `set -e`, which makes "HTTP is listening" imply "migrations + applied" for the process that is serving. That claim was FALSE + before this fix and is what the empty-database failure turned on; + it is now true by construction rather than by assumption. The long first boot is absorbed with periodSeconds, never by raising - failureThreshold past the platform cap of 20. */}} + failureThreshold past the platform cap of 20. Readiness exhausting + its threshold during a slow database wait is benign — it keeps + probing and the replica joins when it comes up. Liveness is the one + that restarts, so its initialDelaySeconds sits ABOVE the startup + script's 600 s database-wait budget: otherwise a slow first install + is killed mid-wait and the script's own diagnostic never prints. */}} readinessProbe: httpGet: path: /api/version @@ -140,23 +171,32 @@ spec: path: /api/version port: 3000 scheme: HTTP - initialDelaySeconds: 300 + initialDelaySeconds: 780 periodSeconds: 30 failureThreshold: 10 successThreshold: 1 timeoutSeconds: 5 + {{- /* A workload runs in EVERY location its GVC has. Before this was pinned, + a default install into a 3-location GVC produced three app replicas, + each bound by service DNS to its own local Postgres — three separate + databases sharing one NEXTAUTH_SECRET, so a session minted in one + location validated against a database that did not contain the user. + Measured: a table created in aws-us-east-1 did not exist in the other + two, and every status surface read green throughout. + + 0/0 here means a GVC location this release did not ask for starts + NOTHING, by construction — its deployment message reads "This workload + location is deactivated because maxScale is set to 0". localOptions + below supplies the real count for the one configured location. */}} defaultOptions: autoscaling: - maxConcurrency: 0 - maxScale: {{ .Values.calcom.replicas }} - metric: disabled - minScale: {{ .Values.calcom.replicas }} - scaleToZeroDelay: 300 - target: 100 + {{- include "calcom.autoscaling" (dict "scale" 0) | nindent 6 }} capacityAI: false debug: false suspend: false timeoutSeconds: 30 + localOptions: + {{- include "calcom.localOptions" (dict "root" . "scale" .Values.calcom.replicas) | nindent 4 }} firewallConfig: {{- /* Fully declared, including the empty lists the API backfills: a partial block is completed on apply and shows as drift from creation. */}} diff --git a/calcom/versions/1.0.0/values.yaml b/calcom/versions/1.0.0/values.yaml index 5bca4f94..aa899012 100644 --- a/calcom/versions/1.0.0/values.yaml +++ b/calcom/versions/1.0.0/values.yaml @@ -1,3 +1,18 @@ +# ─── Location ───────────────────────────────────────────────────────────────── +# This chart deploys into the GVC you install into — it does NOT create one. +# Cal.com runs in exactly ONE location, and this names it. It MUST already be a +# location of that GVC: a workload runs in every location its GVC has, and an +# unpinned Cal.com in a multi-location GVC becomes N independent instances, each +# on its own database, sharing one session secret. +# +# If this names a location the GVC does NOT have, the install succeeds and +# NOTHING starts — there is no failed deployment to see. Check with +# `cpln gvc get {gvc} -o yaml` before installing. +# +# NOTE: this pins Cal.com and its cron caller only. The bundled PostgreSQL +# subchart cannot be pinned by this chart — see the `postgres` section below. +location: aws-us-east-1 + # ─── Cal.com application ────────────────────────────────────────────────────── calcom: image: calcom/cal.com:v6.2.0 @@ -81,6 +96,13 @@ internalAccess: # set postgresHA.enabled: true (and postgres.enabled: false) for near-zero-downtime # failover. Every knob of the `postgres` template is available under this key, # including scheduled backups — see "Backup setup" in the README. +# +# NOT PINNED TO `location`. A parent chart cannot template a subchart's values, +# and neither postgres nor postgres-highly-available exposes a location knob, so +# in a multi-location GVC an empty copy of the database starts in EVERY location +# with its own volumeset. Cal.com itself only ever uses the one in `location`; +# the others hold no data and are never read, but they are billed. The app logs +# a warning naming them at boot. Prefer a single-location GVC. postgres: enabled: true image: postgres:18 From c7c00a099d0034a14fd4fdea2b7766236681f8ba Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 18:31:54 -0600 Subject: [PATCH 5/8] calcom: livenessProbe.initialDelaySeconds 780 -> 600, the platform cap The previous commit raised it to 780 so the liveness deadline would sit above the startup script's 600s database wait. The platform caps the field at 600 and rejects the apply with a 400: "spec.containers[0].livenessProbe.initialDelaySeconds" must be less than or equal to 600 That is apply-time validation, so helm template renders cleanly and the chart is simply uninstallable -- measured on a default install, which was refused after 5.9s with the app workload never created. The intent survives the cap: the first restart cannot occur before initialDelay + failureThreshold * periodSeconds = 600 + 10*30 = 900s, which already clears the 600s wait budget. Round 2 ran its whole matrix against a scratch copy with exactly this one line changed, so everything it measured applies to this commit. --- calcom/versions/1.0.0/templates/workload.yaml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/calcom/versions/1.0.0/templates/workload.yaml b/calcom/versions/1.0.0/templates/workload.yaml index 5e8d93a0..9f504964 100644 --- a/calcom/versions/1.0.0/templates/workload.yaml +++ b/calcom/versions/1.0.0/templates/workload.yaml @@ -153,9 +153,16 @@ spec: failureThreshold past the platform cap of 20. Readiness exhausting its threshold during a slow database wait is benign — it keeps probing and the replica joins when it comes up. Liveness is the one - that restarts, so its initialDelaySeconds sits ABOVE the startup - script's 600 s database-wait budget: otherwise a slow first install - is killed mid-wait and the script's own diagnostic never prints. */}} + that restarts, and its first restart must fall AFTER the startup + script's 600 s database-wait budget, or a slow first install is + killed mid-wait and the script's own diagnostic never prints. + + initialDelaySeconds is capped at 600 by the platform (a cpln apply + 400, invisible to helm template — measured 2026-08-31, when 780 made + the chart uninstallable). The deadline does not need it: the first + restart cannot occur before initialDelay + failureThreshold * + periodSeconds = 600 + 10 * 30 = 900 s, which already clears the + budget. */}} readinessProbe: httpGet: path: /api/version @@ -171,7 +178,7 @@ spec: path: /api/version port: 3000 scheme: HTTP - initialDelaySeconds: 780 + initialDelaySeconds: 600 periodSeconds: 30 failureThreshold: 10 successThreshold: 1 From 180fc58e6a67b45f165f552665d9aadd77cadb89 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 19:09:44 -0600 Subject: [PATCH 6/8] calcom: reject a malformed location validateLocation checked empty and is-string but never the string's shape, so '//location/aws-us-east-1' and a comma-separated list were both accepted by Helm AND by the API, storing a placement that matches no real location: nothing runs anywhere, every location reports 'deactivated because maxScale is set to 0', and the logs are silent. Found on documenso in round 2 (measured over 426s on a real GVC); calcom had the identical gap. The link form is what spec.staticPlacement.locationLinks actually contains, so it is the natural copy-paste. --- calcom/versions/1.0.0/templates/_helpers.tpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/calcom/versions/1.0.0/templates/_helpers.tpl b/calcom/versions/1.0.0/templates/_helpers.tpl index 25a08209..08c0d4c5 100644 --- a/calcom/versions/1.0.0/templates/_helpers.tpl +++ b/calcom/versions/1.0.0/templates/_helpers.tpl @@ -222,6 +222,9 @@ inboundAllowWorkload: [] {{- if not (kindIs "string" .Values.location) -}} {{- fail "calcom: `location` must be a single location NAME, e.g. `location: aws-us-east-1`. Cal.com runs in exactly one location." -}} {{- end -}} +{{- if not (regexMatch "^[a-z0-9]+(-[a-z0-9]+)*$" .Values.location) -}} +{{- fail (printf "calcom: `location` must be a bare location NAME like `aws-us-east-1`, got '%s'. A link form (`//location/x`, which is what `spec.staticPlacement.locationLinks` contains) or a comma-separated list is accepted by Helm AND by the API, and stores a placement that matches no real location — so Cal.com starts NOWHERE, every location reports `deactivated because maxScale is set to 0`, and the logs are silent." .Values.location) -}} +{{- end -}} {{- if hasKey .Values "locations" -}} {{- fail "calcom: `locations` (plural) is not a key of this chart. Cal.com runs in exactly ONE location — use the singular `location`, e.g. `location: aws-us-east-1`." -}} {{- end -}} From e1295a3e4062db0419d724157ac2996fb7591fa6 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 19:23:26 -0600 Subject: [PATCH 7/8] =?UTF-8?q?calcom=201.0.0:=20the=20database=20gate's?= =?UTF-8?q?=20FATAL=20was=20unreachable=20=E2=80=94=20readiness,=20not=20l?= =?UTF-8?q?iveness,=20kills=20the=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 found the startup gate's 600 s wait outliving the container: the platform terminated it at ~515 s having reached attempt 104/120, with `Health Check Failed` and a signal, and `|= "FATAL"` returned 0 lines. The operator saw a generic probe failure where the chart had written a sentence naming the database, and a database slower than ~515 s could never be waited out on a single attempt. The chart's own comment asserted that readiness exhausting its threshold during a slow database wait is benign. That is false here, and it is the assumption the 600 s budget rested on — only liveness had been re-budgeted. Measured on test-gvc with four throwaway busybox workloads whose readiness could never pass (port declared, nothing listening) and NO liveness probe: readiness deadline container start -> restart 5 s (0/5/1) 237.6 s, 240.0 s, 243.4 s 330 s (30/15/20) 563.2 s, 555.1 s 660 s (60/30/20) 1043.7 s no probes at all never restarted (and ready:true with nothing listening) So it is READINESS, not a platform "must become ready within N" timer (the no-probe control rules that out) and not liveness (none of them had one). The kill always lands after readiness's own deadline and moves with it. The grace on top is NOT a clean constant — 233 s at the 5 s and 330 s deadlines, 384 s at 660 s, and ~185 s on calcom itself at the same 330 s deadline — so the deadline, which is the floor, is the number to design against. Fix: the wait budget drops from 600 s to 240 s (MAX_ATTEMPTS 120 -> 48), which fires inside calcom's 330 s readiness deadline even after the GVC read's worst-case ~25 s. Readiness is deliberately left tight rather than widened, because failureThreshold x periodSeconds is also how long a sick replica stays in rotation. Nothing is lost by the shorter wait: the gate re-runs in full on every restart, so a slower database is still waited out across cycles — each one now saying why. Verified live on test-gvc against a database pinned to a nonexistent image: 01:19:56.090 [calcom] FATAL: PostgreSQL at test-cal4-postgres.test-gvc.cpln .local:5432 did not answer the Postgres protocol within 240s. Refusing to start: ... with `restarts.exitCode: 1` — the script's own exit, not the platform's 143. The false comment is corrected in workload.yaml and the briefing; README and briefing carry the 240 s number and the fact that the wait restarts each cycle. Co-Authored-By: Claude Opus 5 --- briefings/calcom.md | 50 ++++++++++++++++--- calcom/versions/1.0.0/README.md | 2 +- .../1.0.0/templates/secret-startup.yaml | 29 +++++++++-- calcom/versions/1.0.0/templates/workload.yaml | 48 +++++++++++++----- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/briefings/calcom.md b/briefings/calcom.md index 34c9b2c3..799cbbd3 100644 --- a/briefings/calcom.md +++ b/briefings/calcom.md @@ -62,7 +62,7 @@ `/api/version` and **0 tables**. It is a race, and a fresh volumeset — the first-install case — is the one that loses it. The template now mounts its own `/cpln/start.sh`, which waits by sending a **Postgres SSLRequest** (`\x00\x00\x00\x08\x04\xd2\x16\x2f`) and requiring the - one-byte `S`/`N` reply, three times consecutively, for up to 600 s, and runs under `set -e` so a + one-byte `S`/`N` reply, three times consecutively, for up to 240 s, and runs under `set -e` so a failed migration crash-loops instead of serving. Verified against the pinned image: live Postgres → ready; dead port, nonexistent host and an accepts-but-never-replies server (the sidecar's shape) → not ready, and the gate exits 1 with a named FATAL; a bad-credentials @@ -87,9 +87,46 @@ platform stores such a `localOptions` entry verbatim and it is simply inert. It cannot be caught at boot either — no container runs to complain. README Prerequisites tells the user to check `cpln gvc get` first; that is all the defence there is. -- **Liveness `initialDelaySeconds` is 780, deliberately above the 600 s database-wait budget.** - Lower it and a slow first install is killed mid-wait, and the script's own FATAL diagnostic never - prints — the operator sees an unexplained restart loop instead. +- **READINESS, NOT LIVENESS, IS WHAT KILLS A CONTAINER THAT NEVER BECOMES READY — and the number + it kills at is a hard ceiling on boot time.** This cost the template two rounds. The chart + originally sized its 600 s database wait against *liveness* on the stated assumption that + "readiness exhausting its threshold during a slow database wait is benign — it keeps probing". + That assumption is false here, and it made the script's FATAL diagnostic unreachable: with a + 600 s budget the platform terminated the container at **~515 s**, having reached attempt + **104/120**, with `exitCode: 143` and `Health Check Failed: check events for details.` — + `|= "FATAL"` returned **0 lines**. The operator saw a probe failure where the chart had written + a sentence naming the database. + **Measured directly, 2026-08-31, on four throwaway busybox workloads in `test-gvc` with an + unsatisfiable readiness probe (port declared, nothing listening) and NO liveness probe at all:** + + | readiness `initialDelay` / `period` / `failureThreshold` | deadline | container start → restart | grace over deadline | + |---|---|---|---| + | 0 / 5 / 1 | 5 s | 237.6 s, 240.0 s, 243.4 s (four cycles) | ~233–238 s | + | 30 / 15 / 20 (calcom's own numbers) | 330 s | 563.2 s | 233.2 s | + | 60 / 30 / 20 | 660 s | 1043.7 s | 383.7 s | + | *no probes at all* — control | — | **never restarted** (and `ready: true` with nothing listening) | — | + + **What is settled:** the terminator is the READINESS probe. The no-probe control was never + restarted, which rules out a platform-wide "must become ready within N" timer, and none of the + four had a liveness probe at all. The kill always lands *after* readiness's own deadline + (`initialDelaySeconds + failureThreshold × periodSeconds`) and moves with it — 5 s → 238 s, + 330 s → 563 s, 660 s → 1044 s. + **What is not settled:** the size of the grace on top. It was 233 s at both the 5 s and the 330 s + deadline (so 1:1 with the deadline across that range, slope 1.00 over a 325 s change) and 384 s at + 660 s, so it is not a constant and the relationship is not linear across the whole range. calcom + itself showed ~185 s at the same 330 s deadline where busybox showed 233 s, which suggests part of + it is shutdown behaviour (busybox `sh` ignores the signal as PID 1 and is force-killed; calcom + exits on it). **Design against the DEADLINE, not the observed kill** — the deadline is the floor + the kill can never precede, and it is the only number under our control. + **Consequence for any template with a long boot gate:** the gate's own timeout must fire inside + `initialDelaySeconds + failureThreshold × periodSeconds`, or its diagnostic is dead code. calcom's + readiness deadline is 330 s and its wait budget is now **240 s** (48 × 5 s), which clears it with + ~65 s to spare even after the GVC read's worst-case ~25 s. Liveness (600 / 30 / 10 → 900 s) is not + the load-bearing bound and never was. +- **Shortening the wait did not shorten how long a slow database can take.** The gate re-runs in + full on every restart, so a database that needs 20 minutes is still waited out — across cycles, + each one printing `[calcom] FATAL: PostgreSQL at ... did not answer` instead of dying silently. + The only thing that changed is that the operator is now told which component to look at. - **A missing `my-calcom-auth` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines. The only place the missing secret is named is `cpln workload get-deployments {workload} --gvc {gvc} -o yaml` → `status.versions[].message`. @@ -134,8 +171,9 @@ and stores it verbatim (probed 2026-08-31; only `CPLN_`-prefixed names are reserved). - **First boot is slow by design** — the container waits for the database, rewrites built assets when the public URL differs from the baked one, runs `prisma migrate deploy`, and seeds the app - store, all before the HTTP server starts. Readiness can exhaust its threshold during a slow - database wait; that is benign, it keeps probing. The private default is the *fast* path: the + store, all before the HTTP server starts. Readiness exhausting its threshold during that wait is + **not** benign — see the readiness bullet above; it is what terminates the container, and the + 240 s wait budget is sized to stay inside it. The private default is the *fast* path: the baked `BUILT_NEXT_PUBLIC_WEBAPP_URL` is `http://localhost:3000` (read out of the pinned image), so `replace-placeholder.sh` prints "Nothing to replace" and skips the rewrite entirely. - **Enterprise-gated / not shipped**: organizations, SAML/SSO and the v2 API. The first two are diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md index f73eee1a..1560bb7f 100644 --- a/calcom/versions/1.0.0/README.md +++ b/calcom/versions/1.0.0/README.md @@ -239,7 +239,7 @@ Then set `provider: minio`, `minio.{endpoint,bucket}` and `minio.credentialsSecr - **Install into a single-location GVC, or accept idle database copies.** Cal.com and its cron caller run only in `location`, but the bundled PostgreSQL cannot be pinned by this template — in a multi-location GVC an empty database starts in every location, each with its own volumeset, never read and still billed. The app names them in its log at boot (`GVC ... also has locations this release does not use`). Choose the GVC before you install — changing a GVC's locations affects every release in it, not just this one. - **If `location` is not a location of your GVC, nothing starts and nothing reports it.** The install succeeds, the workloads exist, and `cpln workload get-deployments {release}-calcom --gvc {gvc}` shows no running location. Verify the name against `cpln gvc get {gvc} -o yaml` first. -- **A database that never becomes reachable crash-loops the app on purpose.** The app waits up to 600 s for PostgreSQL to answer the Postgres protocol, then exits with `[calcom] FATAL: PostgreSQL at ... did not answer`; a failed `prisma migrate deploy` exits too. That is deliberate — the alternative, which this template shipped before, is Cal.com serving 200s against an empty database with every health surface green. Read `cpln logs '{gvc="{gvc}", workload="{release}-calcom"}' --limit 50` and fix the backing store; the app recovers by itself once the database serves. +- **A database that never becomes reachable crash-loops the app on purpose.** The app waits up to 240 s for PostgreSQL to answer the Postgres protocol, then exits with `[calcom] FATAL: PostgreSQL at ... did not answer`; a failed `prisma migrate deploy` exits too. That is deliberate — the alternative, which this template shipped before, is Cal.com serving 200s against an empty database with every health surface green. The wait restarts from the beginning on every restart, so a database that takes longer than 240 s is still waited out; you simply get the FATAL line each time round instead of an unexplained restart. Read `cpln logs '{gvc="{gvc}", workload="{release}-calcom"}' --limit 50` and fix the backing store; the app recovers by itself once the database serves. - **A missing `calcom.auth.secretName` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines, because the container never starts. The only place the missing secret is named is `cpln workload get-deployments {release}-calcom --gvc {gvc} -o yaml` → `status.versions[].message`. Create the secret and it recovers on its own in roughly 6–10 minutes, or immediately with `cpln workload force-redeployment {release}-calcom --gvc {gvc}`. - **`encryptionKey` is write-once.** It encrypts every stored calendar and app credential, so changing it orphans all of them. Rotating any key in that secret also requires a forced redeployment — a `cpln://` reference is resolved when the replica starts and nothing re-resolves it while the replica lives, so the old value keeps working with no error until you redeploy. - **Do not remove the cron workload unless you drive its endpoints yourself.** Cal.com has no in-process scheduler: with nothing calling `/api/tasks/cron` and friends, the task queue (which carries queued outbound email), calendar-subscription sync and credential refresh never run — and every health surface still reads green. `cron.enabled` also requires `internalAccess.type` other than `none`; the chart refuses that combination. diff --git a/calcom/versions/1.0.0/templates/secret-startup.yaml b/calcom/versions/1.0.0/templates/secret-startup.yaml index c0c05866..695a3308 100644 --- a/calcom/versions/1.0.0/templates/secret-startup.yaml +++ b/calcom/versions/1.0.0/templates/secret-startup.yaml @@ -48,11 +48,30 @@ data: GVC="${CPLN_GVC:-}" DB_HOST="{{ include "calcom.db.host" . }}" DB_PORT=5432 - # 120 x 5s = 600s. A fresh volumeset plus initdb, or a first Patroni - # election in the HA path, is the slow case; the liveness probe's - # initialDelaySeconds is set above this budget so THIS script's message is - # what an operator sees, not an unexplained restart. - MAX_ATTEMPTS=120 + # 48 x 5s = 240s. The ceiling on this budget is the READINESS probe, not + # liveness. A container that has never become ready is restarted by the + # platform when READINESS exhausts its own deadline + # (initialDelaySeconds + failureThreshold x periodSeconds), plus some + # minutes of grace -- delivered as a signal, with a generic + # "Health Check Failed" and no mention of the database. If the wait + # outlives that, the FATAL below is dead code: measured at MAX_ATTEMPTS=120 + # the container was killed at ~515 s having reached attempt 104/120, and + # `|= "FATAL"` returned zero lines. + # + # Measured 2026-08-31 on four throwaway workloads whose readiness could + # never pass (nothing listening on the probed port), with no liveness probe + # at all: the restart tracks the readiness deadline -- a 5 s deadline gave + # a 238 s cycle, a 330 s deadline (calcom's own numbers) gave 563 s, a + # 660 s deadline gave 1044 s, and a workload with NO readiness probe was + # never restarted at all. So readiness sets the ceiling and this budget + # must sit under it: calcom's readiness deadline is 30 + 20 x 15 = 330 s, + # and 240 s of waiting plus at most ~25 s of the GVC read above lands the + # FATAL inside even that floor, let alone the ~515 s the platform measured. + # + # Shortening the wait costs nothing operationally: the gate re-runs in full + # on every restart, so a database slower than 240 s is still waited out + # across cycles -- and now each cycle says why. + MAX_ATTEMPTS=48 SLEEP_SECONDS=5 # Require CONSECUTIVE successes: the first byte back can precede the server # actually being ready to serve (grafana-multi-location measured a connect diff --git a/calcom/versions/1.0.0/templates/workload.yaml b/calcom/versions/1.0.0/templates/workload.yaml index 9f504964..463b07b9 100644 --- a/calcom/versions/1.0.0/templates/workload.yaml +++ b/calcom/versions/1.0.0/templates/workload.yaml @@ -149,20 +149,42 @@ spec: before this fix and is what the empty-database failure turned on; it is now true by construction rather than by assumption. - The long first boot is absorbed with periodSeconds, never by raising - failureThreshold past the platform cap of 20. Readiness exhausting - its threshold during a slow database wait is benign — it keeps - probing and the replica joins when it comes up. Liveness is the one - that restarts, and its first restart must fall AFTER the startup - script's 600 s database-wait budget, or a slow first install is - killed mid-wait and the script's own diagnostic never prints. + READINESS IS WHAT RESTARTS A CONTAINER THAT NEVER BECOMES READY, + NOT LIVENESS. An earlier version of this comment claimed the + opposite — that readiness exhausting its threshold during a slow + database wait is benign because it keeps probing — and the whole + wait budget was sized against liveness on that assumption. It is + false on this platform. Measured 2026-08-31 on four throwaway + workloads with an unsatisfiable readiness probe and NO liveness + probe at all: the platform restarts the container some minutes + AFTER readiness's own deadline (initialDelaySeconds + + failureThreshold * periodSeconds), and the restart time tracks that + deadline — 5 s deadline -> 238 s, 330 s deadline -> 563 s, 660 s + deadline -> 1044 s. The control with no readiness probe was never + restarted (and reported ready with nothing listening), which is + what pins the cause on readiness rather than on a platform-wide + "must become ready" timer. The extra grace was 233 s at both the + 5 s and 330 s deadlines and 384 s at 660 s, so it is NOT a clean + constant — treat the DEADLINE as the number to design against, + since it is the floor the kill can never precede. - initialDelaySeconds is capped at 600 by the platform (a cpln apply - 400, invisible to helm template — measured 2026-08-31, when 780 made - the chart uninstallable). The deadline does not need it: the first - restart cannot occur before initialDelay + failureThreshold * - periodSeconds = 600 + 10 * 30 = 900 s, which already clears the - budget. */}} + So these numbers are a CEILING ON BOOT TIME, not just a rotation + policy: 30 + 20 * 15 = 330 s here, and the container is terminated + ~515 s in (measured on this chart) with `Health Check Failed` and a + signal — no mention of the database, and the startup script's own + FATAL never prints. The startup script's wait budget is therefore + sized to fire INSIDE this, at 240 s; see secret-startup.yaml. Do + not lengthen that budget without moving these numbers first. + + Readiness is left tight on purpose rather than widened to cover a + long wait: failureThreshold * periodSeconds is also how long a sick + replica stays in rotation, and buying boot time here would be paid + for on every day the app is healthy. + + Liveness cannot fire first (600 + 10 * 30 = 900 s) and is not the + load-bearing bound. initialDelaySeconds is capped at 600 by the + platform (a cpln apply 400, invisible to helm template — measured + 2026-08-31, when 780 made the chart uninstallable). */}} readinessProbe: httpGet: path: /api/version From 43d69826b6a27ad8b46c2bc7038f750f41775485 Mon Sep 17 00:00:00 2001 From: Jacob Cox Date: Mon, 31 Aug 2026 19:40:18 -0600 Subject: [PATCH 8/8] calcom: quantify the multi-location cost, correct the deactivation-message quote Round 3 measured what an extra GVC location actually costs: not just an idle volumeset but a LIVE PostgreSQL -- ready 1/1, minCpu 250m, minMemory 512Mi, plus a bound 10GiB EBS volume, in every extra location. The README said 'never read and still billed' without a number; now it carries one in both places it appears. Also: the platform's deactivation message ends with a period, and the chart comment quoted it without one. Worth exactness because the message text IS the signal -- desiredScale is absent on a deactivated location, not present-and-0. --- briefings/calcom.md | 3 ++- calcom/versions/1.0.0/README.md | 4 ++-- calcom/versions/1.0.0/templates/workload.yaml | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/briefings/calcom.md b/briefings/calcom.md index 799cbbd3..793c3709 100644 --- a/briefings/calcom.md +++ b/briefings/calcom.md @@ -82,7 +82,8 @@ multi-location GVC an empty database still starts in every location with its own volumeset — idle, never read, and billed. The app reads its own GVC at boot and logs a warning naming them; that warning is the only signal a user gets. **The honest statement is: data-splitting is fixed, - cost sprawl is not.** + cost sprawl is not.** Measured on a 3-location GVC: each extra location runs a live idle + PostgreSQL (`ready: true`, `minCpu 250m`, `minMemory 512Mi`) with a bound 10 GiB EBS volume. - **`location` naming a location the GVC lacks starts NOTHING, with no failed deployment.** The platform stores such a `localOptions` entry verbatim and it is simply inert. It cannot be caught at boot either — no container runs to complain. README Prerequisites tells the user to check diff --git a/calcom/versions/1.0.0/README.md b/calcom/versions/1.0.0/README.md index 1560bb7f..0579164b 100644 --- a/calcom/versions/1.0.0/README.md +++ b/calcom/versions/1.0.0/README.md @@ -126,7 +126,7 @@ internalAccess: Exactly one of the two stores must be enabled (the chart enforces this at render). Set `postgresHA.enabled: true` for near-zero-downtime failover. -**The database is not pinned to `location`.** A parent chart cannot template a subchart's values, and neither backing store exposes a location knob, so in a multi-location GVC an empty copy of the database starts in every location, each with its own volumeset. Cal.com only ever uses the one in `location`; the others hold no data and are never read, but they are billed. The app logs a warning naming them at boot. Prefer a single-location GVC — see Important Notes. +**The database is not pinned to `location`.** A parent chart cannot template a subchart's values, and neither backing store exposes a location knob, so in a multi-location GVC an empty copy of the database starts in every location, each with its own volumeset. Cal.com only ever uses the one in `location`; the others hold no data and are never read, but they run and are billed — roughly 250m CPU, 512Mi memory and a 10 GiB volume each (measured on a 3-location GVC). The app logs a warning naming them at boot. Prefer a single-location GVC — see Important Notes. ```yaml postgres: # default: single-instance PostgreSQL @@ -237,7 +237,7 @@ Then set `provider: minio`, `minio.{endpoint,bucket}` and `minio.credentialsSecr ## Important Notes -- **Install into a single-location GVC, or accept idle database copies.** Cal.com and its cron caller run only in `location`, but the bundled PostgreSQL cannot be pinned by this template — in a multi-location GVC an empty database starts in every location, each with its own volumeset, never read and still billed. The app names them in its log at boot (`GVC ... also has locations this release does not use`). Choose the GVC before you install — changing a GVC's locations affects every release in it, not just this one. +- **Install into a single-location GVC, or accept idle database copies.** Cal.com and its cron caller run only in `location`, but the bundled PostgreSQL cannot be pinned by this template — in a multi-location GVC an empty database starts in every location, each with its own volumeset, never read and still billed — roughly 250m CPU, 512Mi memory and a 10 GiB volume each (measured on a 3-location GVC). The app names them in its log at boot (`GVC ... also has locations this release does not use`). Choose the GVC before you install — changing a GVC's locations affects every release in it, not just this one. - **If `location` is not a location of your GVC, nothing starts and nothing reports it.** The install succeeds, the workloads exist, and `cpln workload get-deployments {release}-calcom --gvc {gvc}` shows no running location. Verify the name against `cpln gvc get {gvc} -o yaml` first. - **A database that never becomes reachable crash-loops the app on purpose.** The app waits up to 240 s for PostgreSQL to answer the Postgres protocol, then exits with `[calcom] FATAL: PostgreSQL at ... did not answer`; a failed `prisma migrate deploy` exits too. That is deliberate — the alternative, which this template shipped before, is Cal.com serving 200s against an empty database with every health surface green. The wait restarts from the beginning on every restart, so a database that takes longer than 240 s is still waited out; you simply get the FATAL line each time round instead of an unexplained restart. Read `cpln logs '{gvc="{gvc}", workload="{release}-calcom"}' --limit 50` and fix the backing store; the app recovers by itself once the database serves. - **A missing `calcom.auth.secretName` secret wedges the install almost invisibly.** `cpln logs` returns *zero* lines, because the container never starts. The only place the missing secret is named is `cpln workload get-deployments {release}-calcom --gvc {gvc} -o yaml` → `status.versions[].message`. Create the secret and it recovers on its own in roughly 6–10 minutes, or immediately with `cpln workload force-redeployment {release}-calcom --gvc {gvc}`. diff --git a/calcom/versions/1.0.0/templates/workload.yaml b/calcom/versions/1.0.0/templates/workload.yaml index 463b07b9..11fb197f 100644 --- a/calcom/versions/1.0.0/templates/workload.yaml +++ b/calcom/versions/1.0.0/templates/workload.yaml @@ -215,7 +215,9 @@ spec: 0/0 here means a GVC location this release did not ask for starts NOTHING, by construction — its deployment message reads "This workload - location is deactivated because maxScale is set to 0". localOptions + location is deactivated because maxScale is set to 0." (the platform's + message ends with a period; gate on the text, since `desiredScale` is + ABSENT on a deactivated location rather than present-and-0). localOptions below supplies the real count for the one configured location. */}} defaultOptions: autoscaling: