Cache the firmware repository's released images on disk #55
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/firmware-cache"
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?
The updater now asks the firmware repository for its newest release every few
hours and downloads any image it does not already hold. It is built to find
nothing: no network, no release, a refused download and a short one are all
ordinary outcomes, logged at info and never raised.
An image is only offered once its bytes are on disk and hashed. The cache tree
is
<environment>/<version>/<environment>-<version>-firmware.binbeside animage.jsonrecording environment, node type, version, size and SHA-256. Themetadata is written last, through a rename, so a reader never sees half an
entry, and the ticket that serves these to nodes over HTTP can map a URL path
straight onto the tree. Nothing upstream publishes a hash, so this end computes
it after the download; the release API's
sizeis what catches a truncated one.Node to image mapping comes from
protocol/identifiers.py, not a second table:the firmware's PlatformIO environments are
node_plus the registry name. Thatneeded the protocol package inside the updater container, so compose mounts it
read-only, the same way it already mounts the integration into Home Assistant.
FIRMWARE_REGISTRY_TOKENtravels in anAuthorizationheader only, never in aURL or a log line.
Tested with a substituted release source, so nothing in the suite touches the
network: no network at all, no release published, an asset missing for a node,
a truncated download, a refused one, the hash recorded, and a second check that
finds nothing new and downloads nothing.
CCS-UHA-3.
One real defect (the poll loop can die silently), one token-leak risk, and three nits.
@ -0,0 +115,4 @@return Nonedef environment_for_node(node_type: int) -> str | None:environment_for_nodehas no caller outside its own test. Same forRelease.version—refreshtakes the version from the asset filename, so thetag_nameparse is never read. Drop both until something needs them.@ -0,0 +144,4 @@return (self.root / environment / version / METADATA_NAME).is_file()def images(self) -> tuple[CachedImage, ...]:"""Every image that is installable right now, newest version first."""Docstring says "newest version first"; the sort is ascending, so it returns oldest first (
['2026.09.9', '2026.10.1']).latest_for_nodecompensates withreversed, so nothing is broken today, but the tree is described as an interface for the serving ticket and this will mislead it.@ -0,0 +193,4 @@raise _ShortDownloadErrordigest = _sha256_of(partial)partial.replace(directory / asset.name)except OSError, urllib.error.URLError, _ShortDownloadError:urllib.error.URLErrorsubclassesOSError, so it is redundant here and inrefresh's tuple at line 221.@ -0,0 +262,4 @@await asyncio.wait_for(stop.wait(), delay)if stop.is_set():returnawait asyncio.to_thread(refresh, cache, releases)The poll loop dies permanently and silently on any exception
refreshdoes not catch.http.client.HTTPException—IncompleteReadfromjson.load(response)orcopyfileobjon a chunked body,BadStatusLinefromgetresponse()— is neitherOSErrornorValueError, so it escapesrefresh, ends this task, andserveonly looks at the task at shutdown underreturn_exceptions=True. Nothing is logged and no further check runs until the container restarts.Confirmed: a source raising
IncompleteReadleavespolldone after one call, exception swallowed.Wrap the
to_threadcall inexcept Exceptionlogged at info, which also makes "a failed check is an ordinary outcome" true for anything future code adds. Worth a test with a source that raises something outside the caught set.@ -0,0 +282,4 @@"""request = urllib.request.Request(url)if self.token:request.add_header("Authorization", f"token {self.token}")urllib'sHTTPRedirectHandler.redirect_requestcopies every header except content-length and content-type onto the new request, so a 302 frombrowser_download_urlto another host carriesAuthorizationwith it. Forgejo redirects release attachments when storage is external. Send the header only when the URL's host matchesrepository's, or install a redirect handler that drops it on a host change.I have not confirmed this instance redirects; the stdlib forwarding behaviour is confirmed.
All five earlier points fixed and verified. One narrow gap left in the new redirect handler.
@ -0,0 +324,4 @@newurl: str,) -> urllib.request.Request | None:"""Follow the redirect, without the token if the host changed."""redirected = super().redirect_request(req, fp, code, msg, headers, newurl)The host check ignores the scheme, so an
https->httpredirect on the same host keeps the token and sends it in clear. Confirmed: redirecting the latest-release request tohttp://git.robware.uk/blobstill carriestoken secret.Compare
(req.type, req.host)rather thanreq.hostalone, and extendtest_the_token_does_not_follow_a_redirect_off_the_registrywith a same-host downgrade.Scheme fix verified. One leftover import, then this is good to merge.
@ -0,0 +24,4 @@import loggingimport reimport shutilimport urllib.parseimport urllib.parseis now unused —urlsplitwas its only caller. Ruff cannot flag it becauseimport urllib.requestbindsurllibtoo. Drop the line.