Skip to content

feat(cli): add healthcheck, foreground supervision, and configurable cert SANs - #230

Open
robinnsc wants to merge 1 commit into
mainfrom
feat/container-runtime-supervision
Open

feat(cli): add healthcheck, foreground supervision, and configurable cert SANs#230
robinnsc wants to merge 1 commit into
mainfrom
feat/container-runtime-supervision

Conversation

@robinnsc

@robinnsc robinnsc commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

Makes the extenddb binary supervisable and probeable from a container runtime. First of two container-readiness PRs; the migration concurrency guard follows in a separate PR.

  • extenddb healthcheck — new subcommand that sends an HTTPS GET /health and exits 0 or 1, so a Docker HEALTHCHECK needs no shell or curl and works on a distroless/scratch base. Reads the port from the config file, or takes --endpoint https://host:port. Connect, read, and write are bounded at 3s TcpStream::connect has no timeout of its own), every resolved address is tried so a name resolving to both ::1 and 127.0.0.1 works, and --endpoint accepts an optional scheme, port, path, and IPv6 literal.
  • serve --foreground writes no PID file and skips the run directory entirely, so the container can use a read-only root filesystem. Daemon mode is unchanged. With no PID file to read, stop now probes the port and reports that a server is listening under foreign supervision instead of claiming nothing is running; status already degraded to reporting an unknown PID.
  • serve --write-pid-file — opts back into the PID file in foreground mode, for shell use and for tooling that wants stop and status to work. It goes to the same run_dir path daemon mode uses, so neither command needs extra arguments, and run_dir then has to be writable. Ignored in daemon mode, which always writes one.
  • init --tls-san <name> (repeatable) — appends Subject Alternative Names to the generated self-signed certificate so it is valid for the name clients actually use, such as an in-cluster service DNS name, rather than only localhost/127.0.0.1/bind-addr. Values are trimmed and de-duplicated case-insensitively.
  • Docs: the architecture and deployment guides claimed the server always daemonizes and that foreground mode still writes a PID file. Corrected, and the container recipe that waited on that PID file is replaced with a foreground entrypoint plus a HEALTHCHECK.
  • CI/tooling — the new test file is excluded from the main pytest suite and run in devtools/run-tests' CLI section instead, alongside test_cli_lifecycle.py; like those tests it starts and stops its own servers and creates its own databases, so it cannot run in parallel against the shared instance the main suite uses. The integration workflow now starts the server with --write-pid-file, because run-tests restarts it via extenddb stop to apply an import/export config change. That restart also no longer suppresses errors — see Notable below.

Implementation Decisions

  • healthcheck is a liveness probe, deliberately. /health is a static handler that does not query the storage backend, so a replica whose database has gone away still reports healthy. That is the right behaviour for a HEALTHCHECK and a Kubernetes livenessProbe: one that failed on a database outage would restart every replica at once and prolong the outage. A backend that is unreachable at startup does stop the server from listening, so that case is caught. There is no readiness endpoint yet, and the follow-up is to add one backed by a cached storage-layer round-trip rather than to make /health query the backend and lose its value as a liveness signal. This is stated in the module docs, the admin guide, and the deployment guide so nobody wires it to a readinessProbe expecting traffic to drain.
  • init fails rather than silently dropping a --tls-san. init never regenerates an existing certificate, since rotating the key pair under a live deployment would be a surprise. That means a --tls-san added on a later run cannot take effect — and the container story generates the certificate into a persistent volume with an idempotent entrypoint that re-runs init on every start, so this is the common path, not an edge case. Exiting 0 having dropped the name leaves the operator to discover it as a client-side TLS hostname verification failure. Instead, when a certificate already exists we verify it covers every requested SAN and fail with an actionable error otherwise; an already-covered SAN is accepted, so the idempotent entrypoint still works. Certificate generation also moved ahead of all database work so a bad SAN fails before any users or databases are created.

Why

Prerequisite binary changes from the containerization design: the server daemonizes by default (so the container runtime sees PID 1 exit), needs a health probe that works without a shell, and fixes its certificate SANs to localhost/bind-addr, which is wrong for any in-cluster service name.

