Skip to content

calcom 1.0.0 — Cal.com scheduling - #528

Open
jacobecox wants to merge 8 commits into
mainfrom
claude/calcom
Open

calcom 1.0.0 — Cal.com scheduling#528
jacobecox wants to merge 8 commits into
mainfrom
claude/calcom

Conversation

@jacobecox

Copy link
Copy Markdown
Contributor

Cal.com scheduling — Next.js standalone, bundled PostgreSQL (or postgresHA), a separate cron tier, private by default.

Testing

Three rounds across test-gvc and the three-location test-gvc-2. Round 1 found two defects, both now closed and measured closed rather than reasoned closed.

Round 1, blocker: a default install could serve an EMPTY database

Cal.com's own scripts/wait-for-it.sh at v6.2.0 is the eficode POSIX-sh variant that probes with nc -w 1 -z. The mesh sidecar completes that handshake for anything, so the wait never waited. Three controls: a dead port, a nonexistent hostname and the real port all reported "up" in ~0 s. start.sh runs set -x but not set -e, so the failed prisma migrate deploy did not stop the boot, and the readiness probe hits /api/version — served from package.json, touching no database. Net result: ready: true, HTTP 200, and information_schema.tables returning 0.

Fixed with a chart-authored entrypoint that speaks the Postgres SSLRequest and requires the one-byte S/N reply three consecutive times, then migrates under set -e. Verified on a fresh volumeset: the gate held 50 s across 10 failing probes, then 588 migrations, and the poller found no sample where the app was ready with the schema absent.

Patterns copied from keycloak 1.4.0 (protocol bytes) and grafana-multi-location 2.0.0 (consecutive successes), which already solve this correctly.

Round 1, second defect: a multi-location GVC split Cal.com into N independent instances

Three app replicas, three separate databases, a table created in one location absent from the other two, every status surface green.

Fixed with defaultOptions.minScale/maxScale: 0 plus a localOptions entry and a required location value. Round 3 proved it on a real three-location GVC: both unlisted locations report verbatim This workload location is deactivated because maxScale is set to 0. on both calcom workloads, with replica get returning [] — while the unpinned bundled Postgres returned a live replica in those same locations at the same moment, as the positive control. aws-us-east-1 holds 125 tables; the other two hold 0, with the database present but unmigrated, so the probe demonstrably reached the right place.

Two findings that outlived this template

  • Probe initialDelaySeconds is capped at 600. A fix commit set 780 and made the chart uninstallable — a 400 at apply, invisible to helm template. Now in CLAUDE.md's Platform API limits table.
  • Readiness exhaustion restarts the container and caps boot time. The gate's FATAL diagnostic was unreachable because the platform killed the container at ~515 s. Settled with four throwaway workloads and a no-probe control that rules out a platform-wide timer: the kill lands after initialDelaySeconds + failureThreshold × periodSeconds and moves with it. The wait budget was shortened to 240 s rather than widening readiness — that product is also how long a sick replica stays in rotation.

Known and stated honestly

The bundled postgres subchart cannot be pinned by a parent, so each extra GVC location runs a live idle PostgreSQL — minCpu 250m, minMemory 512Mi and a bound 10 GiB volume. Data-splitting is fixed; cost sprawl is not, and the README, values and briefing all say so with the numbers.

render-vs-stored is 0 for calcom's own resources, localOptions included (stored verbatim). The 9 remaining differences are inside the vendored postgres 3.4.1 workload — a partial defaultOptions block inherited by every template vendoring it, and not this chart's to fix.

🤖 Generated with Claude Code

Jacob Cox and others added 8 commits August 31, 2026 09:27
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 <noreply@anthropic.com>
…firewall

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.
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 <noreply@anthropic.com>
…t the app

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 <noreply@anthropic.com>
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.
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.
…not liveness, kills the container

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 <noreply@anthropic.com>
…ssage 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant