feat(ops): hardened production container and exclusive service directory lock #60

Merged
yuris merged 1 commit from feat/issue-34-container into main 2026-09-22 23:58:43 +00:00
Owner

Motivation

life stores its state in a SQLite database in WAL mode with synchronous=FULL, which allows at most one writer process per data directory. Two things were missing to make that safe in production:

  1. Nothing took the lock. ProcessLock (an exclusive advisory flock on life.lock) has existed in src/history/lock.rs since #4, but no caller — life serve could start twice against the same volume (rolling update, reschedule, accidental second replica) and let two processes write the same WAL.
  2. There was no container build, and shutdown had no bound: serve never closed the SQLite worker, and a SIGTERM during a dispatched remote write left that operation's outcome recorded nowhere except a prepared row that only the next start would look at.

This change makes the deployment topology in docs/technical/operations-security.md real: one non-root replica, node-local storage, an exclusive service lock that rejects a second writer, and a bounded drain that records uncertainty instead of converting it into failed (scenario D06/D08 in docs/technical/verification.md).

Changes

Exclusive service directory lock (src/api/mod.rs::serve, src/history/lock.rs)

  • life serve acquires ProcessLock::acquire(storage.data_directory) as its first action — before the DatabaseWorker opens/migrates SQLite and before the listener accepts anything. A second instance fails immediately with an error naming the lock and what to do about it, and never touches the database.
  • The lock file is never unlinked: release is an explicit unlock on the descriptor (a Drop impl documents the invariant). Unlinking would let a second process lock a fresh inode instead of the one the holder owns; leaving the file also keeps the claim visible as evidence.
  • With no configured data directory there is no journal and nothing to guard, so no lock is taken (logged at debug).
  • The lock is keyed on storage.data_directory (the operations contract names /var/lib/life-service/life.lock), so a deployment must keep storage.database_path inside that directory — as the documented runtime path does.