Testing done

New tests/test_cli_container_readiness.py, against a real PostgreSQL:

  • --tls-san adds one and multiple SANs to the generated certificate; blanks are skipped and case-insensitive duplicates appear once.
  • init fails, naming the SAN, when an existing certificate does not cover it, and does not rotate the certificate; an already-covered SAN is accepted.
  • healthcheck exits 0 when the server is up, non-zero before start and after stop, honours --endpoint including a trailing path, and fails in under 15s against an unreachable host instead of hanging for the OS connect timeout.
  • serve --foreground leaves no PID file and no run directory, answers healthcheck, is not killed by extenddb stop (which reports the port is listening), and exits on SIGTERM.

Plus 5 unit tests for --endpoint parsing (scheme, path, default port, IPv6, malformed input).

Also verified manually that the tests fail if the SAN coverage check is removed, so they are not passing by accident, and that the suite no longer touches the real ~/.extenddb/tls

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

Breaking changes

serve --foreground no longer writes a PID file or creates run_dir. On main it does both, and extenddb stop works against a foreground server. Anyone relying on that must add --write-pid-file, which restores the previous behaviour exactly. extenddb status is unaffected apart from reporting the PID as unknown, since it probes the port. Daemon mode is unchanged.

The repo's own tooling was such a consumer: devtools/run-tests restarts the server with extenddb stop, so the integration workflow passes the new flag.

One newly non-silent failure: init --tls-san X against an existing certificate that does not cover X now exits non-zero where it previously exited 0 and ignored the flag. Since --tls-san is new in this PR, no existing invocation can hit it.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

Comment thread tests/test_cli_container_readiness.py Fixed
Comment thread tests/test_cli_container_readiness.py Fixed
Comment thread tests/test_cli_container_readiness.py Fixed
@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch 2 times, most recently from 4b7c4c1 to 7aa3895 Compare July 28, 2026 10:33
@robinnsc
robinnsc marked this pull request as ready for review July 28, 2026 10:57
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

Thanks @robinnsc, this is a well-put-together PR and the description matches what the code does. I built the branch and ran everything live against a real PostgreSQL before writing this, so the findings below are observed, not read.

What I verified

All claims in the description hold on this machine (release build, real Postgres):

  • serve --foreground starts, serves, and leaves no PID file and no run directory. run_dir was confirmed absent after startup.
  • healthcheck exits 0 against a live server and 1 against a down port, honours --endpoint, and failed against a blackhole host (TEST-NET 203.0.113.1) in 3.09s instead of hanging.
  • stop against a PID-file-less foreground server refuses to kill it and prints the foreign-supervision message. The server stayed healthy afterwards.
  • --write-pid-file restores status and stop exactly; both worked, and stop terminated the server cleanly.
  • SIGTERM to the foreground process drains gracefully.
  • init --tls-san produced a cert with correct SAN typing (DNS for names, IP for 10.42.0.7), case-insensitive dedup (one entry for a name passed twice in different cases), and blanks skipped. Re-running init with a covered SAN passes; an uncovered SAN fails with the actionable error, before any database work, and the cert is not rotated.
  • Suites: 13/13 Rust unit tests, 10/10 of the new pytest file, and 11 passed / 1 skipped on the existing test_cli_lifecycle.py, so no regression to the current CLI behaviour.

One functional issue worth fixing before merge

Wildcard SANs break the idempotent entrypoint. The generation path (rcgen::CertificateParams::new) accepts *.svc.cluster.local as a DNS SAN, but the coverage check calls rustls::pki_types::ServerName::try_from, which rejects wildcards. So init --tls-san '*.svc.cluster.local' succeeds on first run and then fails hard on every subsequent run, even though the cert covers the name. Since the container story re-runs init on every start, this turns a plausible in-cluster input into a startup crash loop. Either match wildcards against the cert's DNS SAN list directly, or reject them symmetrically at generation time.

