Skip to content

Container Dev Mode: host CLI, embedded registry, and VM push path - #184

Open
jetm wants to merge 62 commits into
mainfrom
container-dev-mode
Open

Container Dev Mode: host CLI, embedded registry, and VM push path#184
jetm wants to merge 62 commits into
mainfrom
container-dev-mode

Conversation

@jetm

@jetm jetm commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 per-project content-addressed OCI store, split write/read listeners, and
    a control WebSocket (three-listener model), all over a per-project pinned CA;
  • Basic-gated write token vs Bearer read/control token (split-credential model);
  • an engine-driver watcher (docker/podman) that syncs only changed layers and
    selects PUSH vs INGEST by host topology, plus a cross-arch guard;
  • avocado container dev up/sync/status/down/prune;
  • the authenticated VM push path: on the macOS/VM fast path the guest pushes to
    a routable HTTPS write listener whose per-project CA is delivered at up,
    never baked.

Verification: unit + integration suites (container_dev lib, plus
container_dev_e2e, container_dev_security, container_dev_arch) all pass;
both halves of the loop are validated against a real Avocado OS qemux86-64
target (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, so up could never
bootstrap 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[].service is now
carried on the wire.

Phase 0 de-risk findings are in docs/container-dev/phase0-findings.md; the lab
that produced the above is docs/container-dev/lab/.

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/commands/container/dev.rs
Comment thread src/commands/container/dev.rs Outdated
Comment thread src/commands/container/dev.rs
Comment thread src/utils/container_dev/registry.rs
Comment thread src/utils/container_dev/registry.rs
Comment thread src/commands/container/dev.rs Outdated

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and reconcile never consults the book at all. Since up spawns 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::acquire runs after the state write, the token mint, all three binds, the watcher spawn, and both SSH deliveries, so it cannot exclude a second up from any of them. Two of its consequences need no unusual configuration at all: a concurrent status/sync/down in the two-statement window makes acquire fail 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 single down.

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.

Comment thread src/commands/container/dev.rs
Comment thread src/commands/container/dev.rs Outdated
Comment thread src/utils/container_dev/registry.rs Outdated
Comment thread src/commands/container/dev.rs Outdated
Comment thread src/utils/container_dev/registry.rs
Comment thread tests/container_dev_arch.rs
Comment thread src/utils/container_dev/watcher.rs
Comment thread src/utils/container_dev/registry.rs Outdated
Comment thread src/commands/container/dev.rs
Comment thread src/commands/container/dev.rs Outdated

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::finish re-implements blob_path (store.rs:326) inline plus write_blob's exists/create_dir_all/persist tail, so the blob layout now has two authors while only readers go through blob_path. blobs_root is also initialized from self.root and then has "blobs" joined onto it, and format!("sha256:{hex}") hardcodes an algorithm two lines above a parse_digest that accepts any.
  • store.rs:341 - parse_digest's doc comment is orphaned onto pub struct BlobUpload, so cargo doc renders the new public type's summary as "Split an OCI digest into its (algorithm, hex) components", and parse_digest at store.rs:402 ends up undocumented.
  • store.rs:360/:378 - unbuffered write_all per hyper frame means syscall count per GiB is set by link behavior rather than bounded by code; flush() before persist is a documented no-op on File, and with no sync_all a host crash can leave the blob path present while its tail never reached disk, which has_blob and the HEAD dedup probe both report as complete.
  • watcher.rs:601 - record_hello has no production callers left (only watcher.rs:962/990 and six sites in tests/container_dev_arch.rs) and plants holders: 1 with no lease, so its entry can never be released. The struct doc at watcher.rs:579 still says reconnects "overwrite" while record_session six lines below says they refcount.
  • ws.rs:441 - the inner Option<DeviceArchLease> is never None, since the only Some(..) return arm always supplies a lease, so if lease.is_some() is a branch that cannot be taken.
  • ws.rs:1015 - notify_refuses_an_event_with_no_image_id labels frames.is_empty() "The decisive assertion", but an empty desired digest compares equal to the empty running_digest, so that assertion passes with the digest.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_connector is now the fourth byte-identical copy (also container_dev_security.rs:131, container_dev_e2e.rs:353, ws.rs:1064), and tests/common/mod.rs already 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 same image inspect object, 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.

Comment thread src/commands/container/dev.rs
Comment thread src/utils/container_dev/ws.rs
Comment thread src/utils/container_dev/registry.rs Outdated
Comment thread src/utils/container_dev/store.rs
Comment thread src/utils/container_dev/registry.rs
Comment thread src/commands/container/dev.rs
Comment thread src/utils/container_dev/registry.rs Outdated
Comment thread tests/container_dev_arch.rs Outdated
Comment thread src/commands/container/dev.rs Outdated
Comment thread src/utils/container_dev/registry.rs Outdated

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/container/dev.rs Outdated
jetm added 23 commits August 3, 2026 11:55
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>
jetm added 14 commits August 3, 2026 11:55
…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>
@jetm
jetm force-pushed the container-dev-mode branch from 16091b8 to ad111ee Compare August 3, 2026 17:57
… 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 jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/utils/container_dev/registry.rs Outdated
Comment thread src/utils/container_dev/tls.rs Outdated
Comment thread src/utils/container_dev/watcher.rs Outdated
Comment thread src/utils/container_dev/store.rs Outdated
Comment thread src/utils/container_dev/store.rs
Comment thread src/utils/container_dev/watcher.rs Outdated
Comment thread src/utils/container_dev/store.rs
Comment thread src/utils/container_dev/store.rs
Comment thread src/utils/container_dev/ws.rs Outdated
Comment thread src/utils/container_dev/engine.rs
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 jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/utils/container_dev/store.rs
Comment thread src/utils/container_dev/watcher.rs
Comment thread docs/container-dev/verify-vm-write-path.sh Outdated
Comment thread src/utils/container_dev/engine.rs Outdated
Comment thread src/commands/container/dev.rs
Comment thread src/utils/container_dev/store.rs Outdated
Comment thread src/utils/container_dev/store.rs
jetm added 5 commits August 4, 2026 09:19
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>
@jetm
jetm force-pushed the container-dev-mode branch from 8c707d6 to e2afb0e Compare August 4, 2026 16:46
jetm added 2 commits August 4, 2026 10:50
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 jetm left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/container-dev/lab/demo.sh Outdated
Comment thread docs/container-dev/lab/demo.sh Outdated
Comment thread docs/container-dev/lab/demo.sh Outdated
…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>
@jetm
jetm requested a review from nicksinas August 5, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant