feat(ops): hardened production container and exclusive service directory lock #60
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-34-container"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
lifestores its state in a SQLite database in WAL mode withsynchronous=FULL, which allows at most one writer process per data directory. Two things were missing to make that safe in production:ProcessLock(an exclusive advisoryflockonlife.lock) has existed insrc/history/lock.rssince #4, but no caller —life servecould start twice against the same volume (rolling update, reschedule, accidental second replica) and let two processes write the same WAL.servenever closed the SQLite worker, and a SIGTERM during a dispatched remote write left that operation's outcome recorded nowhere except apreparedrow that only the next start would look at.This change makes the deployment topology in
docs/technical/operations-security.mdreal: 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 intofailed(scenario D06/D08 indocs/technical/verification.md).Changes
Exclusive service directory lock (
src/api/mod.rs::serve,src/history/lock.rs)life serveacquiresProcessLock::acquire(storage.data_directory)as its first action — before theDatabaseWorkeropens/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.unlockon the descriptor (aDropimpl 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.storage.data_directory(the operations contract names/var/lib/life-service/life.lock), so a deployment must keepstorage.database_pathinside that directory — as the documented runtime path does.Graceful shutdown and drain (
src/api/mod.rs::serve)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.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
unknownthrough the shutdown path, not from the dropped request future:src/history/worker.rskeeps 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), andJournalService::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.unknownis the honest record — the write may have landed — so it is never reportedfailed, and because recovery's work list isstate IN ('prepared','unknown')the operation stays exactly as recoverable as before.The alternative (leave
preparedand 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. Markingunknownrecords 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)nixos/nixand builds through the project's locked flake (nix build .#life-runtime-root) — no rustup, no distribution Rust, no second toolchain source;nix developremains the canonical dev interface.FROM scratch: it inherits nothing. It carries the statically linked Linux binary, the CA bundle, the IANA timezone database,/var/lib/life-service(owned10001:10001) and/tmp— nothing else. No shell, no package manager, no compiler, no libc to install, so--read-onlywith only the data volume and/tmpwritable 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.nixgains two outputs and keeps its devShell untouched:packages.<system>.life(static musl on Linux, so the runtime image needs no libc) andpackages.<system>.life-runtime-root(the binary plus CA bundle and tzdata, all pinned byflake.lock).Tests (
tests/service_directory_lock.rs)life serveagainst the same data directory (same port, so the lock — not the port — is what rejects it) exits non-zero within 5s with an error naminglife.lockand "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.prepared), the daemon is stopped, the drain window (1s) elapses, and the operation is recordedunknownwith the reason; the next start reconciles it toneeds_reconciliationfrom live evidence instead of leaving it dangling or calling it failed.Verification
The image consumes the Linux artifact of the same flake output:
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-runforx86_64-linux— the derivations, toolchain and assetsthe builder stage will use):
Acceptance criteria, per criterion
USER 10001:10001, no dev tooling, CA + tzdata,/var/lib/life-service,--read-onlysafe, Linux binary, built through the locked flakeDockerfile/flake.nix(below) plus a realnix buildof the same expression, whose runtime root contains exactlybin/life,etc/ssl/certs/ca-certificates.crt,etc/ssl/certs/ca-bundle.crt,usr/share/zoneinfo,var/lib/life-service,tmp.docker buildcould not be executed here — see "Not run".servetakes the lock beforeDatabaseWorker::startand beforeaxum::serve;tests/service_directory_lock.rs::a_second_writer_on_the_same_data_directory_is_rejected_by_the_lockserve's drain select +record_unknown_dispatches+worker.close()+drop(lock); both tests assertlife.lockstill exists afterwards and exit code 0tests/service_directory_lock.rsreproduces D06 and passes/health/liveindependent of backends;startup_recoverystill bounded and in front of the listener/health/livewhile a second writer is rejected, and the whole pre-existing suite (includingcrash_recovery_tests) is greenunknown, neverfailedor droppeda_remote_write_in_flight_at_drain_expiry_is_recorded_unknownreads the row back from SQLite:preparedbefore the stop,unknownafter,result_json.shutdown.reason = "the drain window elapsed while the remote write was in flight", thenneeds_reconciliationafter the next start's recoverytests/service_directory_lock.rsStatic container checks (all pass):
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 inTMPDIR— verified by auditing everystd::fs::write/File::create/create_dir_all/OpenOptionsinsrc/; 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 infofails withfailed 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 realnix buildof the same flake output on the host, not by a container build.aarch64-darwinwith no Linux builder (nix show-config:system = aarch64-darwin,builders =empty,extra-platforms =), so only theaarch64-darwinvariant 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.0built byx86_64-unknown-linux-musl-rustc-1.98.1).Related Issues
Closes #34
c73fd4c6c2toeff71ce8a7Coordinator review — APPROVED (with an explicitly unverified image build)
Verified
eff71cemyself, including one thing the report could not claim.Verified independently:
cargo test450 passed / 0 failed,cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean, andtests/service_directory_lock.rs(2 tests) green — I ran the D06 and D08 suites myself.serve()acquiresProcessLockbefore binding, beforeDatabaseWorker::startand before recovery; a second writer gets an actionable message naminglife.lockand the alternative (storage.data_directory).Dropcallsunlock()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.record_unknown_dispatches()marks registered in-flight operationsunknownin one transaction withWHERE state = 'prepared', so a concurrently committedapplied/failedcan never be overwritten, and it runs beforeworker.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 falsefailed.select!races graceful completion against the deadline and logs when requests were still in flight.FROM scratchruntime,USER 10001:10001, only the static binary, both CA bundle names,zoneinfo,/var/lib/life-servicechowned and declaredVOLUME,/tmp, no shell, no package manager, no compiler, and a run example documenting--read-only. The flake builds withdoCheck = falsefor 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 needtests/copied in.Not verified, and I could not verify it either — recording it rather than implying otherwise:
docker buildwas not executed: the Docker daemon is not running on this machine, andopen -a Dockerplus a 90-second wait did not bring it up.nix build .#life-runtime-root --system aarch64-linux). This host isaarch64-darwinwith noextra-platformsand no configured builders, so a Linux build cannot complete here; it progressed only as far as the platform-agnosticcargo-vendor-dirfixed-output derivation before I stopped it at a 240-second bound.So the image build remains unproven, and the
--read-onlyand non-root claims are supported by static inspection plus the write audit (writes confined to the configured data directory and SQLite temp files inTMPDIR), 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 rundocker build; nothing here is a defect I can act on.Merging.
feat(ops): hardened production container and exclusive service directory lock (#34)to feat(ops): hardened production container and exclusive service directory lock