Smaller observations (non-blocking)

  • cmd_healthcheck.rs:189 swallows set_read_timeout/set_write_timeout errors with let _ =. The whole point of the command is a bounded probe; a server that completes TCP connect but wedges the TLS handshake is exactly what a liveness probe exists to catch, so propagate those errors.
  • The default probe target is the literal 127.0.0.1. A deployment binding ::1 or :: gets a false unhealthy from the flagless invocation. Consider deriving the host from the config bind_addr or probing localhost across both families.
  • serve accepts --port but healthcheck does not, so serve --port X plus a bare healthcheck --config probes the wrong port. Hit this in testing; --endpoint works around it, but the asymmetry is a footgun.
  • Nit: "the names clients actually use" in init_helpers.rs; drop "actually".
  • Nit: test_healthcheck_up_and_down sleeps a fixed 1s after stop before asserting failure; polling for port-closed would be flake-proof.

The security-adjacent choices are right: skipping cert verification in the probe is correct for a self-signed liveness check and is honestly documented, and using rustls' real name verification for SAN coverage (rather than string matching) handles the IP-vs-DNS distinction properly.

Sequencing and the containerization plan

Two coordination points, neither a fault of this PR:

  1. feat: serve lib decoupling #218 (serve/lib decoupling) is likely to land today, and it moves cmd_serve.rs, cmd_init.rs, cmd_stop.rs, init_helpers.rs, and main.rs into a new crates/app/ crate. A merge simulation between the two branches shows a modify/delete conflict on cmd_serve.rs plus content conflicts in main.rs and init_helpers.rs. It is also semantic, not just textual: feat: serve lib decoupling #218 makes the PID write unconditional inside the new serve() library entrypoint, which is the opposite of this PR's foreground contract. Once feat: serve lib decoupling #218 merges, this PR should rebase and express the PID decision as a field on the new ServeParams struct (pid_file: Option<PathBuf> fits naturally). Happy to help with that rebase.

  2. Three divergences from the containerization design doc that we should reconcile on the doc side rather than block here: the doc had the healthcheck doubling as readiness (this PR correctly splits liveness out and defers readiness); the doc specified a server.pid_file config key rather than a CLI flag; and the doc chose local-CA generation where this ships a bare self-signed leaf, which also means the doc's AWS_CA_BUNDLE auto-trust flow will not work as written. Related: the migration concurrency guard the doc scoped into this same phase is deferred to your follow-up, which should be reconciled with feat: adopt sqlx::migrate for PostgreSQL catalog and data migrations (ADR-0003) #221 (sqlx::migrate adoption) before it is written, since feat: adopt sqlx::migrate for PostgreSQL catalog and data migrations (ADR-0003) #221 may change where that guard lives.

Net: fix the wildcard SAN check, rebase once #218 lands, and this is good to go from my side.

…cert SANs

Make the binary supervisable and probeable from a container runtime. First of
two changes for container readiness; the migration concurrency guard follows
separately.

- healthcheck: new subcommand that probes /health over HTTPS and exits 0 or 1,
  so a Docker HEALTHCHECK needs no shell or curl and works on distroless. It
  reports liveness, which is what a HEALTHCHECK and a Kubernetes livenessProbe
  want: /health is a static handler that does not query the backend, and a
  liveness probe that failed on a database outage would restart every replica
  at once. There is no readiness endpoint yet; adding one backed by a cached
  storage-layer round-trip is the follow-up. Connect, read, and write are
  bounded at 3s (TcpStream::connect has no timeout of its own), every resolved
  address is tried so a name resolving to both ::1 and 127.0.0.1 works, and
  --endpoint accepts an optional scheme, port, path, and IPv6 literal. The
  flagless probe derives its host from the configured bind_addr rather than
  assuming 127.0.0.1, so an IPv6-bound server is not reported unhealthy, and
  --port mirrors serve's own override. Read and write timeout failures are
  propagated, since a bounded probe is the whole point of the command.
