Add the updater service beside Home Assistant #51
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/updater-service"
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?
CCS-UHA-1. A Python service that will own the Pi's radio, the firmware cache and the over-the-air conversation with the nodes. This is the skeleton only: it starts, logs what it was configured with, and waits to be stopped. The features are CCS-UHA-2 onwards.
The compose entry takes host networking for
can0and the later hotspot address,/run/dbusread-only for NetworkManager, a bind mount for the firmware cache, andFIRMWARE_REGISTRY_TOKENdefaulting to empty. It has noprivilegedflag and no added capability, unlike the Home Assistant entry above it — NetworkManager does the privileged work in its own process, and aCAN_RAWsocket on an interface that is already up needs none. The container does still run as root, which is what keeps polkit happy when a later ticket starts changing connections.Tested with the suite plus a real
docker buildand run: it logs its start line, redacts the token, and stops on SIGTERM in under a second. No compose-parsing test —7412537dropped that deliberately. The Dockerfile's Python is pinned totarget-versionby a check intests/test_manifest.py, and CI's mypy step now coversupdater.Service starts, logs, stays up and exits 0 on SIGTERM in ~100ms — verified against a built image. Two test defects and a documentation overclaim below.
@ -161,1 +161,4 @@integration wants `127.0.0.1` on port `1883` with no credentials.- The `updater` service is built from `updater/` and will own the Pi's radio,the firmware cache and the over-the-air conversation with the nodes. It runswith no privileged flag and no added capability: NetworkManager does theThis reads as a security claim and it doesn't hold. The container is root in the host network namespace with the system bus socket, so it can drive systemd over D-Bus (
StartTransientUnit) and that is host root — polkit sees uid 0.:rodoesn't limit it either: a read-only mount only blocks writes to regular files, directories and symlinks, soconnect()on the socket still works. Keep the mount as the ticket specifies, but state it as "the privileged work is NetworkManager's" without implying the absence ofprivilegedbounds what the container can do.@ -38,0 +42,4 @@volumes:- ./updater-data/firmware:/var/lib/campervan-updater/firmware- /etc/localtime:/etc/localtime:ro# NetworkManager does the privileged work in its own process; this onlyThis comment explains the absence of
privilegedandcap_add, but sits inside thevolumes:list where neither appears. Move it above the service's keys.@ -38,0 +49,4 @@- /run/dbus:/run/dbus:rorestart: unless-stopped# Long enough to put the host's network back the way it was found.stop_grace_period: 30s"put the host's network back the way it was found" describes work that doesn't exist yet — the container stops in about 100ms today. Say it's headroom for the radio teardown landing in CCS-UHA-2 onwards, or leave the number bare.
Coverage still measures only
custom_components/campervan, so the new package reports nothing while the rest of the repo sits at 99%. Add--cov=updaterif that bar is meant to apply here too.@ -0,0 +40,4 @@"""The startup line goes to the container log, which is not a secret store."""summary = Settings(Path("/cache"), "secret").describe()assert "secret" not in summaryassert "set" in summary"set" in summaryis satisfied by the substring inside"unset", so both summary tests pass with adescribe()that always reports the token missing — I mutated it to a literal"registry token unset"and all 9 tests still passed. Assert"registry token set" in summary.@ -0,0 +89,4 @@running = asyncio.create_task(run(Settings(tmp_path, "")))await asyncio.sleep(0)signal.raise_signal(signal.SIGTERM)If signal registration ever regresses, this raises SIGTERM with no handler installed and kills the pytest process mid-run: no failure report, and every test after it is lost. Verified by stubbing out
add_signal_handler— the run died after 8 dots with no summary. Assert the handler is in place first (signal.getsignal(signal.SIGTERM) is not signal.SIG_DFL) so the regression fails as an assertion.@ -0,0 +8,4 @@COPY requirements.txt ./updater/RUN pip install --no-cache-dir -r updater/requirements.txtCOPY . ./updater/No
.dockerignore, soCOPY . ./updater/bakes the build machine's__pycache__and the Dockerfile itself into the image — confirmed present in the built image at/app/updater/__pycache__. COPY preserves mtimes, so a stale.pyccan validate and win over the source beside it. Addupdater/.dockerignorewith__pycache__/,*.py[cod]andDockerfile.@ -0,0 +25,4 @@leaving it out, so an empty string has to mean the default."""source = os.environ if environment is None else environmentcache = source.get("FIRMWARE_CACHE") or DEFAULT_FIRMWARE_CACHEFIRMWARE_CACHEis a knob nothing sets — the compose entry only passesTZand the token, and the ticket scoped it to a volume at a fixed path. Either drop it and use the constant, or set it in compose beside the mount so the two can't drift. Two of the five settings tests exist only for this path.All seven addressed, nothing further. Re-ran both mutations against
0815f02: forcingdescribe()to a literal"registry token unset"now fails, and stubbing outadd_signal_handlerfails as an assertion with the run's report intact. Rebuilt image carries only the source files. Leavingmain()and__main__.pyuncovered is the right call —__main__.pyexits on import and a test formain()would only assertasyncio.runwas called.