Graceful shutdown and drain (src/api/mod.rs::serve)

  • The drain window is bound to the stop signal, not to process start: a daemon that has been up for hours still gets its full server.shutdown_timeout_seconds (default 30s, crate::config::default_shutdown_timeout_seconds). The signal stops admission of new requests immediately; the window only bounds how long dispatched work may take to record an outcome.
  • After the drain (and after the outbox worker from #17 stops), remote writes this process dispatched but never got an answer for are recorded as unknown; then the SQLite worker is closed (WAL checkpointed, sidecars left in place); then the lock is released.

D08 decision (as the issue requires, with justification)

Operations still unanswered when the drain window elapses are marked unknown through the shutdown path, not from the dropped request future: src/history/worker.rs keeps a process-local registry of operations dispatched but not yet settled, maintained at the existing two-phase boundary (prepare_operation → begin_dispatch, commit_operation_result → settle_dispatch), and JournalService::record_unknown_dispatches() drains it with a conditional update (WHERE state = 'prepared') so an operation that reached a recorded outcome in the meantime keeps it. unknown is the honest record — the write may have landed — so it is never reported failed, and because recovery's work list is state IN ('prepared','unknown') the operation stays exactly as recoverable as before.

The alternative (leave prepared and rely on recovery) is also honest and satisfies D08's "durable recoverable intent" wording, but it records nothing at the moment the process loses the outcome. Marking unknown records the fact that a remote write was dispatched and never answered, which is what the acceptance criterion asks for, at the cost of ~60 lines that reuse the existing journal boundary rather than adding a second locking or state mechanism.

Hardened container (Dockerfile, .dockerignore, flake.nix)

  • Two stages. The builder is nixos/nix and builds through the project's locked flake (nix build .#life-runtime-root) — no rustup, no distribution Rust, no second toolchain source; nix develop remains the canonical dev interface.
  • The runtime stage is FROM scratch: it inherits nothing. It carries the statically linked Linux binary, the CA bundle, the IANA timezone database, /var/lib/life-service (owned 10001:10001) and /tmp — nothing else. No shell, no package manager, no compiler, no libc to install, so --read-only with only the data volume and /tmp writable is enough (--tmpfs /tmp).
  • USER 10001:10001, WORKDIR/VOLUME /var/lib/life-service, EXPOSE 3000. CMD ["serve", "--config", "/etc/life/life.toml"]: the configuration (endpoint, credentials, [storage] pointing at the volume) is a deployment input mounted read-only, never an image layer. Liveness is probed over HTTP by the orchestrator; the image deliberately has no healthcheck tooling.
  • flake.nix gains two outputs and keeps its devShell untouched: packages.<system>.life (static musl on Linux, so the runtime image needs no libc) and packages.<system>.life-runtime-root (the binary plus CA bundle and tzdata, all pinned by flake.lock).

Tests (tests/service_directory_lock.rs)

  • D06: a second life serve against the same data directory (same port, so the lock — not the port — is what rejects it) exits non-zero within 5s with an error naming life.lock and "another instance is running"; the first instance keeps answering /health/live; after a clean SIGTERM the lock file is still on disk and a later start acquires it and serves.
  • D08: a create whose backend never answers is dispatched (prepared), the daemon is stopped, the drain window (1s) elapses, and the operation is recorded unknown with the reason; the next start reconciles it to needs_reconciliation from live evidence instead of leaving it dangling or calling it failed.

Verification

$ nix develop --command cargo fmt --check
(no output)

$ nix develop --command cargo clippy --all-targets -- -D warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.03s

$ nix develop --command cargo test
test result: ok. 62 passed; 0 failed   (lib unit tests)
test result: ok. 22 passed; 0 failed   tests/agenda_tasks_tests.rs
test result: ok.  6 passed; 0 failed   tests/api_tests.rs
test result: ok. 14 passed; 0 failed   tests/caldav_discovery_tests.rs
test result: ok. 10 passed; 0 failed   tests/caldav_report_tests.rs
test result: ok.  8 passed; 0 failed   tests/cli_creation_tests.rs
test result: ok.  9 passed; 0 failed   tests/cli_tests.rs
test result: ok. 13 passed; 0 failed   tests/config_tests.rs
test result: ok. 20 passed; 0 failed   tests/crash_recovery_tests.rs
test result: ok. 19 passed; 0 failed   tests/creation_tests.rs
test result: ok. 23 passed; 0 failed   tests/hindsight_client_tests.rs
test result: ok.  5 passed; 0 failed   tests/history_journal_tests.rs
test result: ok.  8 passed; 0 failed   tests/history_tests.rs
test result: ok. 19 passed; 0 failed   tests/jev_advisory_tests.rs
test result: ok. 24 passed; 0 failed   tests/jev_client_tests.rs
test result: ok. 34 passed; 0 failed   tests/jev_fixtures.rs
test result: ok. 27 passed; 0 failed   tests/lifecycle_tests.rs
test result: ok. 18 passed; 0 failed   tests/outbox_tests.rs
test result: ok. 23 passed; 0 failed   tests/recurrence_tests.rs
test result: ok. 23 passed; 0 failed   tests/resolve_tests.rs
test result: ok. 28 passed; 0 failed   tests/scoped_recurrence_tests.rs
test result: ok.  2 passed; 0 failed   tests/service_directory_lock.rs
test result: ok. 33 passed; 0 failed   tests/update_tests.rs
(450 tests, 0 failed)

$ nix develop --command cargo build --release --locked --bin life
    Finished `release` profile [optimized] target(s) in 1m 14s
target/release/life   (host artifact of the same expression the image builds; on this machine Mach-O arm64, 13,480,480 bytes)

The image consumes the Linux artifact of the same flake output:

$ nix build .#life-runtime-root          # in the Linux builder stage
/nix/store/…-life-runtime-root           # bin/life, etc/ssl/certs/{ca-certificates.crt,ca-bundle.crt},
                                         # usr/share/zoneinfo, var/lib/life-service, tmp

What that output resolves to for the Linux target, from the locked nixpkgs (this machine has no Linux
builder, so this is nix build --dry-run for x86_64-linux — the derivations, toolchain and assets
the builder stage will use):

these 69 derivations will be built:
  /nix/store/…-life-static-x86_64-unknown-linux-musl-0.1.0.drv      # static musl binary
  /nix/store/…-life-runtime-root.drv
these 20 paths will be fetched:
  /nix/store/…-x86_64-unknown-linux-musl-rustc-1.98.1
  /nix/store/…-x86_64-unknown-linux-musl-gcc-wrapper-15.3.0
  /nix/store/…-nss-cacert-3.126
  /nix/store/…-tzdata-2026c

Acceptance criteria, per criterion

# Criterion Evidence
1 Multi-stage minimal runtime image, USER 10001:10001, no dev tooling, CA + tzdata, /var/lib/life-service, --read-only safe, Linux binary, built through the locked flake Static checks over the committed Dockerfile/flake.nix (below) plus a real nix build of the same expression, whose runtime root contains exactly bin/life, etc/ssl/certs/ca-certificates.crt, etc/ssl/certs/ca-bundle.crt, usr/share/zoneinfo, var/lib/life-service, tmp. docker build could not be executed here — see "Not run".
2 Lock acquired before the journal worker and the listener; second process fails immediately and explicitly serve takes the lock before DatabaseWorker::start and before axum::serve; tests/service_directory_lock.rs::a_second_writer_on_the_same_data_directory_is_rejected_by_the_lock
3 Graceful shutdown drains, records, closes SQLite, releases the lock without unlinking serve's drain select + record_unknown_dispatches + worker.close() + drop(lock); both tests assert life.lock still exists afterwards and exit code 0
4 tests/service_directory_lock.rs reproduces D06 and passes 2 passed, 0 failed
5 /health/live independent of backends; startup_recovery still bounded and in front of the listener Both untouched; the D06 test probes /health/live while a second writer is rejected, and the whole pre-existing suite (including crash_recovery_tests) is green
6 In-flight write at drain expiry recorded unknown, never failed or dropped a_remote_write_in_flight_at_drain_expiry_is_recorded_unknown reads the row back from SQLite: prepared before the stop, unknown after, result_json.shutdown.reason = "the drain window elapsed while the remote write was in flight", then needs_reconciliation after the next start's recovery
7 Automated D06 test in tests/service_directory_lock.rs Present and passing

Static container checks (all pass):

PASS  multi-stage: nix builder stage
PASS  multi-stage: scratch runtime stage
PASS  runtime runs as USER 10001:10001
PASS  runtime stage executes no commands (no shell, no package manager, no compiler)
PASS  runtime installs no packages
PASS  no rustup invocation
PASS  no rust/rustup-based base image
PASS  builder builds from the locked flake
PASS  runtime carries the Linux binary from the builder
PASS  CA bundle present
PASS  IANA timezone data present
PASS  runtime data path /var/lib/life-service owned by 10001:10001
PASS  data volume declared
PASS  writable /tmp present
PASS  workdir is the runtime data path
PASS  entrypoint is the binary, default command serves the mounted config
PASS  flake builds a static musl Linux binary
PASS  flake runtime root supplies CA bundle + tzdata from locked nixpkgs
PASS  code locks life.lock in the configured data directory
PASS  serve takes the lock before the journal worker and the listener

The only runtime write locations are the configured data directory (life.lock, the database and its WAL/SHM sidecars, the readiness probe file) and SQLite's temp files in TMPDIR — verified by auditing every std::fs::write/File::create/create_dir_all/OpenOptions in src/; the config-module hits are test-only. That is exactly the volume plus /tmp.

Not run (disclosed, not claimed)

  • docker build / docker run: the Docker daemon is not available on this machine. docker info fails with failed to connect to the docker API at unix:///Users/yurisasc/.docker/run/docker.sock … connect: no such file or directory; starting Docker Desktop (open -a Docker) and waiting 120s did not bring it up. The image build therefore has not been executed; criterion 1 is supported by the static checks and by a real nix build of the same flake output on the host, not by a container build.
  • The Linux (static musl) build of that output: this machine is aarch64-darwin with no Linux builder (nix show-config: system = aarch64-darwin, builders = empty, extra-platforms =), so only the aarch64-darwin variant of the identical expression was actually built and inspected (Mach-O, as expected for a host build — it is not what the image gets). The Linux variant is the one the Dockerfile builds inside its Linux builder stage; its derivation and toolchain were verified by evaluation only (life-static-x86_64-unknown-linux-musl-0.1.0 built by x86_64-unknown-linux-musl-rustc-1.98.1).

Closes #34

## Motivation `life` stores its state in a SQLite database in WAL mode with `synchronous=FULL`, which allows at most **one writer process** per data directory. Two things were missing to make that safe in production: 1. **Nothing took the lock.** `ProcessLock` (an exclusive advisory `flock` on `life.lock`) has existed in `src/history/lock.rs` since #4, but no caller — `life serve` could start twice against the same volume (rolling update, reschedule, accidental second replica) and let two processes write the same WAL. 2. **There was no container build**, and shutdown had no bound: `serve` never closed the SQLite worker, and a SIGTERM during a dispatched remote write left that operation's outcome recorded nowhere except a `prepared` row that only the *next* start would look at. This change makes the deployment topology in `docs/technical/operations-security.md` real: one non-root replica, node-local storage, an exclusive service lock that rejects a second writer, and a bounded drain that records uncertainty instead of converting it into `failed` (scenario D06/D08 in `docs/technical/verification.md`). ## Changes **Exclusive service directory lock** (`src/api/mod.rs::serve`, `src/history/lock.rs`) - `life serve` acquires `ProcessLock::acquire(storage.data_directory)` as its first action — before the `DatabaseWorker` opens/migrates SQLite and before the listener accepts anything. A second instance fails immediately with an error naming the lock and what to do about it, and never touches the database. - The lock file is **never unlinked**: release is an explicit `unlock` on the descriptor (a `Drop` impl documents the invariant). Unlinking would let a second process lock a fresh inode instead of the one the holder owns; leaving the file also keeps the claim visible as evidence. - With no configured data directory there is no journal and nothing to guard, so no lock is taken (logged at debug). - The lock is keyed on `storage.data_directory` (the operations contract names `/var/lib/life-service/life.lock`), so a deployment must keep `storage.database_path` inside that directory — as the documented runtime path does. **Graceful shutdown and drain** (`src/api/mod.rs::serve`) - The drain window is bound to the *stop signal*, not to process start: a daemon that has been up for hours still gets its full `server.shutdown_timeout_seconds` (default 30s, `crate::config::default_shutdown_timeout_seconds`). The signal stops admission of new requests immediately; the window only bounds how long dispatched work may take to record an outcome. - After the drain (and after the outbox worker from #17 stops), remote writes this process dispatched but never got an answer for are recorded as `unknown`; then the SQLite worker is closed (WAL checkpointed, sidecars left in place); then the lock is released. **D08 decision (as the issue requires, with justification)** Operations still unanswered when the drain window elapses are marked `unknown` **through the shutdown path**, not from the dropped request future: `src/history/worker.rs` keeps a process-local registry of operations dispatched but not yet settled, maintained at the existing two-phase boundary (`prepare_operation` → `begin_dispatch`, `commit_operation_result` → `settle_dispatch`), and `JournalService::record_unknown_dispatches()` drains it with a conditional update (`WHERE state = 'prepared'`) so an operation that reached a recorded outcome in the meantime keeps it. `unknown` is the honest record — the write may have landed — so it is never reported `failed`, and because recovery's work list is `state IN ('prepared','unknown')` the operation stays exactly as recoverable as before. The alternative (leave `prepared` and rely on recovery) is also honest and satisfies D08's "durable recoverable intent" wording, but it records *nothing* at the moment the process loses the outcome. Marking `unknown` records the fact that a remote write was dispatched and never answered, which is what the acceptance criterion asks for, at the cost of ~60 lines that reuse the existing journal boundary rather than adding a second locking or state mechanism. **Hardened container** (`Dockerfile`, `.dockerignore`, `flake.nix`) - Two stages. The builder is `nixos/nix` and builds **through the project's locked flake** (`nix build .#life-runtime-root`) — no rustup, no distribution Rust, no second toolchain source; `nix develop` remains the canonical dev interface. - The runtime stage is `FROM scratch`: it inherits nothing. It carries the statically linked Linux binary, the CA bundle, the IANA timezone database, `/var/lib/life-service` (owned `10001:10001`) and `/tmp` — nothing else. No shell, no package manager, no compiler, no libc to install, so `--read-only` with only the data volume and `/tmp` writable is enough (`--tmpfs /tmp`). - `USER 10001:10001`, `WORKDIR`/`VOLUME` `/var/lib/life-service`, `EXPOSE 3000`. `CMD ["serve", "--config", "/etc/life/life.toml"]`: the configuration (endpoint, credentials, `[storage]` pointing at the volume) is a deployment input mounted read-only, never an image layer. Liveness is probed over HTTP by the orchestrator; the image deliberately has no healthcheck tooling. - `flake.nix` gains two outputs and keeps its devShell untouched: `packages.<system>.life` (static musl on Linux, so the runtime image needs no libc) and `packages.<system>.life-runtime-root` (the binary plus CA bundle and tzdata, all pinned by `flake.lock`). **Tests** (`tests/service_directory_lock.rs`) - **D06**: a second `life serve` against the same data directory (same port, so the lock — not the port — is what rejects it) exits non-zero within 5s with an error naming `life.lock` and "another instance is running"; the first instance keeps answering `/health/live`; after a clean SIGTERM the lock file is still on disk and a later start acquires it and serves. - **D08**: a create whose backend never answers is dispatched (`prepared`), the daemon is stopped, the drain window (1s) elapses, and the operation is recorded `unknown` with the reason; the next start reconciles it to `needs_reconciliation` from live evidence instead of leaving it dangling or calling it failed. ## Verification ``` $ nix develop --command cargo fmt --check (no output) $ nix develop --command cargo clippy --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.03s $ nix develop --command cargo test test result: ok. 62 passed; 0 failed (lib unit tests) test result: ok. 22 passed; 0 failed tests/agenda_tasks_tests.rs test result: ok. 6 passed; 0 failed tests/api_tests.rs test result: ok. 14 passed; 0 failed tests/caldav_discovery_tests.rs test result: ok. 10 passed; 0 failed tests/caldav_report_tests.rs test result: ok. 8 passed; 0 failed tests/cli_creation_tests.rs test result: ok. 9 passed; 0 failed tests/cli_tests.rs test result: ok. 13 passed; 0 failed tests/config_tests.rs test result: ok. 20 passed; 0 failed tests/crash_recovery_tests.rs test result: ok. 19 passed; 0 failed tests/creation_tests.rs test result: ok. 23 passed; 0 failed tests/hindsight_client_tests.rs test result: ok. 5 passed; 0 failed tests/history_journal_tests.rs test result: ok. 8 passed; 0 failed tests/history_tests.rs test result: ok. 19 passed; 0 failed tests/jev_advisory_tests.rs test result: ok. 24 passed; 0 failed tests/jev_client_tests.rs test result: ok. 34 passed; 0 failed tests/jev_fixtures.rs test result: ok. 27 passed; 0 failed tests/lifecycle_tests.rs test result: ok. 18 passed; 0 failed tests/outbox_tests.rs test result: ok. 23 passed; 0 failed tests/recurrence_tests.rs test result: ok. 23 passed; 0 failed tests/resolve_tests.rs test result: ok. 28 passed; 0 failed tests/scoped_recurrence_tests.rs test result: ok. 2 passed; 0 failed tests/service_directory_lock.rs test result: ok. 33 passed; 0 failed tests/update_tests.rs (450 tests, 0 failed) $ nix develop --command cargo build --release --locked --bin life Finished `release` profile [optimized] target(s) in 1m 14s target/release/life (host artifact of the same expression the image builds; on this machine Mach-O arm64, 13,480,480 bytes) ``` The image consumes the Linux artifact of the same flake output: ``` $ nix build .#life-runtime-root # in the Linux builder stage /nix/store/…-life-runtime-root # bin/life, etc/ssl/certs/{ca-certificates.crt,ca-bundle.crt}, # usr/share/zoneinfo, var/lib/life-service, tmp ``` What that output resolves to for the Linux target, from the locked nixpkgs (this machine has no Linux builder, so this is `nix build --dry-run` for `x86_64-linux` — the derivations, toolchain and assets the builder stage will use): ``` these 69 derivations will be built: /nix/store/…-life-static-x86_64-unknown-linux-musl-0.1.0.drv # static musl binary /nix/store/…-life-runtime-root.drv these 20 paths will be fetched: /nix/store/…-x86_64-unknown-linux-musl-rustc-1.98.1 /nix/store/…-x86_64-unknown-linux-musl-gcc-wrapper-15.3.0 /nix/store/…-nss-cacert-3.126 /nix/store/…-tzdata-2026c ``` **Acceptance criteria, per criterion** | # | Criterion | Evidence | |---|---|---| | 1 | Multi-stage minimal runtime image, `USER 10001:10001`, no dev tooling, CA + tzdata, `/var/lib/life-service`, `--read-only` safe, Linux binary, built through the locked flake | Static checks over the committed `Dockerfile`/`flake.nix` (below) plus a real `nix build` of the same expression, whose runtime root contains exactly `bin/life`, `etc/ssl/certs/ca-certificates.crt`, `etc/ssl/certs/ca-bundle.crt`, `usr/share/zoneinfo`, `var/lib/life-service`, `tmp`. **`docker build` could not be executed here — see "Not run".** | | 2 | Lock acquired before the journal worker and the listener; second process fails immediately and explicitly | `serve` takes the lock before `DatabaseWorker::start` and before `axum::serve`; `tests/service_directory_lock.rs::a_second_writer_on_the_same_data_directory_is_rejected_by_the_lock` | | 3 | Graceful shutdown drains, records, closes SQLite, releases the lock without unlinking | `serve`'s drain select + `record_unknown_dispatches` + `worker.close()` + `drop(lock)`; both tests assert `life.lock` still exists afterwards and exit code 0 | | 4 | `tests/service_directory_lock.rs` reproduces D06 and passes | 2 passed, 0 failed | | 5 | `/health/live` independent of backends; `startup_recovery` still bounded and in front of the listener | Both untouched; the D06 test probes `/health/live` while a second writer is rejected, and the whole pre-existing suite (including `crash_recovery_tests`) is green | | 6 | In-flight write at drain expiry recorded `unknown`, never `failed` or dropped | `a_remote_write_in_flight_at_drain_expiry_is_recorded_unknown` reads the row back from SQLite: `prepared` before the stop, `unknown` after, `result_json.shutdown.reason = "the drain window elapsed while the remote write was in flight"`, then `needs_reconciliation` after the next start's recovery | | 7 | Automated D06 test in `tests/service_directory_lock.rs` | Present and passing | Static container checks (all pass): ``` PASS multi-stage: nix builder stage PASS multi-stage: scratch runtime stage PASS runtime runs as USER 10001:10001 PASS runtime stage executes no commands (no shell, no package manager, no compiler) PASS runtime installs no packages PASS no rustup invocation PASS no rust/rustup-based base image PASS builder builds from the locked flake PASS runtime carries the Linux binary from the builder PASS CA bundle present PASS IANA timezone data present PASS runtime data path /var/lib/life-service owned by 10001:10001 PASS data volume declared PASS writable /tmp present PASS workdir is the runtime data path PASS entrypoint is the binary, default command serves the mounted config PASS flake builds a static musl Linux binary PASS flake runtime root supplies CA bundle + tzdata from locked nixpkgs PASS code locks life.lock in the configured data directory PASS serve takes the lock before the journal worker and the listener ``` The only runtime write locations are the configured data directory (`life.lock`, the database and its WAL/SHM sidecars, the readiness probe file) and SQLite's temp files in `TMPDIR` — verified by auditing every `std::fs::write`/`File::create`/`create_dir_all`/`OpenOptions` in `src/`; the config-module hits are test-only. That is exactly the volume plus `/tmp`. **Not run (disclosed, not claimed)** - `docker build` / `docker run`: **the Docker daemon is not available on this machine.** `docker info` fails with `failed to connect to the docker API at unix:///Users/yurisasc/.docker/run/docker.sock … connect: no such file or directory`; starting Docker Desktop (`open -a Docker`) and waiting 120s did not bring it up. The image build therefore has **not** been executed; criterion 1 is supported by the static checks and by a real `nix build` of the same flake output on the host, not by a container build. - The **Linux** (static musl) build of that output: this machine is `aarch64-darwin` with no Linux builder (`nix show-config`: `system = aarch64-darwin`, `builders =` empty, `extra-platforms =`), so only the `aarch64-darwin` variant of the identical expression was actually built and inspected (`Mach-O`, as expected for a host build — it is *not* what the image gets). The Linux variant is the one the Dockerfile builds inside its Linux builder stage; its derivation and toolchain were verified by evaluation only (`life-static-x86_64-unknown-linux-musl-0.1.0` built by `x86_64-unknown-linux-musl-rustc-1.98.1`). ## Related Issues Closes #34
yuris force-pushed feat/issue-34-container from c73fd4c6c2 to eff71ce8a7 2026-09-22 23:49:12 +00:00 Compare
Author
Owner

Coordinator review — APPROVED (with an explicitly unverified image build)

Verified eff71ce myself, including one thing the report could not claim.

Verified independently:

  • cargo test 450 passed / 0 failed, cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean, and tests/service_directory_lock.rs (2 tests) green — I ran the D06 and D08 suites myself.
  • Locking order is right and the rationale is stronger than the requirement. serve() acquires ProcessLock before binding, before DatabaseWorker::start and before recovery; a second writer gets an actionable message naming life.lock and the alternative (storage.data_directory). Drop calls unlock() and the code documents why the file is never unlinked — unlinking would let a second process lock a fresh inode, which is the exact race the lock exists to prevent. That is the correct reason, not a stylistic preference.
  • D08 is implemented safely. record_unknown_dispatches() marks registered in-flight operations unknown in one transaction with WHERE state = 'prepared', so a concurrently committed applied/failed can never be overwritten, and it runs before worker.close(). Combined with the process-global dispatch registry (populated before dispatch, therefore still populated when a handler is dropped), a drain expiry leaves uncertainty recorded rather than a false failed.
  • The drain is bounded at the right moment. The window starts on the stop signal, not at process start, so a long-lived daemon still gets its full window; the select! races graceful completion against the deadline and logs when requests were still in flight.
  • The Dockerfile is sound on inspection: two stages, FROM scratch runtime, USER 10001:10001, only the static binary, both CA bundle names, zoneinfo, /var/lib/life-service chowned and declared VOLUME, /tmp, no shell, no package manager, no compiler, and a run example documenting --read-only. The flake builds with doCheck = false for a documented and correct reason (the suites open sockets and spawn the daemon, so they cannot run in the build sandbox) — which is also why the builder stage does not need tests/ copied in.

Not verified, and I could not verify it either — recording it rather than implying otherwise:

  • docker build was not executed: the Docker daemon is not running on this machine, and open -a Docker plus a 90-second wait did not bring it up.
  • I also tried to build the Linux artifact directly (nix build .#life-runtime-root --system aarch64-linux). This host is aarch64-darwin with no extra-platforms and no configured builders, so a Linux build cannot complete here; it progressed only as far as the platform-agnostic cargo-vendor-dir fixed-output derivation before I stopped it at a 240-second bound.

So the image build remains unproven, and the --read-only and non-root claims are supported by static inspection plus the write audit (writes confined to the configured data directory and SQLite temp files in TMPDIR), not by running the image. Your PR body already discloses this, which is the right call. The residual risk belongs to the first environment that can actually run docker build; nothing here is a defect I can act on.

Merging.

## Coordinator review — APPROVED (with an explicitly unverified image build) Verified `eff71ce` myself, including one thing the report could not claim. **Verified independently:** - `cargo test` **450 passed / 0 failed**, `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean, and `tests/service_directory_lock.rs` (2 tests) green — I ran the D06 and D08 suites myself. - **Locking order is right and the rationale is stronger than the requirement.** `serve()` acquires `ProcessLock` *before* binding, before `DatabaseWorker::start` and before recovery; a second writer gets an actionable message naming `life.lock` and the alternative (`storage.data_directory`). `Drop` calls `unlock()` and the code documents why the file is never unlinked — unlinking would let a second process lock a fresh inode, which is the exact race the lock exists to prevent. That is the correct reason, not a stylistic preference. - **D08 is implemented safely.** `record_unknown_dispatches()` marks registered in-flight operations `unknown` in one transaction with `WHERE state = 'prepared'`, so a concurrently committed `applied`/`failed` can never be overwritten, and it runs before `worker.close()`. Combined with the process-global dispatch registry (populated before dispatch, therefore still populated when a handler is dropped), a drain expiry leaves uncertainty recorded rather than a false `failed`. - **The drain is bounded at the right moment.** The window starts on the stop signal, not at process start, so a long-lived daemon still gets its full window; the `select!` races graceful completion against the deadline and logs when requests were still in flight. - **The Dockerfile is sound on inspection**: two stages, `FROM scratch` runtime, `USER 10001:10001`, only the static binary, both CA bundle names, `zoneinfo`, `/var/lib/life-service` chowned and declared `VOLUME`, `/tmp`, no shell, no package manager, no compiler, and a run example documenting `--read-only`. The flake builds with `doCheck = false` for a documented and correct reason (the suites open sockets and spawn the daemon, so they cannot run in the build sandbox) — which is also why the builder stage does not need `tests/` copied in. **Not verified, and I could not verify it either — recording it rather than implying otherwise:** - `docker build` was not executed: the Docker daemon is not running on this machine, and `open -a Docker` plus a 90-second wait did not bring it up. - I also tried to build the Linux artifact directly (`nix build .#life-runtime-root --system aarch64-linux`). This host is `aarch64-darwin` with no `extra-platforms` and no configured builders, so a Linux build cannot complete here; it progressed only as far as the platform-agnostic `cargo-vendor-dir` fixed-output derivation before I stopped it at a 240-second bound. So the image build remains unproven, and the `--read-only` and non-root claims are supported by static inspection plus the write audit (writes confined to the configured data directory and SQLite temp files in `TMPDIR`), not by running the image. Your PR body already discloses this, which is the right call. The residual risk belongs to the first environment that can actually run `docker build`; nothing here is a defect I can act on. Merging.
yuris merged commit 1c35748d75 into main 2026-09-22 23:58:43 +00:00
yuris changed title from feat(ops): hardened production container and exclusive service directory lock (#34) to feat(ops): hardened production container and exclusive service directory lock 2026-09-23 05:12:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!60
No description provided.