- serve --foreground: write no PID file and skip the run directory by default,
  so the container can use a read-only root filesystem. Daemon mode is
  unchanged. With no PID file to read, `stop` now probes the port and reports
  that a server is listening under foreign supervision rather than claiming
  nothing is running; `status` already degrades to an unknown PID.
- serve --write-pid-file: opt back into the PID file in foreground mode, for
  shell use and tooling that wants `stop` and `status` to work. It goes to the
  same run_dir path daemon mode uses, so neither command needs extra arguments,
  and run_dir then has to be writable. Ignored in daemon mode, which always
  writes one. devtools/run-tests restarts the server with `stop` to apply a
  config change, so the integration workflow passes this flag; without it that
  restart silently did nothing: `stop` failed, `serve` could not bind, and the
  health check passed against the process that was never replaced. That path
  no longer suppresses errors either, so a failed restart fails the run instead
  of reporting success.
- init --tls-san <name> (repeatable): append Subject Alternative Names to the
  generated self-signed certificate so it is valid for the name clients use,
  such as an in-cluster service DNS name, not just
  localhost/127.0.0.1/bind-addr. Values are trimmed and de-duplicated
  case-insensitively. init never regenerates an existing certificate, so a
  later --tls-san cannot take effect; rather than exit 0 having dropped the
  name and leave clients to hit a TLS hostname verification failure, it
  verifies the existing certificate covers every requested SAN and fails with
  an actionable error otherwise. Certificate generation moved ahead of all
  database work so a bad SAN fails before any state is created. The coverage
  check uses rustls's own `verify_server_name`, so it adds no new dependency. A
  wildcard such as *.svc.cluster.local is a valid certificate entry but not a
  valid server name, so coverage is tested by substituting a single label;
  without that, a wildcard accepted on the first run failed on every later one,
  which is a crash loop for the idempotent entrypoint. Every requested name is
  validated before generation, so a malformed wildcard fails on the first run
  rather than the next.
- docs: correct the architecture and deployment guides, which claimed the
  server always daemonizes and that foreground mode still writes a PID file,
  and replace the container recipe that waited on that PID file with a
  foreground entrypoint plus a HEALTHCHECK.
- tests: add tests/test_cli_container_readiness.py covering SAN generation,
  dedup/blank handling, the not-covered failure and the already-covered
  idempotent case, healthcheck up/down/--endpoint plus prompt failure against
  an unreachable host, and foreground leaving no PID file or run directory
  while still exiting on SIGTERM. Runs under an isolated $HOME so the suite no
  longer overwrites the developer's real ~/.extenddb certificate.
- devtools/run-tests: exclude the new file from the main pytest suite and run
  it in the CLI section instead, alongside test_cli_lifecycle.py. Like those
  tests it starts and stops its own servers and creates its own databases, so
  it cannot run in parallel against the shared instance the main suite uses.

Rebased onto the post-#218 layout: the CLI now lives in crates/app, so
cmd_healthcheck joins it there. ServeParams gains pid_file: Option<PathBuf> in
place of run_dir, which it only ever used to derive that path, so serve() no
longer writes a PID file unconditionally and needs no notion of a run
directory.
@robinnsc
robinnsc force-pushed the feat/container-runtime-supervision branch from 7aa3895 to 10bb920 Compare July 30, 2026 22:34
@robinnsc

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and addressed those callouts:

  • Fixed by the wildcard SAN issue, kept wildcards working rather than rejecting them
  • set_read_timeout/set_write_timeout now propagate
  • The flagless probe now derives its host from the configured bind_addr: a wildcard bind maps to same-family loopback, anything else is probed as configured. The IPv6 concern is fair, bind_addr = "::1" binds fine, curl -g 'https://[::1]:18443/health' works, and the flagless healthcheck now exits 0 against it where it previously probed 127.0.0.1 and reported unhealthy. Unit tests cover the mapping.
  • healthcheck gained --port, with precedence --endpoint > --port > config > default.
  • The fixed 1s sleep in test_healthcheck_up_and_down is now a poll with a 15s deadline

Will address those doc gap callouts on those various design docs

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.

3 participants