Serve the manifest and cached images to the nodes #56
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/node-file-server"
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-4. The node-facing half of an update session, spec section 11: a node
joins the hotspot, fetches
/manifest.json, finds itself by node type, andfetches its image.
The manifest is ours to define — it never goes on the bus — and is a single
object holding one entry per node type:
node_typeis byte 0 of the node's ownSYS_ANNOUNCE, so no node carriesconfiguration to find its image, and
sha256is what it checks the bytesagainst before switching boot slot. No version field: an unknown key is ignored
rather than rejected, as everywhere else here.
Only the newest image the cache holds for a node type is offered, and only an
offered image can be fetched. A request path is looked up against the paths the
manifest advertises rather than joined onto the cache root, so a request has no
way out of the tree. There is no authentication, deliberately: the trust
boundary is the network, which during an update is a hotspot with a per-session
key.
Bound on every interface at port
8080, not on the hotspot's address — theticket said otherwise, but it also says this has to be reachable over the
hotspot and the ordinary LAN alike, and binding one address defeats that. The
read-back hotspot address is only what goes into
SYS_WIFI_CONTROL, which isCCS-UHA-5.
Tested without binding a port: the real server is built without its bind and
requests go through the real handler over a socket pair. The suite gets an
autouse fake in its place, so starting the service in a test no longer puts a
firmware server on the machine running it.
CCS-UHA-6 adds its own loopback control API to
updater/; the only shared fileis the two lines in
updater/service.pythat start each server.serve_firmwarecan hang the updater forever on stop, and the lifecycle tests can't see it because they run the fake. Details inline.@ -0,0 +237,4 @@assert [reply.status for reply in climbs] == [404, 404, 404]assert not any(b"not for a node" in reply.body for reply in climbs)This and
test_a_port_that_will_not_bind_does_not_take_the_service_downrun againstFakeFileServer, not the real one: the autouseno_real_listening_socketfixture inconftest.pypatchesfile_server.FirmwareFileServer, andserve_firmwarelooks the name up on the module. So the assertion here is thatthreading.Event.set()unblocksthreading.Event.wait(), which is why the hang above passes CI.The module docstring's "the server is the real one" only holds for the request tests. Cover the real stop path — unpatching this module and binding
127.0.0.1:0, or injecting the server intoserve_firmware, either works.@ -0,0 +55,4 @@FIRMWARE_PREFIX = "/firmware"EVERY_INTERFACE = ""Nothing asserts this.
EVERY_INTERFACEcould be changed to the hotspot address and the whole suite would still pass, yet reachability over both the hotspot and the ordinary LAN is the requirement.FirmwareFileServer(cache, bind_and_activate=False).server_address == ("", NODE_FILE_SERVER_PORT)costs nothing and takes no port.@ -0,0 +121,4 @@body = image.path.read_bytes()except OSError as error:_LOGGER.warning("Could not read %s: %s", image.path, error)self.send_error(HTTPStatus.NOT_FOUND)An advertised image that will not read answers 404, the same as one that was never offered, so a node cannot tell "not for me" from "try again later". 500 is the honest answer here.
@ -0,0 +133,4 @@self.end_headers()self.wfile.write(body)def log_message(self, format: str, *args: Any) -> None:log_messageis routed to the logger buthandle_erroris not, so the one failure that will actually happen in the van escapes it. A node walking off the hotspot mid-image raisesBrokenPipeErrorout ofdo_GET(confirmed against the real handler with a 4 MB image and a closed peer), andsocketserverprints a traceback to stderr. Overridehandle_errorto log it at info — it is an ordinary outcome, not a fault.@ -0,0 +182,4 @@try:await stop.wait()finally:server.shutdown()server.shutdown()on the event loop can hang the process forever.await stop.wait()does not yield ifstopis already set, so theservingtask never gets its first step,serve_foreveris never entered, andshutdown()blocks on an event onlyserve_foreversets. The loop is blocked synchronously, soasyncio.wait_forcannot break it either.Reproduced through
service.serveby setting the stop one scheduling hop earlier than a signal does:Today's signal path happens to be safe only because the two tasks land in
_readyahead of thestop.setcallback. That is scheduling luck, not a guarantee, and the failure is a hard hang until Docker's SIGKILL at 30 s.Even on the happy path this blocks the loop for up to
serve_forever's 0.5 s poll interval.await asyncio.to_thread(server.shutdown)fixes both: the flag is set synchronously, the wait moves off the loop, and the serving task is then free to enter and exitserve_forever.All five are addressed. Reverting the
to_threadand re-runningtests/test_node_file_server.pyfailstest_stopping_before_the_serving_thread_ran_still_stops, so that one bites, andfaulthandler_timeoutturns a regression into a stack dump rather than a hung job. One small thing left, inline.@ -0,0 +169,4 @@outcome, and the next session fetches it again."""_LOGGER.info("Request from %s did not finish: %r", client_address, sys.exception()%ronsys.exception()gives the type and message but no stack, so a genuine bug indo_GETnow logs one line with no line number and surfaces nowhere else — on a box in a van with no network that is all you get.exc_info=Truekeeps the traceback for the unexpected case and costs nothing for theBrokenPipeErrorthis is really for.Nothing left. Dropping
exc_infofailstest_a_request_that_dies_is_logged_rather_than_printed, so the stack assertion holds.