feat(zcash_local_net)!: containerized artifacts with a consumer manifest + preflight pre-check#282
Merged
Conversation
…with an artifact manifest Every managed process (zebrad Validator, zainod Indexer, zcash-devtool Wallet) can now run from a container image via the docker or podman CLI. Launch configs gain a `source: ArtifactSource` field whose default is the historical TEST_BINARIES_DIR / PATH resolution; the alternatives are an explicit local-build path (the escape hatch for artifacts built from source) or a container image. Container mode is a spawn-command substitution, not a parallel implementation: the container runs in the foreground (`run --rm`) so its stdio streams through the child the harness already manages, joins the host network namespace, and has the harness's temp config/data dirs bind-mounted at identical paths. Readiness-indicator scanning, launch-log endpoint discovery, port-collision retry, the front proxies, and Indexer-convergence parsing are therefore shared verbatim between host and container modes. Stopping a containerized process force-removes the container by name; wallet operations each run as their own one-shot container. Consumers describe where artifacts come from with a small JSON artifact manifest (`container::manifest::ArtifactManifest`), loadable from the ZCASH_LOCAL_NET_MANIFEST environment variable, and validate it as a pre-check with `ArtifactManifest::preflight` — runtime client present and daemon reachable, images present/pulled per their pull policy, host binaries resolvable and executable — reported all at once, before any launch. The new `zcash-local-net` CLI exposes the same pre-check from a shell (`zcash-local-net preflight` / `zcash-local-net template`) for use ahead of `cargo nextest run`. Breaking: `ZebradConfig`, `ZainodConfig` and `ZcashDevtoolConfig` gain the public `source` field (struct-literal constructors must add it); `Zebrad::stop` now tolerates an already-reaped child like `Zainod::stop` does. The container-run CLI contract (flag shape, identical-path volume syntax, entrypoint default, --pull mapping) is pinned by unit tests; the full container launch path was validated live against a local scratch image (foreground log streaming, host-loopback reachability through the front, rm --force teardown, stdin piping, mount ownership). A manifest-driven live integration test is provided ignored, for environments that supply real images. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi
…t manifest Container artifacts exist to make test runs reproducible; a floating reference (`zebra`, `zebra:latest`) silently changes what a test run means when the registry moves the tag. Manifest loading now rejects untagged, empty-tagged and `latest`-tagged image references with the new `ManifestError::UnpinnedImage`, naming the offending reference and the fix (digest pin `repo@sha256:…`, the strongest form, or an explicit non-`latest` tag). A registry port is not mistaken for a tag. Preflight's image checks now also report the immutable identity each reference resolved to (first repo digest, falling back to the local image ID for locally built images) — the line to compare across machines when a manifest pins by tag. Direct `ContainerImage` construction bypasses the manifest check by design; its docs now state that programmatic callers carry the pinning responsibility themselves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi
… the artifact manifest
The manifest now doubles as intent and lockfile, Cargo.toml/Cargo.lock
style in one file: each image artifact may declare `track` — the
floating reference the consumer prefers to follow (`…:latest`, a
release-channel tag) — while `image` remains the pin every run uses.
Run-time code never consults the registry about what a tag currently
means; moving a pin is a deliberate act:
zcash-local-net update [--manifest <path>] [validator|indexer|wallet …]
resolves each selected artifact's tracked reference (its `track`
field, or the tag its `image` was pinned from) against the registry —
pull, then read the repo digest — and rewrites `image` to the
digest-pinned result (`repo:tag@sha256:…`, tag preserved for
readability, digest authoritative), reporting every bump. The file is
rewritten in the manifest's canonical formatting (the serde schema
now also serializes, in declaration order), so each bump is a
reviewable version-control diff. The library entry point is
`container::update::update_manifest_file`.
Semantics pinned by tests: a `track`-only artifact is deliberately
not launchable until its first update mints the pin (new
`ManifestError::UnresolvedTrack`, whose message says to run update);
digest-only images without `track` have no floating preference and
are never touched; `track` is rejected on `local` artifacts and must
itself be floating (a digest-pinned `track` has nothing to follow); a
resolver failure surfaces before the file is touched; the bumped
manifest is validated before it replaces the file. The registry
interaction is injected behind a resolver seam, so the update logic
is unit-tested hermetically; the CLI error paths (unreachable
registry, track-only preflight, unknown artifact filter) were
validated live against a real docker daemon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi
… to TOML TOML is the configuration language everything else in this ecosystem speaks (zebrad.toml, zindexer.toml, Cargo.toml) and, unlike JSON, carries comments — the template printed by `zcash-local-net template` now documents every field inline. The manifest file is `zcash-local-net.toml`, `ArtifactManifest::from_json_str` is now `from_toml_str`, `template_json` is now `template_toml`, and `zcash-local-net update` rewrites the file as canonical TOML (declaration-order fields via the serializing schema). Adds the `toml` crate (0.8) as a dependency; its stack (toml_edit, toml_datetime, serde_spanned, winnow) is MIT/Apache-licensed within the cargo-deny allow list, and cargo-vet exemptions for the toml 0.8 line already exist in supply-chain/config.toml. This reverses the earlier zero-new-deps JSON decision at the maintainer's request. Schema, semantics, pinning enforcement, tracking, preflight, and the CLI behave identically — only the surface syntax changed. All fixtures and the live CLI validation were converted; three previously-missing manifest-level `track` rule tests (track on local artifacts, digest-pinned track, track-only launch refusal) were added alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi
…ifest-dialect module Remove the `toml = "0.8"` dependency and serve the artifact manifest with a crate-internal `toml` module instead. The module is a drop-in for the two entry points the crate used — `from_str` and `to_string`, with the crate's `de::Error` / `ser::Error` split — so both call sites are byte-for-byte unchanged apart from their imports. The dependency list returns to exactly what `dev` already carries, and `toml`, `toml_datetime`, `toml_edit`, and `toml_write` all leave `Cargo.lock`. The module is deliberately NOT a full TOML parser. It implements only the minimal dialect the manifest needs — comments, bare keys, table headers, basic strings with the standard escapes, and decimal integers — and rejects every other TOML construct with an error that names the construct and its line, never silently misreading a file. The module's rustdoc header is the single authoritative statement of the dialect; the manifest documentation points at it. Parsing produces a `serde_json::Value` tree and bridges through `serde_json::from_value`, so the schema stays defined once, on the `RawManifest` derives, and unknown-field rejection and type checking keep working unchanged. Rendering is a minimal serde `Serializer` that emits fields in declaration order in the exact shape `toml` 0.8.23 emitted. Equivalence with `toml` 0.8.23 was proven by a live A/B run against the real crate before its removal — byte-for-byte on rendering, and value-for-value on parsing across the template, comment placement, indentation, and every escape form — and is frozen as provenance-commented golden fixtures in the module's tests, which keep running offline. The one deliberate divergence is documented and tested: strings needing escapes render as basic strings with backslash escapes, where the crate switched to literal strings, so the renderer never emits what the dialect cannot read back. Also correct the `update_manifest_file` documentation, which promised a two-space indent the old renderer never produced, and record the Manifest dialect term in CONTEXT.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…repo manifest-dialect module" This reverts commit 14750cb.
zancas
approved these changes
Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the ability to run the whole
zcash_local_netsetup from containers (docker or podman), replicating all currently supported functionality, plus a consumer-authored artifact manifest (TOML) that doubles as a pre-check and a lockfile, and an escape hatch for artifacts built locally from source.Container mode is a spawn substitution, not a parallel implementation
Every managed process (zebrad Validator, zainod Indexer, zcash-devtool Wallet) already flowed through one command-construction choke point. That choke point is now an
ArtifactSourceon each launch config:HostProcess { binary: None }— the default:TEST_BINARIES_DIR/PATHresolution, byte-for-byte the historical behavior. Existing callers are unaffected.HostProcess { binary: Some(path) }— the escape hatch: run a locally built zebrad/zainod/zcash-devtool directly (ArtifactSource::local_binary(...), orlocal = "…"in the manifest).Container(ContainerImage { runtime, image, entrypoint, pull })— run the binary from its actual published image (e.g. Zebra's officialzfnd/zebra), one image per artifact.Containers launch in the foreground (
run --rm, never--detach), so their stdio streams through the child process the harness already manages; they join the host network namespace; and the harness's temp config/data dirs are bind-mounted at identical paths, so the generated config files are valid verbatim inside. As a result, readiness-indicator scanning, launch-log endpoint discovery, port-collision retry, the front proxies, and Indexer-convergence parsing are shared verbatim between host and container modes —LocalNet,launch_wallet,generate_blocks_converged, chain caching, observers, everything works identically.stop()force-removes the container by name (zcash-local-net-<binary>-<pid>-<n>); wallet operations each run as one-shot containers (with--interactivefor the stdin-fedinit).Host networking makes container mode Linux-first (Docker Desktop VM loopback is not host-reachable); documented in
crate::container.The manifest: intent + lockfile in one TOML file
TOML — the configuration language the rest of the ecosystem already speaks (
zebrad.toml,zindexer.toml), comments included;zcash-local-net templateprints a fully commented starting point:Image references must be pinned. Untagged and
:latestreferences are rejected at load time (ManifestError::UnpinnedImage) — digest pins (repo@sha256:…) are the strongest form, an explicit non-latesttag the minimum. A registry port (registry:5000/zebra) is not mistaken for a tag. Preflight reports the immutable identity each reference resolved to (repo digest, or local image ID for locally built images), so tag-pinned runs leave comparable evidence.Pins move only through an explicit, well-defined bump.
trackrecords the floating preference a consumer wants to follow (…:latest, a release-channel tag);imagerecords the pin every run uses. Nothing at run time asks the registry what a tag currently means. To move the pins:resolves each tracked reference (the
trackfield, or the tagimagewas pinned from) against the registry and rewritesimagetorepo:tag@sha256:…in canonical TOML — a reviewable version-control diff per bump, exactly like a lockfile update. Atrack-only artifact is deliberately not launchable until its first update mints the pin (ManifestError::UnresolvedTracksays so and names the command); digest-only images withouttrackhave no floating preference and are never touched. Library entry point:container::update::update_manifest_file.ArtifactManifest::from_env()?.unwrap_or_default()→.preflight()(the pre-check) →.launch_local_net(), or seed individual configs viazebrad_config()/zainod_config()/wallet_source().zcash-local-net preflight --manifest ci-artifacts.toml \ && ZCASH_LOCAL_NET_MANIFEST=ci-artifacts.toml cargo nextest runpreflightverifies, all at once and before any launch: runtime client present and daemon reachable, images present locally (pulled per theirnever/if-missing/alwayspolicy — also enforced at launch viarun --pull=), and host binaries resolvable and executable.Breaking changes (0.x, changelogged)
ZebradConfig,ZainodConfig,ZcashDevtoolConfiggain a publicsourcefield (struct-literal constructors must add it;Default/builder users unaffected).Zebrad::stopnow tolerates an already-reaped child (matchingZainod::stop).Dependencies
One new dependency:
toml0.8 (manifest parsing/serialization). Its stack (toml_edit, toml_datetime, serde_spanned, winnow) is MIT/Apache within the cargo-deny allow list, andsupply-chain/config.tomlalready carries cargo-vet exemptions for the toml 0.8 line.Verification
image+localconflict, pull/runtime parsing, pinning acceptance/rejection matrix,trackrules incl. track-on-local and digest-pinned-track rejection), preflight failure modes (missing binary, non-executable, report rendering), the container-run CLI contract (flag shape/order, identical-path volume syntax, entrypoint default,--pullmapping) pinned exactly, and the update/bump semantics (tracked and tag-pinned artifacts bumped, first-pin minting, artifact filter, no-op re-run, digest-only untouched, resolver failure leaves the file untouched, post-bump validation) — the registry interaction sits behind an injectable resolver seam, so update logic is tested hermetically.FROM scratchstatic test daemon stood in): foreground log streaming, host-loopback reachability, mount write-through, stdin piping,rm --forcesemantics (including modern docker's exit-0 on missing container), digest reporting in the preflight output, unpinned-manifest rejection, update CLI error paths (unreachable registry leaves the manifest untouched; track-only manifests refuse to launch with the remediation message), TOML template/preflight round-trip, and an end-to-endZainod::launchin container mode — config consumed inside the container, readiness scanned from container logs, front proxy relaying into it, clean teardown with no leftover containers. What was not exercised live: real zebrad/zainod images (none pullable here);tests/containers.rsships an#[ignore]d manifest-driven end-to-end test (preflight → LocalNet → mine → convergence) for environments that provide them.cargo check --workspace --all-targets(also with--features legacy-stack),cargo clippyclean on all new code under-D warnings(3 pre-existingintegration.rslints on thedevbaseline are untouched),cargo fmt,cargo docclean, doc tests pass. No external types added to the public API.Notes for reviewers
Backendtrait's design constraint ("a container backend could implement this without changing one line offront") is honored in spirit by a stronger move: containers reuse the same backends, sofront.rsand friends are untouched.updaterewrites the manifest in a canonical formatting (the serde schema also serializes, in declaration order) rather than preserving arbitrary user formatting —deny_unknown_fieldsmeans there is nothing but formatting (and comments, which a rewrite drops) to lose.ContainerImageconstruction (programmatic, non-manifest) bypasses the pinning check by design; its field docs state that such callers carry the reproducibility responsibility themselves.Zebrad::launch_onceignoresload_chain's return value, so a non-regtestchain_cacheis arguably mis-plumbed ondevalready. Happy to open a tracking issue.🤖 Generated with Claude Code
https://claude.ai/code/session_015dRfXaAyhrUC5LwgEGHaNi