ADR-087: Native cosalette-health probe binary shipped in platform wheels¶
Status¶
Accepted Date: 2026-10-04 | Amended Date: 2026-10-04 | Amended Date: 2026-10-05
Context¶
ADR-083 added an opt-in health file and a health CLI probe for container liveness. Every probe run starts a Python interpreter and imports the app or the package CLI. On a Raspberry Pi 4 limited to cpus: 0.5, an early adopter measured 11-13 s per probe and about 100 times the app's idle CPU. On a 6-core x86_64 host (Python 3.14.7, median of 50 runs) the costs per probe are:
| Probe | Wall | CPU | Max RSS |
|---|---|---|---|
native cosalette-health binary |
1.2 ms | <1 ms | 2.2 MiB |
stdlib Python fallback (cosalette._health._probe) |
60.3 ms | 59.2 ms | 15.1 MiB |
cosalette health (Typer CLI) |
392.8 ms | 392.6 ms | 45.9 MiB |
The health-file check itself is small: read one JSON file, compare written_at with the clock, and inspect per-device statuses. cosalette already ships platform wheels built by maturin with a pyo3 extension (ADR-070, cosalette-filters-rs), so a Rust toolchain and a wheel matrix exist. The difficulty is packaging: a wheel can carry an executable in .data/scripts, but pip and uv let a [project.scripts] entry of the same name overwrite it, maturin sdist stores data files as mode 644, and the sdist must keep working on platforms without a prebuilt wheel. This ADR decides how the native probe is built, packaged and named, what happens on platforms without it, and how the health-file contract both implementations read is versioned.
Decision¶
Ship a small Rust binary, cosalette-health, inside each platform wheel and keep a stdlib-only Python console script of the same name as the fallback for the sdist and local builds, because it cuts the per-probe cost by about 50 times (CPU) and 20 times (RSS) against the stdlib fallback without changing how users invoke it.
Build and packaging. The binary lives in the workspace crate crates/cosalette-health (only serde_json), built with the Cargo profile probe (opt-level = "s", LTO, one codegen unit, panic = "abort", stripped; about 370 KB on glibc, 450 KB static musl). The committed pyproject.toml declares the console script cosalette-health = "cosalette._health._probe:main". For platform wheels only, scripts/bundle-health-probe.sh builds the binary, stages it at packages/data/scripts/cosalette-health[.exe], removes the console-script line and sets data = "packages/data" under [tool.maturin]. Each wheel therefore contains exactly one cosalette-health: the native binary in platform wheels, the Python launcher everywhere else. On Linux the script runs as maturin-action's before-script-linux inside the manylinux/musllinux container, which gives the glibc 2.17 floor; on macOS and Windows a separate runner step runs it. task build:wheel:probe does the same locally and restores pyproject.toml afterwards.
Fallback. cosalette._health._probe imports only the standard library and implements the same CLI and the same checks. cosalette health and <app> health call into it, so all three Python entry points share one implementation. The sdist contains neither packages/data nor the crate, so installing it from source always yields the Python launcher.
CLI contract. cosalette-health [--file PATH] [--max-age SECONDS] [--fail-on STATUS ...], with --file overriding COSALETTE_HEALTH_FILE. Exit 0 means healthy and 1 means unhealthy; usage errors print unhealthy: usage error: ... and exit 1, and a panic prints unhealthy: internal error and exits 1, so container runtimes never see another code.
Health-file contract. written_at is required and finite; interval defaults to 60; devices maps names to objects with a string status; unknown keys are ignored. render() now writes health_file_version: 1. A missing version means 1; an unknown major version is reported unhealthy. Golden fixtures in packages/tests/fixtures/health_file_cases.json are run by both pytest and cargo test, so the two implementations cannot drift.
Platform matrix. Native binary: manylinux x86_64, aarch64 and armv7; musllinux x86_64 and aarch64; macOS x86_64 and arm64; Windows x86_64. CI checks each wheel with scripts/check-probe-wheel.py (one data script, no clashing entry point, executable bit, glibc <= 2.17 on gnu targets) and smoke-tests it in a python:3.14-slim/-alpine container or natively. Every other platform (for example musllinux armv7) installs the sdist and gets the Python fallback.
Relation to other ADRs. Extends ADR-083 (same probe semantics, now with a versioned contract and a cheap implementation). Reuses the ADR-070 maturin build and wheel matrix. Keeps ADR-022's lazy-import goal: the fallback imports nothing from cosalette's heavy dependencies. Under ADR-017, syft already reports no components for a .whl; the binary's crates are likewise not visible until it is built with cargo-auditable, which is tracked as a follow-up.
Decision Drivers¶
- Per-probe CPU and memory cost on constrained hosts such as a Raspberry Pi with a CPU quota
- One command name that works on every install, with or without a native wheel
- Exit codes limited to 0 and 1 so orchestrators interpret every outcome correctly
- No drift between the Python and the native checks
- Reuse of the existing maturin build, wheel matrix and Rust toolchain (ADR-070)
- sdist installs must keep working without a Rust toolchain
Considered Options¶
Option 1: Native binary in platform wheels, Python console script elsewhere (chosen)¶
Build the Rust binary per target and ship it as a wheel data script; a pre-build step removes the same-named console script from platform wheels only. The sdist and local builds keep the stdlib console script.
- Advantages: Exactly one
cosalette-healthper wheel, so installer overwrite order does not matter; Same command on every platform; the fallback is automatic; About 1 ms and 2 MiB per probe where the wheel exists - Disadvantages: Platform wheel builds rewrite
pyproject.tomlbefore maturin runs; A second Rust crate to maintain and audit
Option 2: Same-name binary and console script in every wheel¶
Ship both the data script and the [project.scripts] entry and rely on the installer to keep the binary.
- Advantages: No build-time rewriting of
pyproject.toml - Disadvantages: pip and uv write the console script after the data scripts, so the Python launcher silently replaces the binary; Behaviour depends on installer internals
Option 3: Python data-script stub that execs the binary¶
Commit a launcher stub under the data directory that runs the native binary when present and the Python module otherwise.
- Advantages: Single packaging path for wheels and sdist
- Disadvantages:
maturin sdiststores data files as mode 644, so the stub is not executable after a source install; Every probe still starts an interpreter, losing most of the saving
Option 4: Differently named fallback command¶
Ship the binary as cosalette-health and the Python fallback under another name such as cosalette-health-py.
- Advantages: No name collision and no build-time rewriting
- Disadvantages: Dockerfiles must know which variant is installed; On sdist installs the documented command does not exist
Option 5: Stdlib Python probe only¶
Keep only the stdlib-only Python module as the probe and do not build a native binary.
- Advantages: No new crate, no wheel changes
- Disadvantages: Still about 60 ms CPU and 15 MiB per probe, roughly 50 times the native cost
Decision Matrix¶
| Criterion | Native binary in platform wheels, Python console script elsewhere | Same-name binary and console script in every wheel | Python data-script stub that execs the binary | Differently named fallback command | Stdlib Python probe only |
|---|---|---|---|---|---|
| Per-probe cost | 5 | 2 | 2 | 5 | 2 |
| Same command on every install | 5 | 4 | 2 | 1 | 5 |
| Deterministic install result | 5 | 1 | 3 | 5 | 5 |
| sdist works without Rust | 5 | 5 | 2 | 3 | 5 |
| Build and maintenance complexity | 3 | 4 | 3 | 4 | 5 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
HEALTHCHECK CMD ["cosalette-health"]costs about 1 ms of CPU and 2 MiB per probe on supported platforms- Platforms without a native wheel get the same command through the stdlib fallback, still about 6 times cheaper than
cosalette health - The health-file contract is versioned and enforced by shared golden fixtures in both implementations
- Exit codes are limited to 0 and 1, including usage errors and panics
Negative¶
- Platform wheel builds depend on a script that edits
pyproject.toml; local builds must usetask build:wheel:probeto get the binary - A second Rust crate needs MSRV, clippy, cargo-deny and audit upkeep
- The binary's dependencies do not appear in syft SBOMs until it is built with cargo-auditable
- Changes to the health-file check must be made in Python and Rust together
Amendment (2026-10-04) — Minor¶
Editorial note (2026-10-04)
Correction to the original sdist installation claim and its driver/matrix score: the stdlib health fallback adds no Rust requirement, but the package source build still requires Rust because maturin builds the mandatory cosalette-filters-rs PyO3 extension. Therefore source installs without a matching platform wheel require Rust; the original Decision Drivers entry 'sdist installs must keep working without a Rust toolchain' and Decision Matrix criterion 'sdist works without Rust' incorrectly described package installation rather than the health fallback. The intended guarantee is that the fallback itself imports only the standard library and adds no further Rust build requirement. The sdist smoke test validates the fallback command on a Rust-equipped runner, not a Rust-free source build.
Amendment (2026-10-04) — Minor¶
Editorial note (2026-10-04)
Health-file numeric parsing follows serde_json: integer literals in the i64/u64 range remain integers; integers outside that range are converted to finite floating-point values when possible; values that overflow to infinity make the document unreadable. The Python fallback uses the same rule, covered by shared fixtures at both integer boundaries and for overflow.
Amendment (2026-10-05) — Minor¶
Editorial note (2026-10-05)
The platform matrix now also builds musllinux armv7 (armv7-unknown-linux-musleabihf, for example Alpine on a 32-bit Raspberry Pi), checked and smoke-tested like the other Linux targets. The supported-platform table lives in the installation docs (Getting Started, Supported platforms).