Container Dev Mode: host CLI, embedded registry, and VM push path - #184
Container Dev Mode: host CLI, embedded registry, and VM push path#184jetm wants to merge 62 commits into
Conversation
jetm
left a comment
There was a problem hiding this comment.
Self-review (cold read of my own +10k PR). The security core is clean: TLS drops the CA key (bootstrap carries only CA cert + read token), tokens are 256-bit CSPRNG, the store closes digest/tag path-traversal, every docker/podman call is argv-only (no shell), and the bind addresses match the threat model. Six correctness/robustness findings inline - the two worth acting on first are the recycled-PID signal hazard in down/sync and the cross-arch guard never being wired into the live up path (confirm whether that's intentionally deferred - if so it shouldn't read as complete).
jetm
left a comment
There was a problem hiding this comment.
Independent second review of the increment since f69b3e2 (+444/-55, 4 files), traced cold at 2295751. Ten findings inline, two of them high.
The two I would fix before this merges are both about a guard that does not guard:
dev.rs:365- the arch guard this diff wires up is fail-open while the arch book is empty, andreconcilenever consults the book at all. Sinceupspawns the watcher before it bootstraps the device, the empty-book window is the normal case, not an edge: a rebuild pushed before the device ever connects gets recorded into desired state and delivered verbatim on the device's first frame, architecture unchecked. The comment right below claims this decorator prevents exactly that.dev.rs:436-SessionLock::acquireruns after the state write, the token mint, all three binds, the watcher spawn, and both SSH deliveries, so it cannot exclude a secondupfrom any of them. Two of its consequences need no unusual configuration at all: a concurrentstatus/sync/downin the two-statement window makesacquirefail ENOENT (it lacks.create(true)) after everything is already bound and the device is bootstrapped, and because every read path unlinks the lock inode, mutual exclusion does not survive a singledown.
Two findings are backed by running the code rather than reading it. Deleting session.touched = Instant::now(); (registry.rs:407) leaves the test suite at 10/10 passing, so patching_a_session_keeps_it_alive_past_the_ttl asserts nothing about the TTL - it trips the workspace CLAUDE.md rule that every test must fail when the implementation is wrong. And the default-config second-up really does die at EADDRINUSE first (mio sets only SO_REUSEADDR), which is why the destructive half of the lock finding is scoped to the two env overrides rather than claimed outright.
One doc contradiction worth fixing on its own: registry.rs lines 62-63 say only the gap between chunks counts against the TTL, while lines 70-72 say bodies are buffered as Bytes before anything inspects them. The second is what the code does, so the first is wrong, and the commit body repeats it as an absolute ("never evicted mid-push").
Traced and cleared, so nobody re-audits: HelloArchBook's derived Clone does share one map (the Arc is cloned, not the BTreeMap), so the book genuinely is shared between ControlServer and the guard - that was my main suspicion and it is fine. The Arc<dyn DeviceArchBook> double-wrap is the dyn coercion, not redundancy. EngineArchProbe::new(...as_ref()) on a temporary Box is sound because new copies out a &'static str. A refusal from the guard does make do_sync_and_notify skip the notify, as its comment claims. require_basic_write wraps the whole router, so an anonymous request causes no large allocation despite the body limit. The loopback-only doc rewrites are accurate in both directions. read_blob to blob_size is behavior-preserving and the digest is still validated. And I dropped one candidate outright: the new test's Instant::now() - TTL - 1s does not panic on a freshly booted host, since Linux Instant is a signed-seconds Timespec - verified on a box with 3,700s uptime.
Scope note: this was the requested light-to-moderate pass - three finder angles plus four verifiers, no separate gap sweep. avocado-cli has no repo-level CLAUDE.md, so the workspace one governs.
jetm
left a comment
There was a problem hiding this comment.
Second-reviewer pass on my own PR, cold, on the change since the last round. Twelve findings inline, most severe first. Four of them are the ones I'd land before anything else: the session lock no longer proving pid ownership (1), the arch filter that only covers one of two notify paths (2), the mid-transfer upload session teardown (3), and registry/uploads/ never being swept (4).
Findings 3, 4, 5, 6 and 12 all trace to the same shift in this round: the blob path moved from buffer-in-memory to stream-to-disk. That was the right move, but the invariants that used to fall out of buffering for free - a request either arrived whole or not at all, an abandoned upload died with the process, a blob could only reach disk if it fit in RAM, and a size cap existed - each needed an explicit replacement, and only the manifest path got one.
Findings 9, 10 and 11 are a set: three tests whose doc comments claim a falsifier they do not have. In each case I checked by making the change the comment says would fail, and the suite stayed green. Worth fixing as a group, because together they mean the lock mechanism and up's guard wiring are effectively untested.
Below the cap - confirmed, lower severity
store.rs:390-BlobUpload::finishre-implementsblob_path(store.rs:326) inline pluswrite_blob's exists/create_dir_all/persist tail, so the blob layout now has two authors while only readers go throughblob_path.blobs_rootis also initialized fromself.rootand then has"blobs"joined onto it, andformat!("sha256:{hex}")hardcodes an algorithm two lines above aparse_digestthat accepts any.store.rs:341-parse_digest's doc comment is orphaned ontopub struct BlobUpload, socargo docrenders the new public type's summary as "Split an OCI digest into its (algorithm, hex) components", andparse_digestat store.rs:402 ends up undocumented.store.rs:360/:378- unbufferedwrite_allper hyper frame means syscall count per GiB is set by link behavior rather than bounded by code;flush()beforepersistis a documented no-op onFile, and with nosync_alla host crash can leave the blob path present while its tail never reached disk, whichhas_bloband the HEAD dedup probe both report as complete.watcher.rs:601-record_hellohas no production callers left (only watcher.rs:962/990 and six sites intests/container_dev_arch.rs) and plantsholders: 1with no lease, so its entry can never be released. The struct doc at watcher.rs:579 still says reconnects "overwrite" whilerecord_sessionsix lines below says they refcount.ws.rs:441- the innerOption<DeviceArchLease>is neverNone, since the onlySome(..)return arm always supplies a lease, soif lease.is_some()is a branch that cannot be taken.ws.rs:1015-notify_refuses_an_event_with_no_image_idlabelsframes.is_empty()"The decisive assertion", but an empty desired digest compares equal to the emptyrunning_digest, so that assertion passes with thedigest.is_empty()bail deleted. Only the third assertion is decisive.assert!(result.is_err())also never checks which error.tests/container_dev_arch.rs:323-pinned_ca_connectoris now the fourth byte-identical copy (alsocontainer_dev_security.rs:131,container_dev_e2e.rs:353,ws.rs:1064), andtests/common/mod.rsalready exists as the shared home.dev.rs:949+watcher.rs:740- two engine spawns per image per manual sync, to read two fields of the sameimage inspectobject, awaited serially per image.
Checked and refuted
Recording these so nobody re-derives them. Digest verification and path traversal in the new write path are sound: finish compares against the incrementally computed hash before any filesystem move, the dedup branch is only reachable after the hash matched, and parse_digest's permissive algorithm parsing is unreachable because a non-sha256: digest fails equality first. Auth is not bypassable - require_basic_write is a from_fn_with_state layer over the whole router, applied outside routing, so it precedes the /v2/ ping and unrouted methods too. No listener moved this round. Secret file permissions are fine: session.lock is 0644 but always empty (it exists only as a stable flock inode), and persisted blobs keep NamedTempFile's 0600. No DeviceArchLease::drop self-deadlock and no lock-order cycle between image_arches and desired. Instant subtraction in the test helpers does not panic on Linux, and the Windows job is cargo check only. The missing -- before image in resolve_image_id is real in isolation but byte-identical to the pre-existing EngineArchProbe::image_arch shape this round does not touch.
No rust.md violation: edition 2021, anyhow + thiserror matching the CLI row, and no [lints] table, rustfmt.toml, clippy.toml or workspace introduced.
jetm
left a comment
There was a problem hiding this comment.
One finding, carried over from avocado-os#46 after tracing it to the code that actually owns it. The os#46 side is resolved; this is the residual half, and it partly corrects what I said there.
Container Dev Mode is a safety-critical change whose Phase 0 gate must de-risk the core premises before any Phase 1 code ships. The evidence was scattered across an in-session spike and a maintainer's lab, with no durable record of what was actually verified versus attested. Record the Phase 0 decision as GO with explicit provenance: the two-socket separation plus auth matrix (1.11) and the docker arm of the loopback credential path (1.4) were verified in-session with tool output; the remaining hardware spikes (1.1-1.10) are maintainer-attested in-lab. Separating verified from attested keeps the gate honest at this risk tier. Signed-off-by: Javier Tia <javier@peridio.com>
There is currently no foundation for the planned Container Dev Mode feature, which requires an embedded OCI Distribution registry server with TLS and WebSocket support running inside the CLI. Without the necessary dependencies and module structure in place, subsequent tasks implementing config parsing, registry listeners, engine-driver watching, and sync orchestration have nowhere to build on. Introduce the axum, rustls, tokio-rustls, rcgen, and tokio-tungstenite crates as dependencies, all pinned to the aws-lc-rs crypto provider already present via reqwest. This avoids pulling in a second C-based crypto library and keeps the build requirement footprint unchanged. A new container_dev module is added under src/utils as scaffolding, exposing a clear location for the registry server surface and dev loop logic to land in follow-up tasks. Signed-off-by: Javier Tia <javier@peridio.com>
Without a typed configuration structure, the container_dev feature has no way to be selectively enabled per runtime or carry validated parameters such as the watched image list and registry port. Any downstream implementation task would be building on an undefined interface. Introduce ContainerDevConfig as the canonical typed representation of the runtimes.<name>.container_dev YAML block, and wire it into RuntimeConfig as an optional field. Presence of the field enables the feature for that runtime; absence leaves it off. This structural gate is intentional — a container_dev block placed anywhere other than under a runtime is ignored by the parser. The default registry port is set to 5599, explicitly avoiding 5000 which conflicts with the macOS AirPlay Receiver as established during phase 0 task 1.6. The key is also registered with the external-config-ref scanner so the scanner does not recurse into the images list and misinterpret shaped YAML fragments as extension dependency references. Signed-off-by: Javier Tia <javier@peridio.com>
There is currently no entry point for Container Dev Mode in the CLI. Without the command tree in place, the subcommand hierarchy, --help output, and shell completion cannot be exercised or validated before the underlying orchestration logic (host-side registry, engine-driver watcher, device bootstrap) is built. Introduce the `avocado container dev` subcommand family with five verbs: `up`, `sync`, `status`, `down`, and `prune`. Each handler returns a not-yet-implemented error at this stage. This establishes the full dispatch path through the clap enum hierarchy so that integration points and help text can be reviewed independently of the host-side implementation work that follows. Signed-off-by: Javier Tia <javier@peridio.com>
Container Dev Mode needs a local registry to cache OCI blobs and manifest tags between push/pull cycles. Without a dedicated store, there is no safe place to persist layer data across operations, and nothing prevents one project's blobs from polluting another's cache. Introduce a content-addressed BlobStore rooted at `~/.avocado/container-dev/<project>/registry/` that stores blobs under a `blobs/<algorithm>/<hex>` layout matching the OCI image layout spec. Writes are deduplicated by digest so a blob that is already present is never stored a second time, and all writes are atomic via a temp-file-then-rename sequence to avoid partial blobs on crash. Tag pointers live under `manifests/tags/<tag>` and are similarly written atomically. Per-project namespacing is enforced structurally so that GC in one project can never sweep another project's blobs. Digest and tag inputs are validated to reject path-separator and traversal sequences before they can influence filesystem paths. Signed-off-by: Javier Tia <javier@peridio.com>
Without HTTP handlers exposing the content-addressed store built in task 3.1, a device engine has no way to pull images from the embedded registry during a dev-mode iteration. The store exists but is entirely unreachable over the network until the read half of the OCI Distribution protocol is implemented. Introduce the registry read module covering the three endpoints a container engine exercises on a pull: the v2 base check, manifest retrieval by tag or digest, and blob retrieval with Range support. Tags are resolved through the existing BlobStore and manifests are served with the correct media type read from their stored content, ensuring an engine can correctly distinguish a single-platform manifest from a multi-arch image index. Ranged blob fetches return 206 Partial Content with a Content-Range header, matching the resumable-download behavior engines rely on for large layers. HEAD requests are handled transparently by axum's routing without a separate handler. The assembled Router is exported here but intentionally left unbound; it will be mounted onto the dedicated bulk read listener in task 3.7. Signed-off-by: Javier Tia <javier@peridio.com>
Currently the embedded Container Dev Mode registry only serves read requests (blob/manifest GET and partial-content streaming). There is no write path, so a container engine cannot push images to the registry, making the dev-loop push-pull cycle impossible. Add a host-only write token credential type (HTTP Basic, fixed username, password equal to the write token) and a separate write router covering the full OCI push surface: monolithic and chunked blob upload (POST/PATCH/PUT on `.../blobs/uploads/...`), manifest PUT, and the push-side blob dedup HEAD. The write router is gated entirely behind a middleware that validates the Basic credential and rejects anything else — including a Bearer read/control token with the same secret value — before any handler is reached. This enforces the design constraint that a device, which only ever receives a Bearer read/control token, cannot reach a write route regardless of topology. The write router is intentionally kept on a distinct router object to be bound onto a separate listener by later tasks (3.6/3.7); it is never merged onto the bulk read listener. Signed-off-by: Javier Tia <javier@peridio.com>
Previously the auth module only implemented the write-side Basic validator. The read listener and the future control-WebSocket upgrade (task 5.1) had no auth surface defined, meaning any future binding of the read routes would have been open or would have required a separate, independently implemented credential check — risking the two surfaces diverging (design concern G-5). Introduce a single authorization seam, `read_request_authorized`, that both the bulk read listener middleware (`require_bearer_read`) and the control-WS upgrade handler will call. Because both surfaces funnel through the same predicate the WS upgrade cannot accidentally implement a looser or different check. The validator enforces scheme separation (M-2): a Basic write credential is rejected on a read route by construction, since `Bearer` and `Basic` are distinct schemes. The `read_router` public constructor is updated to accept a `ReadToken` and apply the gate, while the internal `read_routes` assembly remains ungated so existing handler-semantics tests are not burdened with auth noise. Signed-off-by: Javier Tia <javier@peridio.com>
Without a sweep path, the per-project blob store accumulates layers indefinitely. Orphaned blobs left behind by retagged or abandoned pushes are never reclaimed, growing disk usage without bound. There is also no protection against a `prune` command racing with a device actively pulling layers, which could sweep a blob the pull still needs and leave the device with a broken image. Introduce an explicit GC path (`collect_garbage`) that walks all currently-tagged manifests, follows their references transitively (including multi-arch indices to their sub-manifests), and removes any blob on disk that is not reachable from a live tag. GC is deliberately invoked only from `prune` and `down`, never implicitly during a push or on a timer, to keep the sweep boundary predictable. To guard against the mid-pull race, a reference-counted `PullGuard` RAII type is added; `begin_pull` increments a shared atomic counter and the guard decrements it on drop. `prune` checks the counter and returns `PruneWhilePulling` if any pull is in flight, while `collect_garbage` (used by `down`, which tears down listeners first) bypasses that check unconditionally. Signed-off-by: Javier Tia <javier@peridio.com>
Container Dev Mode needs mutually-authenticated TLS between the host registry and the device, but previously had no mechanism to generate per-project certificates or session tokens. Without this, the bulk-read and control-WS listeners have no credential material to bind against, and the bootstrap payload delivered to the device has nothing to pin the host with. Introduce the tls module (task 3.6) which mints, in a single operation, a per-project CA and a CA-signed server leaf whose SANs cover the runtime name, the QEMU guest-to-host alias (10.0.2.2), and the loopback address. The notBefore is backdated to year 2000 so that RTC-less devices that cold-boot at the Unix epoch or their firmware build date still fall inside the validity window. The CA private key is consumed during leaf signing and deliberately never stored as a struct field, making it impossible for it to reach any serialized payload. The device receives only the CA certificate and the read/control Bearer token via BootstrapPayload; the host-only Basic write token and the CA key are structurally excluded from that type. Both tokens carry 256 bits of randomness and rotate independently on every mint. Signed-off-by: Javier Tia <javier@peridio.com>
The OCI read router previously had no dedicated bound socket, meaning bulk blob transfers had no guaranteed separation from the control WebSocket channel. Without this separation, a large layer pull could head-of-line-block control frames on the same byte stream, violating the three-listener isolation model required by the session design. Introduce BulkListener, a self-contained handle that binds a TCP socket, wraps it with TLS termination using the per-session leaf certificate, and serves the token-gated OCI read router exclusively on that socket. The TLS layer is handled by a custom axum Listener implementation that absorbs transient accept errors with a brief back-off and silently drops failed handshakes, keeping the server loop running without surfacing per-connection noise. Because bulk reads live on their own socket, devices handed only this endpoint cannot reach the write listener, and no blob body can arrive as a WebSocket frame. Dropping the BulkListener handle aborts the backing task, so the socket lifetime is tied directly to the session lifecycle. Signed-off-by: Javier Tia <javier@peridio.com>
The Container Dev Mode watcher needs to observe the host container
engine for image tag events so it can trigger layer sync to the device
on rebuild. Without a well-defined engine abstraction, both the event
stream logic and credential injection would need to be duplicated or
entangled across Docker and Podman code paths.
Introduce an EngineDriver trait that captures everything engine-specific
about watching for tag events and injecting push credentials. Two
concrete drivers are provided: DockerDriver, which drives `docker events
--format {{json .}}` and injects credentials via an ephemeral
DOCKER_CONFIG directory, and PodmanDriver, which drives `podman events
--format json` and injects credentials via per-invocation `--creds`.
Both drivers communicate exclusively through the engine CLI subprocess,
never through the API socket, so a rootless Podman installation without
a running `podman.socket` works correctly. The engine-agnostic
`forward_tag_events` and `watch_tag_events` helpers drive whichever
engine driver they receive, meaning the watcher orchestration and push
wiring added in subsequent tasks reuse this plumbing unchanged.
Signed-off-by: Javier Tia <javier@peridio.com>
Currently there is no mechanism to detect image rebuild events, select the correct transfer strategy for the host topology, and notify the device after layers are synced. Without this, the container dev-mode loop has no way to react to a `docker build` or `podman build` and propagate the result to the embedded registry on the device. Introduce the watcher module that consumes tag events from the engine driver, debounces rapid rebuilds to a single sync of the latest tag, and supersedes any in-flight transfer when a newer event arrives. The sync strategy is chosen by explicit host-topology detection rather than emergent behavior: native Linux and the avocado-vm take a delta PUSH into the embedded registry, while Docker Desktop and podman-machine without the VM take a full-image INGEST export as the only reachable fallback. The notifier and syncer are expressed as seams so the control WebSocket (task 5.1) and the concrete engine transfer can be wired in later without coupling this module to either. Signed-off-by: Javier Tia <javier@peridio.com>
Syncing a container image built for one CPU architecture to a device running a different architecture results in a silent wrong-arch delivery. The container engine on the device may fail to run the image, or in the case of a multi-arch manifest, pull silently but never execute correctly. There was no check to catch this before the image was pushed or the device was notified. Introduce an architecture guard decorator that sits in the sync path and probes the image's platform architecture before delegating to the real syncer. The guard compares the image's canonical GOARCH architecture against every connected device's reported architecture, sourced from their `hello` control frames. A single mismatched device refuses the entire sync with an actionable error that names the device's target platform and provides the correct `docker buildx build --platform` invocation. Because the guard returns an error before the wrapped syncer runs, neither the push nor the device notification is ever triggered for a mismatched image. An arch normalization layer reconciles `uname -m` spellings (e.g. `aarch64`, `x86_64`) with GOARCH spellings (e.g. `arm64`, `amd64`) so comparisons are reliable regardless of which convention the source uses. Signed-off-by: Javier Tia <javier@peridio.com>
Without a control channel, the host has no way to push image availability notifications to connected devices or reconcile a device that reconnects with a stale running digest. Bulk transfers are handled by a dedicated HTTPS listener, but there is no signalling path to tell a device when and what to pull, nor to detect that a device came back out of sync. Introduce a control-only WebSocket server that exchanges lightweight control frames between host and device. The host sends a single Sync frame carrying image coordinates and a digest reference; the device responds with Hello, Progress, and Status frames. Blob bytes never travel this channel by construction: HostFrame has no variant that could carry them, making an accidental bulk transfer a compile-time error rather than a runtime constraint. Two invariants are load-bearing. Desired state is re-derived entirely from the engine's current watched tags at every up, with no disk-restore path, so a digest that changed while the host was down is always reflected rather than silently restored from a stale snapshot. On each device reconnect the host compares the reported running digest against the desired state and issues reconcile Sync frames for every entry that does not match, driving the device back to current without manual intervention. Authentication reuses the shared read/control-token validator that the bulk listener's middleware already calls. The WebSocket upgrade is an HTTP GET carrying the same Authorization header, so the upgrade callback delegates directly to that function rather than implementing a separate auth surface. ControlServer also implements the watcher's Notifier seam, broadcasting a Sync frame to every connected device when the watcher reports a new tag and updating the desired state so later reconciles compare against the freshly pushed digest. Signed-off-by: Javier Tia <javier@peridio.com>
Previously the `avocado container dev up`, `down`, and `status` subcommands were unimplemented stubs that returned a not-yet-implemented error. The command tree, `--help`, and completion wiring could be exercised, but no actual dev session could be started, monitored, or stopped. This left the Container Dev Mode feature entirely inoperable. Implement the full `up`/`down`/`status` lifecycle. `up` mints fresh TLS material and both session tokens, binds a dedicated bulk read listener on all interfaces and a loopback-only write listener on a separate port, starts the engine-driver watcher and control WebSocket, auto-detects the reachable host IP against the target device (with `AVOCADO_CONTAINER_DEV_HOST`/`PORT` overrides), delivers the bootstrap payload once over SSH, and then runs in the foreground until SIGINT or SIGTERM. `down` signals the foreground `up` process to shut down via SIGTERM and clears the session state file. `status` reads that same state file and surfaces a re-bootstrap warning when any device has presented a stale token. The accompanying `bootstrap` module provides the testable primitives these commands depend on. `DeviceBootstrap` is structurally constrained to carry only the bulk endpoint, the read/control token, and the CA certificate — there is no field for the write token or write-listener address, so neither can ever appear in a serialized payload delivered to a device. `WriteListenerGuard` runs its teardown closure from `Drop`, ensuring the routable write listener is torn down on any exit path, clean or unclean. `TokenRegistry` keeps a rotated-out read token valid until its in-flight bulk connections drain to zero or a hard ceiling elapses, rather than using a fixed timer that would issue a mid-stream 401 to an in-flight image pull on a slow link. Stale tokens surface as `TokenStatus::NeedsReBootstrap` in `DevStatus` rather than silently retrying. Signed-off-by: Javier Tia <javier@peridio.com>
Previously, `container dev sync` and `container dev prune` both exited immediately with a "not implemented yet" error. Users had no way to manually trigger a one-shot re-push of a watched image to a connected device, nor any way to reclaim disk space used by unreferenced blobs in the per-project store. Implement `sync` as a signal-based trigger: a separate `sync` invocation sends SIGUSR1 to the running `up` process, which drives one pass of the same push-then-notify pipeline the file watcher uses on every rebuild. This reuses the live session's registry write listener, engine syncer, and control WebSocket, so the notification reaches the device without requiring additional SSH connections. If no active `up` session exists, `sync` reports this clearly rather than silently doing nothing. A failed re-push surfaces as an error and suppresses the device notification, preserving the invariant that a device is never told an image is ready when the push did not land. Implement `prune` as a thin wrapper over the existing per-project store GC policy: it sweeps blobs unreferenced by any currently-tagged manifest, refuses to run while a device is mid-pull to avoid sweeping a blob a pull still needs, and deliberately leaves the session token and CA material untouched since those live outside the store's registry tree. Signed-off-by: Javier Tia <javier@peridio.com>
The control WebSocket accepted plain TCP and ran the WebSocket upgrade directly on the raw stream, so the host<->device control channel carried sync frames and the reconcile handshake in cleartext. The design binds the control channel to the same per-project pinned-CA TLS the bulk listener uses (D8/D9), and the device agent dials wss:// pinning the session CA -- against a plaintext listener that connection cannot complete and the pinned-CA guarantee does not hold. Make the connection-handling core generic over the transport and add a serve_tls entry that handshakes each accepted stream with the session leaf before the upgrade, mirroring the bulk listener's TlsListener. The plain-TCP serve entry is gated cfg(test) so no production path can bind the control WS in cleartext, and the shared read/control-token validator seam is preserved rather than duplicated per transport. Signed-off-by: Javier Tia <javier@peridio.com>
The container dev mode registry and control WebSocket expose two distinct authentication surfaces: a Bearer-gated TLS bulk read listener and a Basic-gated plain-HTTP write listener. Without interface-level tests exercising the real listeners, regressions that accidentally serve unauthenticated requests or accept the wrong credential type on the wrong interface would go undetected until runtime. Add a dedicated integration test file that spins up live listeners from a real DevSession and drives them with a pinned-CA client, asserting that each auth gate rejects exactly the requests it must reject. The suite covers five failure modes: unauthenticated reads and WS upgrades being served, unauthenticated writes succeeding on either interface, the Bearer read token being honored on write routes (compromised-device scenario), a wrong-password Basic credential being accepted on a write route, and the Basic write token being accepted on a read route. Each test is written as a falsifier so that any gate removal turns a passing assertion into a failure. Signed-off-by: Javier Tia <javier@peridio.com>
Without integration-level coverage, the arch guard introduced in task 4.3 could regress silently: a future change might accidentally allow a mismatched-arch image to pass through to a device that cannot run it, and no test would catch it. Unit tests on individual functions are insufficient because they do not verify that the guard, the device-arch book, and the syncer compose correctly under realistic call patterns. Add a dedicated integration test file that exercises the real guard types (`ArchGuardSyncer`, `HelloArchBook`, `check_arch`, `ArchMismatch`, `DeviceArch`) end-to-end using only two narrow test doubles: a fixed `ImageArchProbe` standing in for an engine `image inspect`, and a `ShipRecorder` that counts actual sync calls. This pairing makes refusals and passes concretely observable: a refusal is proven by an `ArchMismatch` error AND a ship count of zero, while a pass is proven by a ship count of exactly one. The suite covers the full decision matrix: single-device mismatch, single-device match, mixed fleet where one mismatched device refuses the whole sync, homogeneous matching fleet, and the canonical `uname`/GOARCH spelling equivalence between `aarch64` and `arm64` that must not trigger a spurious refusal. Signed-off-by: Javier Tia <javier@peridio.com>
Previously the control WebSocket listener was bound on an ephemeral port (`0.0.0.0:0`), meaning the assigned port was unknown at bootstrap time. The device agent receives its connection parameters once, at bootstrap delivery, so a port that is only discovered after binding can never be communicated to the device. This made the control channel unreachable in practice. Bind the control WS on a fixed, configurable port (default 5600, overridable via `AVOCADO_CONTAINER_DEV_WS_PORT`) and include its device-reachable address as a first-class `ws_endpoint` field in `DeviceBootstrap`. The endpoint is resolved using the same device-reachable host as the bulk listener, keeping the two endpoints symmetric and consistent with the existing host-resolution logic. The control-WS listener remains structurally distinct from both the bulk read listener and the write listener; the write-listener address is still never disclosed to a device, and the bootstrap payload still carries no field for it. Signed-off-by: Javier Tia <javier@peridio.com>
The container dev mode write listener served plain HTTP, which worked for native Linux because Docker's built-in loopback exemption (127.0.0.0/8) allows insecure connections. However, when the container engine runs inside an avocado-vm guest, it reaches the host listener through the QEMU SLIRP alias 10.0.2.2, which is not inside Docker's trusted loopback range. The guest daemon therefore requires HTTPS, and before this change any push from an avocado-vm engine would fail at the transport layer. A second problem existed even when the transport was corrected: axum's default 2 MiB body limit caused any real image layer upload to be rejected with 413 mid-stream, making the write path unusable for non-trivial images regardless of topology. Introduce topology detection that selects either the native loopback plain-HTTP path or the avocado-vm authenticated HTTPS path at `up` time. On the VM path the write listener terminates the same per-project leaf TLS as the bulk and control listeners, bound to a known port so the guest's certs.d trust directory and the pushed image tag can both be keyed on the identical 10.0.2.2:<port> address. The per-project CA is delivered into the guest engine's docker trust store over SSH at `up`, never baked into the VM overlay, satisfying the per-connection delivery requirement. The write path is authenticated with a host-only Basic write token; the device-delivered read token is never accepted on a write route. Lift the body limit on the write router so large image layers are accepted. Add a verification script and tests covering each falsifiable property of the VM write path.
Without integration-level tests, the two core properties of the container dev mode sync loop have no falsifiable coverage: that a one-line change transfers only the changed layer (not the whole image), and that a device reporting a stale digest receives a sync frame over the control WebSocket prompting it to pull and restart. Add an end-to-end test suite that exercises both properties against the real listeners a device talks to in production. The delta-pull test drives the write listener and bulk TLS listener over a shared content-addressed store, verifying that byte-identical blobs are never re-transferred after a top-layer change. The control-WS test dials the TLS-upgraded WebSocket with a pinned session CA and a Bearer token, sends a Hello carrying the stale running digest, and asserts that the host responds with a sync frame carrying the new digest. Together these tests catch regressions in the store deduplication logic and the reconciliation path without requiring a real device or container runtime. Signed-off-by: Javier Tia <javier@peridio.com>
…aims The shared-lock change fixed half of what its own comment claimed. `LOCK_EX` conflicts with a held `LOCK_SH` exactly as it does with another `LOCK_EX`, so switching the liveness probe to shared fixed probe-vs-probe and left probe-vs-`up` untouched: an IDE task polling `status` could land inside the window and make a legitimate `up` abort with "another `up` is already running" when none existed, pointing the user at a `down` that would do nothing. Verified the conflict directly before believing it. `acquire` now waits out a brief hold rather than failing on the first EWOULDBLOCK. That separates the two cases on duration instead of guessing: a probe's hold is over in microseconds, while a competing `up` holds the lock for its whole lifetime and is still holding it when the window expires. Three of the four lock tests could not have caught this, because none created two concurrent holders - they ran sequential probes on one thread and `session_is_live` releases its flock on every return, so all of them passed with `LOCK_EX` restored. Replaced with tests that hold a real shared lock across an `acquire`, and one that asserts the property which actually distinguishes the two modes: with an exclusive probe, a concurrent reader makes `session_is_live` report a session live when nothing owns it - a false positive that would have `down`/`sync` signalling whatever pid the stale record held. Device-supplied text no longer reaches the terminal raw. `print_warning` is a bare println with an ANSI prefix, and both `device_id` and `arch` come off the wire - `DeviceArch::parse` returns the raw lowercased input for anything it does not recognize. A device holding the read token could put `ESC[2K\\r` and a forged green success line in its `device_id` and overwrite the refusal warning, so a refused sync would read on screen as a completed one. The staging test asserted a counter, not staging. It checked `upload.written()`, a u64 incremented in `append`, while claiming that proved write-through - so rewriting `BlobUpload` to accumulate into a `Vec` and only write inside `finish` left it green, reintroducing the whole-layer-in-memory behaviour this work exists to remove. It now stats the staging file between chunks and fails against exactly that implementation. Its second, unrelated assertion is split out. Two doc corrections, both of which would have misled the next reader rather than merely being untidy. The arch test claimed unwiring the guard in `up` would fail it; nothing in the suite touches `DevUpCommand`, so that gap is named as unclosed instead of claimed as covered. And the TTL comment still described the buffered path - a `Bytes` extractor that no longer exists, an in-flight session the sweep can no longer see, and a throughput bound derived from a removed constant - while prescribing middleware that would duplicate what remove-on-entry already does. The resource at risk is disk now, not memory. Signed-off-by: Javier Tia <javier@peridio.com>
…hole Moving uploads to a streaming write removed the property that a layer had to fit in host memory before it could reach disk. A chunked push used to accumulate in a per-session buffer, so an oversized layer failed at push time and never landed; it now writes straight through, so a 40 GiB layer stores and tags successfully on a 32 GiB host. serve_blob still read the whole object back via read_blob, which turned that into a worse failure than the one it replaced. The first device GET of such a blob sizes a single allocation by the blob and the OOM killer takes the `up` process down - with it all three listeners, the watcher, and the session's minted TLS material. A Range request allocated the window a second time on top of the full buffer. Unlike the old push-time failure this one is persistent and repeats on every pull, because the oversized blob is already stored. Add BlobStore::open_blob, returning a handle and the size, and serve both the full body and the range window through ReaderStream. The range case seeks and takes rather than slicing a buffer, so neither path sizes an allocation by anything the host chose. Manifests keep using read_blob: they are capped at 32 MiB and the media-type sniff needs the bytes. open_blob returns a std::fs::File so the store stays synchronous; the caller wraps it for the runtime it serves on. The full-body response now sets Content-Length explicitly, which a streamed body does not get for free and engines use for pull progress. tokio-util was already in the tree via axum and tokio-tungstenite, so the dependency is one new edge in Cargo.lock rather than a new package. Tested by frame count rather than by allocation: a Body built from one Vec carries exactly one data frame however large it is, while a streamed body carries one per read, so >1 frame is the discriminator. Driven through oneshot because over TCP the chunk boundaries a client sees come from coalescing, not from how the handler built the body - counting socket reads would have proven nothing. Both mutations were checked in isolation: buffering the full path fails only the full-blob test, copying the window fails only the range test. 1279 lib tests and every integration target pass; the pre-existing range and suffix-range assertions are unchanged. Signed-off-by: Javier Tia <javier@peridio.com>
The bootstrap file carries the Bearer read/control token, and delivery
wrote it and then corrected the mode:
... | base64 -d > path && chmod 0600 path
The redirect creates the file at the remote shell's umask with the token
already written, so it sits on disk world-readable for the width of two
commands - 0644 under a default 0022, measured. Any local user on the
device can read it in that window, and the token is enough to pull from
the session's registry.
Put a umask in force at creation instead, so the mode is right from the
first byte and there is no second step to race. A subshell keeps the
umask change from leaking into anything else run_command may chain later.
This is also less code than what it replaces: one construct rather than
two, with no ordering to get wrong.
Extracted the command into `bootstrap_delivery_command` to make the
property assertable rather than reviewed by eye - the function exists for
the test, and the commit body is the place to say so.
Two tests, and the split between them is deliberate. The behavioral one
runs the generated command under an explicitly permissive parent umask
and stats the result, which pins the outcome. But it passes against the
old shape too, since chmod also ends at 0600 - so it cannot be the guard.
The window itself is unobservable from a test without racing the shell,
so the discriminating assertion is structural: a umask before the
redirect, and no correcting chmod anywhere. Confirmed by reverting to the
old command and watching exactly that test fail while the behavioral one
still passed.
Raised on the review of avocado-os#46, where I had wrongly reported the
token as unprotected; the chmod was already there, and this is the
narrower issue that survived tracing it here.
Signed-off-by: Javier Tia <javier@peridio.com>
…age id The device pulls `<bulk-endpoint>/<image>@<digest>` using the digest the control-WS `sync` frame carries, but `notify` was populating that field from `TagEvent::image_id`, the engine's LOCAL image id, which is a config digest. No registry can serve a manifest under it, so the device's pull failed while the control frame and the recorded desired state both looked correct; every hot reload silently no-opped. Resolve the tag against the same `BlobStore` the bulk listener serves, so the digest named in the frame is by construction one the device can pull. Refusing outright when the store has no manifest for the tag is what keeps a notify from racing ahead of its push: an unresolvable tag means the push has not landed, and reporting that beats recording a desired state the device can never reach. The store is optional only because the fan-out and reconciliation unit tests exercise `ControlServer` with no registry behind it; `container dev up` always supplies one. Signed-off-by: Javier Tia <javier@peridio.com>
…elled pushes `container_dev.images` documents itself as the list of images to watch, but only the manual `sync` trigger ever read it: `run_watcher` acted on every tag event the engine reported. `EngineSyncer::push` retags onto the registry before each push, so the watcher was syncing in response to its own side effect, and that sync retagged again. One real rebuild produced 1281 failed pushes in the lab before the session was torn down. The 401 those pushes reported was a second, independent defect that the loop merely exposed. A superseding event cancels an in-flight sync by dropping its future, which also drops the ephemeral `DOCKER_CONFIG` tempdir holding the write credential; tokio leaves the spawned child running when its future is dropped, so the orphaned `docker push` kept going against a directory that no longer existed, sent no credential, and got "no basic auth credentials" from the write listener. `kill_on_drop` ties the child's lifetime to the future so cancelling a sync genuinely cancels its push rather than converting it into an unauthenticated one. Applying the watch list is what closes the loop; the process-lifetime fix is what makes a legitimate supersede (two rapid rebuilds) safe on its own. `WatchSet` normalizes both sides to docker's default-tag rule because engine tag events always carry an explicit tag, and without that a legal tagless `ref: my-app` would match nothing and silently stop syncing. Signed-off-by: Javier Tia <javier@peridio.com>
…ults
setup-lab.sh regenerates env.sh on every run, and both of the values it wrote
were wrong in ways that surface far from here.
`AVOCADO_BIN` was hardcoded to `<repo>/target/debug/avocado`. Every avocado
build reports the same `--version` string, so a debug build alongside an
installed one cannot be told apart by the thing people naturally check, and a
run against the wrong binary fails in whatever way that binary is stale. Resolve
the `avocado` on PATH instead, and refuse up front when it has no `container
dev` subcommand - a loud error beats a lab that comes up and then behaves like
the feature does not exist.
`BBAPPEND` defaulted to empty on the belief that empty skips the verify script's
overlay check. It does not: verify-vm-write-path.sh applies its own
`${BBAPPEND:-<default>}`, so empty falls through to a path in a build workspace
that holds an unrelated base-files bbappend, and the check reports "the overlay
does not provision /etc/container-dev" - a 7/8 that reads as a real regression
in the trust-store overlay when nothing is wrong with it. Point the default at a
copy kept with the rest of the generated lab state, and record on the variable
why empty is not a safe value.
Signed-off-by: Javier Tia <javier@peridio.com>
Standing up the lab's demo app was two hand-run steps, and both had a silent failure mode that cost a demo. Building it was a bare `docker build`, so it used whatever DOCKER_HOST the shell carried. A terminal that never sourced env.sh targets the developer's own engine, so the image lands on the workstation, the target never sees it, the watcher has nothing to react to, and `sync` dutifully re-pushes the target's OLD image. The agent then finds its running digest already matches and no-ops. Nothing reports an error anywhere along that path. The script pins the target's engine itself and refuses to run when the socket answers as anything else, so the build cannot land on the wrong machine to begin with. Writing the unit was left to the reader, with only prose warning that ExecStart must re-run `docker run` rather than restart the container. Get that wrong and the loop is silent in the same way: the layer is pulled, the restart succeeds, and the old image keeps running because a container restart re-executes the image ID pinned at create time. Generating the unit puts that detail somewhere it cannot be skipped. BUILD_ONLY=1 rebuilds without touching the unit, which is what makes the reload step honest - restarting the unit would adopt the new image directly and demonstrate nothing about the watcher, push, notify and agent path. The final version check exits non-zero when the app is not reporting what was just built, so a green run means the app really is up rather than that the commands merely ran. Signed-off-by: Javier Tia <javier@peridio.com>
…y action The lab drives two docker daemons - the workstation's and the target's - and nothing in the commands said which one was in play. Every failure that cost time during the demo came from that: an image built against the wrong daemon lands somewhere the target cannot see, and the loop then reports success at every step while the app never changes. The runbook could annotate the commands, but a reader copying a block does not carry the annotation with it. Make the tooling say it instead. Each action prints where it runs, what it reaches, and why, before it does anything, and the two mutating paths resolve the target's daemon and refuse when the socket answers as anything else. `status` answers "where is everything" in one call: both daemon identities, the three registry endpoints, session and agent state, and the digest the target is actually running. The demo app now reports its own machine via `--hostname %H`, so a log line reads `running-on=avocado-vm-lab`. Left alone a container reports its container ID, which says nothing about which side of the loop produced it - and "did this actually move on the target" is the one question the whole demo exists to answer. Folding install-demo-app.sh in: one entry point with subcommands beats remembering which of several scripts owns which step, and `all` runs the sequence end to end. Signed-off-by: Javier Tia <javier@peridio.com>
The lab ran the target's own engine as the build engine, so one machine played both roles and "which docker am I talking to" had no obvious answer. The CLI picks its topology off DOCKER_HOST alone - is_vm_routing_active() is true iff it equals the avocado-vm socket - so the split needs no second VM, just a mode that leaves that variable alone and reaches the target only over ssh. That is also the real shape for a Linux developer with a board, so the demo stops being a special case. Running it that way immediately showed something vm mode could not. The agent pulls `127.0.0.1:<port>/<image>@<digest>` and never tags it, so the image arrives on the target as a dangling <none> entry and the unit's `docker run <image>:<tag>` has nothing to resolve - while the agent still logs "sync complete: pulled, container restarted". vm mode looked fine only because the image was built on the target, so the tag was already present and already new; the pull was redundant and the delivery path was never under test. Recorded as a known gap rather than fixed here, since the fix belongs in the agent alongside its own tests. The remaining changes follow from wanting one entry point that works against hardware too: TARGET_PLATFORM cross-builds through buildx and, because that is BuildKit and emits no tag event, triggers the sync itself rather than waiting on a watcher that cannot see it; LAB_VM=0 makes setup and verify refuse instead of trying to boot a VM that is not there. Session lookup no longer greps argv for "container dev up". That pattern matches any process whose command line happens to contain the phrase - including the shell invoking the script, which then kills its own caller. It matches the process name and confirms against /proc/<pid>/cmdline instead. Signed-off-by: Javier Tia <javier@peridio.com>
The header warned that native mode delivered layers but left the service unable to resolve them. avocado-os 3aff9ee tags the pulled digest as the ref the unit names, so that no longer holds: a target wiped of every my-app image now ends up running the workstation's build. Signed-off-by: Javier Tia <javier@peridio.com>
…ption The script forced DOCKER_BUILDKIT=0 on every same-arch build, on the belief that BuildKit emits no image tag event and so the watcher cannot see it. That belief came from measuring one daemon - the lab guest's docker 20.10.24 - and reading the result as a property of BuildKit. It is a property of the daemon. Docker 29.6.2 emits `image tag` for a BuildKit build; 20.10.24 emits nothing at all. So the workaround was being applied to daemons that never needed it, and pinning the demo to a builder Docker has already deprecated. Gate on the server's major version instead: 23 and above build with BuildKit, older fall back to classic. The context header reports which was chosen and why, so the reason travels with the run rather than living in a comment. Cross-arch is unaffected by the version and still needs the explicit trigger: it requires buildx, and a buildx build emits no tag event whatever the daemon. Also declare TARGET_PLATFORM and LAB_VM up front - both were documented and read but never given defaults, so `set -u` aborted any run that did not export them - and quiet ssh on the target-engine path, whose "Permanently added" warning (the lab alias sets UserKnownHostsFile=/dev/null) was landing inside captured output and being reported as the app's own log line. Signed-off-by: Javier Tia <javier@peridio.com>
`container dev up` could not bootstrap an Avocado OS device at all. The delivery embedded the payload as base64 in the remote command and decoded it with `base64 -d`, and Avocado OS has no `base64` - the target's coreutils are BusyBox, which does not carry that applet. Every `up` against a real device died with `sh: base64: not found` before writing bootstrap.json, so the agent's ConditionPathExists never fired and no session could ever form. A Debian stand-in hid this for the whole development of the feature, because Debian ships GNU coreutils. Reaching for a different decoder would only move the assumption: openssl and python3 happen to be present on this runtime but neither is guaranteed on a minimal one. Send the payload on ssh stdin instead, so the device needs nothing but its shell. That also drops the base64 round-trip that existed only to survive shell quoting, and keeps the Bearer read/control token out of the device's process list, where the encoded form was previously visible in argv for the life of the command. The same change applies to the per-project CA delivered into the engine guest's docker trust store, which had the identical dependency. Signed-off-by: Javier Tia <javier@peridio.com>
…d-in The lab booted a Debian 12 cloud image as its "device" because that was quick to stand up and only needed docker plus sshd. Avocado OS is the OS the feature ships on and every target board is expected to run it, so that substitution was testing the wrong system - and it actively misled: Debian 12's docker 20.10.24 emits no image event for a BuildKit build, which got generalised into a property of BuildKit and cost a ticket, a docs caution and a hardcoded workaround. The real target runs docker 25.0.9, where a plain `docker build` drives the whole loop. setup-lab.sh now builds and provisions a real Avocado OS runtime from the published feed, sourcing the unpublished agent extension from the local avocado-os checkout so the SDK cross-compiles it for the target ABI. It boots the provisioned image with host QEMU rather than inside the SDK container, because the target has to be a separate machine reached only over ssh for the topology to mean anything. Three things the swap exposed, each fixed at its cause rather than worked around: The agent learns which unit owns the container only from AVOCADO_CONTAINER_DEV_SERVICE, and nothing delivers it - the bootstrap payload has no such field, so the declared `service:` never reaches the device and the agent silently falls back to restarting the container, which re-runs its pinned image ID. setup-lab.sh installs the drop-in. `app` used to install the unit and immediately assert a baseline. That only ever worked because vm mode built the image on the target; with the host building, a virgin target has no image and nothing can put one there until the session and agent exist, because delivery IS the loop. Split the delivery into `seed`, which runs after both. The target's daemon reports its own hostname, so comparing it against the ssh alias could never match, and the reported write endpoint was a hardcoded 10.0.2.2:5601 - a pair that never existed, since the listener binds an ephemeral loopback port. Both now read the real values, which matters because the push credential is keyed on the tagged host:port. Verified end to end from a target wiped of every image: v1 delivered over the loop, then a host-only rebuild hot-reloaded it to v2, 0 auth failures. Signed-off-by: Javier Tia <javier@peridio.com>
The runtime carried no CA bundle, so the target's engine could not verify TLS to any public registry. A guest-side `docker build FROM busybox:latest` died with `x509: certificate signed by unknown authority`, which held the authenticated-write verify at 7 of 8 - the one failing check is the only one that builds on the target rather than the host. Container Dev Mode's own traffic is unaffected either way, because it pins the per-project CA rather than trusting a public root. That is exactly why this went unnoticed: the loop works fine on a target that cannot reach Docker Hub, and only a step that pulls a public base image exposes it. A real target is expected to pull base images, so the omission was in the runtime, not in the check. avocado-ext-ca-certificates is published in the feed for this target, so adding it to the runtime is the whole fix. It costs ~72 MiB of image (938 -> 1010 MiB), which stays under the 1024M the SDK's vm script unconditionally resizes to - worth noting, because exceeding that line turns its resize into a refused shrink. Verify is 8 of 8 and the hot-reload loop still lands v1 then v2 with no auth failures. Signed-off-by: Javier Tia <javier@peridio.com>
16091b8 to
ad111ee
Compare
… text Step 4's second assertion grepped the base-files bbappend for the literal "container-dev" and reported PASS. The only thing that observes is whether the file contains that substring, so it passed for a typo'd path, for the directory moved to a different tree, and for the install line deleted leaving only a comment - three mutations that all print PASS. A check that cannot fail for the reason it names is worse than no check, because it reads as coverage. The claim was also aimed at the wrong path. The CLI delivers the CA to /etc/docker/certs.d/<registry>/ca.crt and creates that directory itself with mkdir -p at `up` time, so nothing reads /etc/container-dev and there is no location for the image to provision. Step 1/4 already asserts the real thing against the live guest by running openssl on $GUEST_CA over ssh, which is where a path claim belongs - build metadata cannot tell you where a file landed at run time. Keep the first assertion. It is a negative - no cert baked into the image - and a source grep is the right instrument for a negative: it fails when a cert appears, which is exactly the falsifier it names. Verified against a fixture that bakes one. Delete the positive counterpart rather than repairing it, and say in the comment why there is none, so it does not come back. Signed-off-by: Javier Tia <javier@peridio.com>
jetm
left a comment
There was a problem hiding this comment.
Second-reviewer pass over the Rust sources of this branch, read cold. Reviewed src/commands/container/dev.rs, all of src/utils/container_dev/, and the src/main.rs / src/commands/mod.rs / src/utils/{mod,config,container,remote,runs_on,runtime}.rs and src/commands/runtime/{build,deploy}.rs hunks the diff touches. Deliberately out of scope: Cargo.lock and everything under docs/ (read only as evidence for what the lab exercises, never reviewed as an artifact), and the device-side agent, which lives in avocado-os#46.
Reviewed at ad111ee; the head has since moved to 2c8e854, which is a docs-only commit (docs/container-dev/verify-vm-write-path.sh, +13/-11). No Rust source changed between the two, so all eleven inline findings are current.
The first three are what I would fix before anything else. Two images sharing a tag silently serve each other's manifest; the TLS leaf has no SAN for the LAN address up actually advertises, so the documented hardware path cannot connect at all; and both are invisible in the lab because setup-lab.sh:361 pins AVOCADO_CONTAINER_DEV_HOST=10.0.2.2, the one address that is in the SAN set.
One finding could not be posted inline because its line is not inside a diff hunk. src/main.rs:2004, needs_vm_routing(): it is a positive allow-list of command variants and Commands::Container is absent, so ensure_routed_for_process never runs for container dev, DOCKER_HOST is never set, and HostTopology::vm_routing is false by default. On macOS or Windows that means sync_mode() returns Ingest instead of Push, and VmWriteSetup::docker, the routable write bind, the TLS write task, and deliver_vm_ca are all skipped. It was never hit because docs/container-dev/verify-vm-write-path.sh:29 requires a manual DOCKER_HOST export. Not a one-line fix: flipping it true makes dev.rs:274 hard-fail up for anyone without AVOCADO_CONTAINER_DEV_VM set. It pairs with the ingest.tar finding below, which is the path this gap sends every non-Linux user down.
On the red Windows compile check: it is not a code problem. Run 30858362570 job 91834557875 died in Install build pre-reqs (.github/workflows/pr.yml:20-23), where choco install nasm cmake -y got a 504 from the V2 feed on cmake.install 4.4.2 and then failed dependency resolution against cmake.install 4.4.0. cargo check --target x86_64-pc-windows-msvc never ran. Every std::os::unix / libc / tokio::signal::unix use this branch adds is properly cfg-gated (flock, SessionLock, session_is_live, the signal handlers, and watcher.rs:483's PermissionsExt block, with the dev.rs test module at :1099 gated #[cfg(all(test, unix))]). A re-run should clear it; pinning the cmake version, or dropping it since the windows-2025-vs2026 image ships CMake, would stop it recurring.
Verified but below the inline cut, in rough severity order: dev.rs:643 down removes session.json while up still holds the flock, so status prints "not running" with all three listeners bound. dev.rs:366 seeds ControlServer with DesiredState::default() and derive_from_watched_tags has zero production callers, so after down/rebuild/up a reconnecting device reconciles against an empty map and silently keeps the stale image. collect_garbage's doc claims invocation from down and no such caller exists, so one orphaned manifest plus config plus changed layer accumulates per rebuild until a manual prune. dev.rs:71 hardcodes DEFAULT_CONFIG and the ContainerDevCommands variants take no -C, so this is the one command family that cannot be pointed at a non-default config. registry.rs:610 never compares a digest-shaped reference to the computed digest (spec wants 400 DIGEST_INVALID; low reach, real clients compute it themselves). registry.rs:458 ignores Content-Range and there is no 416 path anywhere on the write listener, so a same-Location retry doubles the bytes and surfaces as a 400 at the final PUT. registry.rs:654's .max(start) reports one byte too many for a zero-length chunk; deleting the clamp gives the correct answer. config.rs:43 makes ContainerDevImage::service required while nothing but tests reads it, and setup-lab.sh:297 states outright that nothing delivers it to the device. runs_on.rs:445 adds an unconditional print_failure_notice on a path whose contract is "returns None if it failed", so a benign probe now dumps up to 15 lines of remote stderr over the TUI mid-build.
Two latent ones worth knowing about rather than fixing now: bootstrap.rs:141 write_bootstrap writes the Bearer token at the ambient umask, but it has zero production callers (the live SSH path uses umask 077 with three tests pinning 0600); and watcher.rs:434 puts the write token on podman's argv where run_engine interpolates argv into an anyhow context printed with {e:#}, but dev.rs:401 hardcodes let engine = DEFAULT_ENGINE and nothing selects podman. Both are one line from mattering.
Test-integrity offenders, since CLAUDE.md is explicit here: registry.rs:1731 a_failed_chunk_stream_leaves_the_session_resumable asserts only status codes and map length, never issues a PUT, and never compares a digest, so it passes with resume broken. tls.rs:352 discards server_config() into _config and asserts a substring on the CA cert, so swapping the leaf's key for the CA's still passes. config.rs:130 compares a const to a literal and exercises no code. Against ~178 new tests these are the exceptions, not the pattern.
Cleanup, not defects: four stale #[allow(dead_code)] module attributes in container_dev/mod.rs that suppress nothing (checked with --force-warn dead_code) plus ~20 lines of "wired by a later task" rationale for tasks that have landed; image-ref normalization implemented three times (watcher.rs:287, watcher.rs:332, ws.rs:135) with the copies already diverged, which is the arch-book finding below; hex encoding hand-rolled with a format! per byte in four places when utils::jcs::hex_encode exists; resolve_endpoint formats host:port and dev.rs immediately re-parses it twice; two docker image inspect subprocesses per image per manual sync where one --format '{{.Id}} {{.Architecture}}' would do.
Things I chased that do not hold, named so nobody re-raises them: non-constant-time token comparison (the doc comment justifies via TLS plus a single-developer host and never claims unreachability); write_blob trusting a client-supplied digest (its one production caller computes it server-side); the docker config.json write-then-chmod window (the parent is a 0700 tempdir_in); the minted CA being dangerously unconstrained (docker scopes it per-registry via certs.d/, and nothing installs it in a system trust store); RecvError::Lagged dropping sync frames (tokio advances to the oldest retained message, so the newest idempotent Sync still lands); and IPv6 endpoint mis-splitting (rsplit_once(':') handles both forms, and both listeners bind IPv4-only anyway).
Verified clean: no second rustls crypto provider is linked, no let _ = on a lock guard anywhere in the diff, no danger_accept_invalid_certs, error handling matches the crate's existing anyhow-plus-thiserror split, auth layers cover every route on both routers with WS auth checked before the upgrade and no query-string token path, and RuntimeConfig has no deny_unknown_fields so container_dev cannot break an existing config parse.
Every defect here shares a shape: the layer that was wrong reported
success, and the failure surfaced somewhere else or not at all.
Two make the documented happy path serve the wrong bytes. Manifest tags
were stored in a flat per-project namespace with no repository component,
so `api:dev` and `web:dev` were one pointer: a rebuild of api broadcast
whichever manifest landed last and the device ran web's image as the api
service, with every frame correct and nothing logged. Tags are now keyed
on `(repository, tag)`, with the name escaped into one path segment so a
legal `library/alpine` cannot collide with the tag beneath it. And the
server leaf's SAN set was fixed at {runtime, 10.0.2.2, 127.0.0.1} while
`up` advertised the auto-detected LAN address, so a real board's agent -
pinned CA, stock rustls verifier - failed hostname verification on both
listeners. The leaf now carries the address actually advertised, which
means minting after endpoint resolution rather than before. Neither was
visible because the lab pins AVOCADO_CONTAINER_DEV_HOST=10.0.2.2, the one
address already in the set.
The cross-arch guard was keyed one way and read the other: recorded under
the raw event ref, looked up under the registry-stripped one, so
`arch_for` answered None for every qualified ref and the filter's
permissive arm passed the frame. podman writes local refs as
`localhost/…` and the watch set is an exact match, so a podman user must
configure the qualified ref for the watcher to fire at all - making this
every ref on that engine. Three copies of the normalization are how the
keys drifted apart; they are now one module with two deliberately distinct
normal forms, because `WatchSet` must keep the registry (canonicalizing
there would match the watcher's own retag and loop).
`prune`'s mid-pull refusal could not fire. The counter was per-instance
and `prune` builds its own store in a different process from `up`, so it
read zero regardless, and `begin_pull` had no production caller at all -
while five doc comments asserted the guarantee and the guarding test
called `begin_pull` itself. The counter is gone; the refusal now keys on
the session flock, which is the only cross-process proof available. That
matters most for `sweep_uploads`, which unlinks by path into the directory
a live push is streaming through: `up` keeps writing to the open fd, so
the client sees a healthy upload to 100% and the final PUT fails in
`persist()` with ENOENT after the whole layer has moved.
GC had two ways to stop working. It read whole blobs to look for manifest
children, sizing one allocation by the largest reachable layer, so a 6 GB
layer OOM-killed prune on an 8 GB host with the store left un-GC'd - it
now checks the size first, since a manifest is kilobytes. And a `.tmp`
file left in `blobs/` by a SIGKILLed write made every later prune return
InvalidDigest and sweep nothing, recoverable only by hand;
`reachable_digests` already skipped that error thirty lines away.
`up` published its pid as a signalable target before registering either
handler, so a `sync` or `down` during the slow startup - TLS mint, a DNS
plus UDP probe, three binds, an events fork - killed it outright, the
SIGTERM window reaching past both SSH round trips and skipping every
teardown. Both streams are registered before the record is written.
The control WS returned from its accept loop on any error, so one
ECONNABORTED ended it for the session while `status` still reported live;
it now backs off and continues like the bulk listener it claims to mirror.
The events forwarder swallowed its result, so restarting the engine daemon
left `up` printing "Watching for image rebuilds..." forever; it now says
what happened and what to do.
INGEST is made to fail rather than pretend. It ran `save -o ingest.tar`
and returned Ok, but nothing in the tree ever read that tar, so the sync
reported success and `notify` then failed pointing at a push never
attempted. Every Docker-Desktop user without the routed VM took that path
on every rebuild. Failing where the path is chosen, with the remedy, beats
a success that unravels one layer down.
Signed-off-by: Javier Tia <javier@peridio.com>
jetm
left a comment
There was a problem hiding this comment.
Verified all twelve findings from review 4849090587 against the pushed head 2badb34. Eight are fully closed and I have nothing to add on those: the ingest bail, the begin_pull deletion (the flock via session_is_live is the cross-process proof the finding wanted, and all five overstating doc comments were rewritten), sweep_uploads following from the same change, early signal registration, the image_ref module collapsing the three normalization copies, the stray-.tmp skip, and the serve_tls backoff. On that last one I checked the other accept loop at ws.rs:400 that still has the else { return; } shape - it is #[cfg(test)]-gated with no production caller, so it is not a live second instance.
Three are partial and one was not fixed. Seven inline; the eighth cannot anchor.
needs_vm_routing() still omits Commands::Container. src/main.rs is not in the diff; main.rs:2004-2030 still lists fifteen variants plus Commands::Connect { command: ConnectCommands::Upload } and no Container arm. This one lived in the review body rather than a thread, so it had no thread to answer, and there is no PR-level comment either. The coupled side moved instead - ingest now bails loudly rather than writing a tar nobody reads - which is a genuine improvement, but it turns the gap into a misleading remedy string rather than closing it. Detail on the watcher.rs thread.
Test integrity. All three named offenders are untouched. a_failed_chunk_stream_leaves_the_session_resumable (registry.rs:1738) still asserts only status codes and map length, never issues a PUT, never compares a digest. mint_builds_a_server_config_from_the_leaf (tls.rs:436-445) still discards server_config() into _config and asserts on the CA cert PEM, so swapping the leaf's key for the CA's still passes - only the mint call signature was threaded through. config.rs is not in the diff at all, so default_registry_port_is_not_5000 still compares a const to a literal.
I reasoned about the revert edit for each new test rather than assuming they bite. Most do: the two-repository tag test, the slash-key test, the GC tag-walk test, all three SAN tests, the arch-recording test, the store-side prune-refusal tests, and the stray-.tmp test all fail on a coherent revert. Three do not, and they are worth knowing about. a_registry_qualified_ref_is_still_arch_filtered_on_the_broadcast pins the recording side only - the frame's reference is already my-app:dev, so canonical(&reference) is the identity there and reverting ws.rs:543 leaves it green. Reverting dev.rs:705 to a hardcoded SessionActivity::Idle leaves everything green, so the wiring of the flock into prune is unpinned even though the store-side check is well covered. And the GC size-guard test is a new offender in its own right - detail inline. Findings 7, 11, 12, and 4 got no test at all, so reverting any of those four is silent.
CI is green, including Windows compile check - run 30864480584, job 91853231339, SUCCESS, so the previous red really was the Chocolatey 504 and a re-run cleared it. The workflow itself was not changed, so .github/workflows/pr.yml:20-23 still runs the unpinned choco install nasm cmake -y and the same flake can recur. Worth pinning the version, or dropping cmake since windows-2025-vs2026 ships it.
On thread resolution. All 40 threads report resolved, including all eleven from that review, each with a substantive reply. Three of those resolutions are premature given the partials above. Two of the replies are accurate about what was done but silent on what was not - the migration question goes unmentioned, and the lab gap is acknowledged as the reason the bug was invisible without either fixing it or stating it as a deferral. One small correction: the tag reply says reverting tag_path to the flat form fails ten tests. That holds only for an incoherent partial revert with the nested list_tags still in place; a coherent revert of both fails two. Still falsifiable, just not ten.
Everything above is from reading the head plus the diff between the two SHAs. I did not run the suite myself - CI's green is the evidence for that - and no real pinned-CA handshake from a device was performed, which is what would actually settle the SAN fix.
Every run of setup-lab.sh printed two errors before doing anything: ERROR: docker: 'docker buildx build' requires 1 argument setup-lab.sh: line 95: x509:: command not found The heredoc that renders avocado.yaml was unquoted so $TARGET and $AGENT_EXT would expand, which also made every backtick in its prose a command substitution. A comment added later happened to mention `docker build ...` and `x509: ...` in backticks, so bash ran both as commands on every invocation and substituted their empty stdout into the file. The generated config silently lost that text; it stayed valid YAML, being comments, which is why nothing failed and the errors read as unrelated noise. Quoting the delimiter makes the whole block inert, so no comment anyone adds later can execute, and the two values that genuinely vary are substituted afterwards where they are visible on their own lines. A guard fails the script if a placeholder survives, since handing avocado a config with an unfilled slot is worse than stopping. This is the second time this script executed its own prose - the first was a `config` in backticks in the ssh-config heredoc. That one got a comment warning, which did not prevent the recurrence four commits later. Structure does: the remaining three heredocs still expand because they must, and none of them contains a backtick or a $( ). Signed-off-by: Javier Tia <javier@peridio.com>
A second run against a target left over from a previous demo failed at the first step: says app v2-RELOADED ... !! app is not reporting 'v1' Both places that asked "does the target have the image" tested whether the TAG existed. A previous run leaves `my-app:dev` on the target pointing at a DIFFERENT image, so the check passed while the host's fresh build had never been delivered. `app` took that as licence to start the unit and assert the version it had just built, and the stale container answered with the old one. The same check in `seed` would have fallen through on its first tick and restarted the unit before the pull landed. Compare image IDs instead. An ID is the digest of the image config, which a push/pull round trip preserves - confirmed by finding the target's running image present on the host under the previous session's registry tags with the same ID - so an ID match is exactly the question "is what the target holds the thing I built". `app` also loses its "unless the target already has it" branch rather than having it corrected. In native mode the host is the only builder, so nothing on the target is the new image until `seed` ships it, and there is no state in which that branch was right. It now reports when the target is holding an older copy, which is the information the failure was missing. Verified against the target that produced the failure, without resetting it: `app` defers and names the stale copy, `seed` waits for the ID to match and lands v1 over the loop, `reload` then moves it to v2 with no auth failures. Signed-off-by: Javier Tia <javier@peridio.com>
Six review findings, three of which were the same shape: a fix whose witness did not witness anything. The ingest bail told the operator to start the avocado-vm and route it, but `needs_vm_routing` had no `Commands::Container` arm, so `ensure_routed_for_process` never ran for `container dev`, `DOCKER_HOST` was never set, and `sync_mode` could not select the VM push path however many times they followed the instruction. The only escape was a manual export. `up`, `sync`, `down` and `prune` now route; `status` deliberately does not, because it only reads the record `up` published and routing may auto-start the VM - a read-only query must not boot one. The dead-watcher warning fired on every clean `down`. Teardown kills the events child, and that kill is precisely what closes the child's stdout and makes the forwarder runnable, so the EOF arrives mid-teardown and is identical to a daemon restart; nothing in the stream distinguishes them, only the caller knows which it did. It now passes a teardown flag, and the decision moved into a pure function so all three cases are testable - an Err still reports during teardown, since a kill produces EOF rather than a read failure. `watcher_running` was hardcoded true and written once, so after the watcher died a `status` from a second terminal still reported it running - the warning reaches only the terminal holding `up`, which is the one place nobody is looking. The forwarder now signals its exit, and `up` corrects the published record read-modify-write, keeping the pid and last_sync a rebuild would have reset. The session keeps serving: the registry, the control WS and manual `sync` all work without the event stream. The GC size-guard test asserted the outcome, which is identical either way - the layer stays reachable from the manifest's `layers` array, and without the guard `serde_json` merely fails on its NUL bytes. Deleting the guard left it green. The store now counts `read_blob` calls so the test can assert the layer was never read, which is the only thing that separates the fix from its absence. The VM write-path script's success gate still globbed `*/tags/$tag` after tags moved under the repository name, so the one tool that would catch a regression on that path reported failure on a healthy run. Reproduced against the real layout before and after. Tags have no migration and cannot have one: the repository name is exactly what the flat layout did not record, so an orphaned `dev` cannot be placed under the repository it came from. Documented as a deliberate wipe costing one re-push, next to the stale layout comment that still described the old path. Signed-off-by: Javier Tia <javier@peridio.com>
The device half of this pair is already merged-pending in avocado-os#46, and both halves are inert alone. `container_dev.images[].service` has been parsed since the config type was written and read by nothing. The device needs it: `docker restart <container>` re-executes the existing container object, which stays bound to its create-time image id, so a freshly pulled image for the same tag is ignored. Restarting the owning unit re-runs `docker run` and re-resolves the tag. Without the field on the wire the device had no way to learn a unit name - the agent's own env override is set by nothing on a shipped device - so every device took the container branch and every sync succeeded at every layer while the container went on running the old image. The field is optional and skipped when absent, so the frame stays byte-identical for a device that predates it. The services map is keyed through `image_ref::split`, the same derivation `build_push_plan`'s retag and `notify`'s own key use. Keying by the raw config string would look right and silently miss for any entry written `localhost/my-app:dev`, and a missed lookup is indistinguishable from "no service declared" - which falls back to the restart this replaces. That agreement has its own test rather than being left to inspection. `Status` frames were dropped with no print and no log. The device could report `sync_failed` or a rejected token and nothing surfaced anywhere on the host: `Hello` carries the running digest but is re-sent only on reconnect, so on a healthy link the host went on showing a device synced at the old digest with the only evidence in the device journal. Reports now print, through the same sanitizer the arch refusal uses - device text reaches a bare println! with an ANSI prefix, so a device holding the read token could otherwise overwrite its own failure line with a forged success one. `needs_rebootstrap` names re-running `up`, since there is no renewal endpoint. Progress stays silent; it is high-frequency and would bury the reports above it. Signed-off-by: Javier Tia <javier@peridio.com>
…eir parts A mutation sweep over the previous commit found two of its fixes had tests that could not fail. Both are the shape this PR exists to correct, which is what makes them worth their own commit rather than a quiet amend. Keying the service map by the raw config `ref` instead of the split repo left the whole suite green. The only test covering it built its key by calling `image_ref::split` in the test helper, so it exercised the derivation twice and production zero times. The derivation moved into `service_map`, and the test now feeds it real `ContainerDevImage` values and asserts the resulting keys - a registry-prefixed ref must strip to `my-app`, an untagged one must default to `latest`. Re-dropping the `Status` arm in `on_device_message` also left everything green, because the tests called `device_status_message` directly and never went through the frame handler. Nothing but observing the sink can catch that, so the report sink is now injectable: production passes `print_warning`, and a test collects into a Vec and asserts exactly one report arrives for a Status frame and none for a Progress frame. Sanitization, wording and the `needs_rebootstrap` remedy keep their own direct tests. Those were never the gap - the wiring was. Signed-off-by: Javier Tia <javier@peridio.com>
8c707d6 to
e2afb0e
Compare
On a second run the header and the session disagreed: serves ... write 127.0.0.1:33061 ... [SUCCESS] ... write listener loopback-only on 127.0.0.1:41403 ... write_endpoint() parses the bound port out of the session log, and the header is printed before the session starts - so it was reading the port the LAST session bound, which the new session then truncated and replaced. A reader debugging an auth failure would have chased a port nothing was listening on, and the earlier fix that replaced a hardcoded 5601 with the log value made that worse rather than better on a re-run. Truncate the log before the header reads it, so write_endpoint falls back to reporting no port bound yet, which is what is true at that point. The real port still shows a moment later on the session's own success line. Signed-off-by: Javier Tia <javier@peridio.com>
…gument `seed` took a version and asserted it, but it does not build - it ships whatever the host holds under the watched tag. So the argument was a claim about content the step never established, and the two commands read as symmetrical while only one of them was: `app v1` produces v1, `seed v1` merely hoped for it. That fired within minutes of the ordering fix landing. Running `seed v1` after a `reload v2-RELOADED` printed "app is not reporting 'v1'" and exited non-zero while delivery had worked perfectly - the host image genuinely contained v2-RELOADED, because reload had moved the tag there. Take no version and read /version out of the image being shipped instead. The expectation now comes from the artifact, so it cannot disagree with what was actually sent, and the header states which version is going over the wire. An argument passed out of habit is reported as ignored rather than silently dropped, and a missing image fails with the command that would build one. The check after the restart is kept even though the image IDs already match by that point: it distinguishes the image having landed from the SERVICE having adopted it, which is the failure the agent's missing-tag bug produced. Verified three ways on the running lab: the misuse that failed now succeeds and reports the version it really shipped, `app v3-FRESH` then `seed` delivers v3-FRESH with matching image IDs, and an unbuilt tag fails with the build command to run. Signed-off-by: Javier Tia <javier@peridio.com>
jetm
left a comment
There was a problem hiding this comment.
Second-reviewer pass on the incremental change since the last review. It touches only docs/container-dev/lab/demo.sh, no Rust, so this is a shell review.
One substantive finding: the version assertion the new comment describes as airtight is vacuous for dev-shaped versions, because write_ctx puts image=my-app:dev into the same log line the check substring-matches against. So demo.sh all dev prod passes whatever the target is running. That is the same false-pass class the rest of this revision is fixing, so it is worth closing here rather than later.
Two smaller ones: reading /version by executing the image makes a build-only step require host-executability, which breaks the cross-arch flow the file's own header advertises and then emits an error telling you to redo the step that just worked; and seed is still missing from the help block whose signature this change alters.
The : >"$UP_LOG" addition is a bigger correctness win than its comment claims - it also closes a stale-log false-pass in the up gate. Detail in the third comment, along with an AVOCADO_BIN quoting inconsistency at :463 that is one way to trigger it.
All 8 checks are SUCCESS on fdfcf86, but none is required, so ci_conclusion is none and CI is not evidence the lab script works. Behavioral proof pending: an actual demo.sh all <v1> <v2> run against a target, which is exactly what finding 1 says the current assertion would not catch anyway.
…ing the image Three defects in the demo driver, all of which report success while proving less than they claim. The version assertions substring-matched the container's whole log line, and write_ctx puts `image=$TEST_IMAGE` into that same line. TEST_IMAGE defaults to my-app:dev, so asserting "dev" matched unconditionally, as did "app", "my-app" and "running-on"; a prefix also satisfied its own extension, accepting v1 while v10 ran. Measured against a real container: the line is `app v9 base=524288B running-on=<host> image=my-app:dev`, and the old pattern accepts "dev" on it. Anchoring on the leading `app <version> ` field is what makes the check test the field it names. All four sites move together - seed, app and both in reload - because this file is new in this branch, so every one of them would reach main at once. Reading the version by `run --rm --entrypoint cat` made a ship-only step execute the image. The header advertises cross-arch (linux/arm64 from an x86-64 laptop), where the image is not host-executable unless binfmt is registered, and a docker-container buildx driver keeps its emulation inside the builder - so the build succeeds and the run fails "exec format error". `2>/dev/null` then swallowed it and told the user to redo the step that had just worked. A LABEL read with `image inspect` never executes anything, costs less, and works over the forwarded socket in MODE=vm. `up` used a bare "$AVOCADO_BIN" where every other call site defaults it. Under `set -u` with $LAB/env.sh absent that is an unbound variable, and bash aborts the subshell before performing the >"$UP_LOG" redirection, so the log kept the previous session's contents and the `bulk listener` grep passed for a session that never started. Verified both halves: the redirect does not run, and with no `set -e` the failing subshell does not stop the script. Also documents `seed` in the usage block it was missing from, and prints its ignored-argument note after the session gate so the note stops preceding the error that actually matters. Signed-off-by: Javier Tia <javier@peridio.com>
Container Dev Mode is the inner dev loop for a containerized app on an
immutable Avocado OS device: edit on the host, and only the changed image
layer is hot-reloaded onto the running device — no reflash, no full re-ship.
This adds the host half:
a control WebSocket (three-listener model), all over a per-project pinned CA;
selects PUSH vs INGEST by host topology, plus a cross-arch guard;
avocado container dev up/sync/status/down/prune;a routable HTTPS write listener whose per-project CA is delivered at
up,never baked.
Verification: unit + integration suites (
container_devlib, pluscontainer_dev_e2e,container_dev_security,container_dev_arch) all pass;both halves of the loop are validated against a real Avocado OS
qemux86-64target (docker 25.0.9) built from the published 2024/edge feed, booted under
QEMU and reached only over SSH. The authenticated write path passes 8/8, and the
hot-reload loop delivers a baseline and then reloads it with no auth failures.
The target used to be a Debian cloud image standing in for a device. Replacing
it found three defects that stand-in could not surface: bootstrap delivery
shelled out to
base64 -d, which Avocado OS does not ship, soupcould neverbootstrap the OS this feature targets; the watcher acted on its own retag and
lost its credential when a superseded push outlived its config, producing 1281
failed pushes from one rebuild; and the device restarted its container rather
than the owning unit, so a pulled image was ignored while every layer reported
success. The last of those is why
container_dev.images[].serviceis nowcarried on the wire.
Phase 0 de-risk findings are in
docs/container-dev/phase0-findings.md; the labthat produced the above is
docs/container-dev/lab/.