test: Go simnet harness for charon/pluto e2e duty tests - #526
test: Go simnet harness for charon/pluto e2e duty tests#526varex83agent wants to merge 6 commits into
Conversation
Add test-infra/harness, a Go module that reuses charon v1.7.1 as a library (cluster fixtures, beaconmock, validatormock, relay) to run simnet-style end-to-end attester duty tests over clusters mixing in-process charon nodes and subprocess pluto nodes. Since charon's beaconmock only implements dynamic behaviour on its Go client interface, the harness adds a per-node HTTP gateway fronting one shared mock, serving duties/validators/attestation endpoints over real HTTP and capturing submissions per node for assertions. Unlike upstream simnet, partial-signature exchange runs over real p2p so out-of-process nodes participate in consensus and threshold signing. Scenarios: all-charon in-process (baseline), all-charon via gateway (validates the gateway), all-pluto and 2+2 mixed with threshold 3 (skip until `pluto run` lands, then activate automatically). The subprocess path is validated today by pointing PLUTO_BIN at a charon binary, which passes the mixed scenario in ~5s. CI runs the charon scenarios standalone for fast signal, plus the full suite with a freshly built pluto binary. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Charon serializes the distributed validator `partial_deposit_data` field with `omitempty`, so a cluster lock created without deposit data (e.g. the simnet harness fixtures) omits it entirely. `DistValidatorV1x8orLater` required the field, so pluto nodes failed to start with "missing field `partial_deposit_data`" and the Pluto/Mixed simnet scenarios timed out. Add `#[serde(default)]` to match charon's tolerant deserializer. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
`build_console_tracing_config` always sets `override_env_filter` from `--log-level` (default "info"), which unconditionally won over `RUST_LOG` in `init`. As a result `pluto run` silently ignored `RUST_LOG`, so there was no way to raise verbosity for specific targets (e.g. libp2p relay tracing) without changing the launch flags — painful when debugging a node started by a harness that hardcodes no log level. Introduce `resolve_filter_directive` with explicit precedence: a non-empty `RUST_LOG` wins, then the `--log-level`/`CHARON_LOG_LEVEL` override, then the built-in default. The value is passed in rather than read from the environment so the precedence is unit-testable. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
The relay only folded its `--p2p-external-ip`/`--p2p-external-hostname` addresses and auto-detected listen addresses into the `/enr` HTTP response; it never registered them as libp2p external addresses. Since rust-libp2p sources circuit-reservation addresses from the swarm's external address set, every reservation the relay issued carried an empty address list. rust-libp2p relay *clients* reject such a reservation with `NoAddressesInReservation`, so a pluto node could never reserve a slot (go-libp2p clients happen to tolerate it). This left pluto nodes unable to reach each other through a pluto relay. Register the same address set we advertise over `/enr` as libp2p external addresses: the configured external addrs at startup, and each auto-detected listen address as it is confirmed (already gated by `filter_private_addrs`, so loopback is only advertised under `--p2p-advertise-private-addresses`). Add `Node::remove_external_address` to keep the set in sync when listen addresses go away. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
A loopback-bound go-libp2p (charon) relay issues circuit reservations with no addresses (it filters non-public addrs), which pluto's rust-libp2p relay client rejects with `NoAddressesInReservation` — so pluto nodes never connected and every attester duty timed out. Scenarios with pluto nodes now start a `pluto relay` (which advertises its loopback address via `--p2p-advertise-private-addresses`); all-charon scenarios keep the charon relay. Both pluto-only and mixed clusters now complete their duties. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
|
@varex83agent /review-pr |
varex83agent
left a comment
There was a problem hiding this comment.
Summary
Adds test-infra/harness/ — a Go simnet harness running end-to-end attester-duty tests over clusters mixing in-process charon nodes and subprocess pluto nodes (charon v1.7.1 consumed as a plain module dependency) — plus four small, well-scoped production fixes: #[serde(default)] on partial_deposit_data, relay circuit-reservation address advertisement, Node::remove_external_address, and RUST_LOG > --log-level precedence.
Overall quality is high. The Rust fixes are correct and unit-tested where it matters; the serde(default) change is parity-safe (partial_deposit_data is part of the SSZ hash-tree-root covered by lock verification, so defaulting it to empty cannot bypass validation and matches charon's omitempty deserializer). The relay fix correctly gates auto-detected listen addresses behind filter_private_addrs.
Bugs (must-fix)
None.
Major
None.
Minor / nit
A handful of minor findings, none blocking:
- Relay parity divergence — the manually-configured
--p2p-external-ippath adds addresses to libp2p without an is-public filter, whereas go-libp2p strips private addrs from circuit reservations. Low impact (only under misconfiguration, and it adds rather than removes reachable addrs), but worth a filter or a documented note. RUST_LOGprecedence intentionally inverts charon's flag-beats-env rule — defensible, but the doc comment should call out the divergence.- Harness assertion coverage — the gateway/mixed path only asserts attestation equality, not the aggregator duties the in-process baseline checks.
- Test-flakiness sources — port-allocation TOCTOU +
SkipIfBindErrnot covering subprocess bind failures; non-deterministic slot selection;t.Logf-after-test race under-race.
Plus assorted nits. Leaving as COMMENT for author discretion — nothing here blocks merge.
🤖 Reviewed by the multi-agent review pipeline.
| // with `NoAddressesInReservation`; auto-detected listen addresses are | ||
| // added below as they are confirmed. | ||
| for addr in &external_addrs { | ||
| node.add_external_address(addr.clone()); |
There was a problem hiding this comment.
Manually-configured external addresses bypass private-address filtering, and rust-libp2p has no go-libp2p reservation backstop.
This loop calls add_external_address for every entry in external_addrs (built from operator-supplied --p2p-external-ip/--p2p-external-hostname) with no filter_private_addrs/is-private gate. In rust-libp2p, add_external_address → ExternalAddrConfirmed feeds libp2p-relay's AcceptReservationReq { addrs } verbatim; libp2p-relay does not filter non-public addresses out of circuit reservations. go-libp2p does: makeReservationMsg (circuitv2/relay/relay.go) unconditionally skips !manet.IsPublicAddr(addr).
Net effect: a misconfigured private/loopback external addr is advertised inside circuit reservations under Pluto, whereas charon/go-libp2p would silently drop it. The auto-detected AddrUpdate::Add path (line 138) is correctly gated by filter_private_addrs, so only the manual path is affected. Impact is low (it adds addresses rather than breaking connectivity, and only under misconfiguration), but consider mirroring go-libp2p's is-public guard here or documenting the divergence.
| match address_update { | ||
| AddrUpdate::Add(address) => { | ||
| node.add_external_address(address.clone()); | ||
| listeners.write().await.push(address); |
There was a problem hiding this comment.
nit: listeners.push(address) on AddrUpdate::Add is unconditional. If libp2p ever re-emits NewListenAddr for the same address (interface flaps / re-listens), listeners accumulates duplicates. This is masked downstream — web.rs's advertised-addrs dedups the external∪listeners union via a HashSet, and swarm.add_external_address dedups internally — so there's no user-visible drift. Correctness is fine; a dedup-on-push (or a set) would just be tidier for a long-lived relay.
|
|
||
| /// Selects the tracing filter directive using this precedence: | ||
| /// | ||
| /// 1. `RUST_LOG` from the environment — the standard runtime escape hatch for |
There was a problem hiding this comment.
RUST_LOG winning over an explicitly-passed --log-level inverts charon's precedence. Charon resolves as explicit --log-level flag > CHARON_LOG_LEVEL env > default (cmd/cmd.go bindFlags short-circuits on f.Changed), so an explicit flag always beats the env var there.
The change is defensible — RUST_LOG is the tracing-subscriber standard and supports per-target directives (info,pluto_p2p=debug) that charon's single-scalar level can't express, and charon has no RUST_LOG. But it's a genuine precedence divergence for that env var; the doc comment should state explicitly that this intentionally differs from charon (where the explicit flag wins over the env).
| rust_log.as_deref(), | ||
| config.override_env_filter.as_deref(), | ||
| ) { | ||
| Some(directive) => EnvFilter::try_new(&directive).unwrap_or_else(|_| default_env_filter()), |
There was a problem hiding this comment.
EnvFilter::try_new(&directive).unwrap_or_else(|_| default_env_filter()) swallows a parse error and silently drops to info. This preserves the pre-existing behavior (not a regression), but it's poor observability for exactly the fine-grained escape-hatch path the new doc comment advertises: an operator who typos RUST_LOG=infp,pluto_p2p=debug gets plain info with zero signal. Since tracing isn't initialized yet here, consider an eprintln! to stderr on the error arm before falling back. Not blocking.
| return Some(rust_log.to_string()); | ||
| } | ||
|
|
||
| override_env_filter.map(str::to_string) |
There was a problem hiding this comment.
Asymmetric blank-string handling: rust_log is rejected when trim().is_empty() (so Some(" ") falls through to the override), but override_env_filter is returned verbatim with no equivalent guard. A blank --log-level/CHARON_LOG_LEVEL (Some(" ")) is therefore forwarded to EnvFilter::try_new, fails to parse, and silently falls back to info — instead of cleanly returning None and using the default. The override is CLI-sourced so blank is unlikely, but the asymmetry is surprising given both are Option<&str> directives. Either apply the same trim().is_empty() filter to the override, or document the difference.
| t.Helper() | ||
|
|
||
| dir := t.TempDir() | ||
| httpAddr := testutil.AvailableAddr(t).String() |
There was a problem hiding this comment.
Port-allocation TOCTOU: testutil.AvailableAddr binds localhost:0, resolves the addr, and immediately closes the listener before the port is handed to the pluto subprocess. Between close and the subprocess's bind, another process/test can grab the port. A subprocess bind failure does not route through SkipIfBindErr — it crashes and surfaces as a WaitReady timeout, turning a flaky port race into a hard failure rather than a skip. Applies to the p2p/monitoring addrs here and in StartPlutoNode (lines 136-137). Consider retrying node startup on bind failure or passing the still-open listener fd.
| }, | ||
| }) | ||
|
|
||
| default: |
There was a problem hiding this comment.
The ServeHTTP switch enumerates known duty/submission endpoints and falls through to the reverse proxy for everything else. A POST to an endpoint the underlying mock doesn't implement (e.g. a future block-proposal or sync-message submission) is silently proxied and returns whatever the mock returns (often 404/405), which a node may treat as a transient error and retry indefinitely rather than failing the test clearly. Acceptable for the curated attester-only flow today, but a default that logs unhandled POSTs would close a latent completeness gap.
| case eth2spec.DataVersionFulu: | ||
| inner = att.Fulu | ||
| default: | ||
| inner = att.Electra |
There was a problem hiding this comment.
nit: the default: arm of versionedAttestationJSON sets inner = att.Electra and reports att.Version.String() as the version. For a genuinely unset/unknown att.Version, att.Electra may be nil, producing an inconsistent {version, data} pair (version says X, body is a nil Electra attestation) that the client could mis-decode. Prefer returning an error for unrecognized versions so a spec mismatch fails loudly in the test log.
| Log: log.DefaultConfig(), | ||
| Feature: featureset.DefaultConfig(), | ||
| SimnetBMock: mode == CharonInProcessBMock, | ||
| SimnetVMock: true, |
There was a problem hiding this comment.
nit: SimnetVMock: true is set unconditionally for every in-process charon node, including the gateway/mixed scenarios. That's intentional (charon drives its own in-process VC while pluto nodes get the harness HTTP vmock in vmock.go), but it means charon and pluto nodes are exercised through different validator-client drivers in the same interop test — charon via its scheduler-wired validatormock, pluto via the harness HTTP vmock with a wall-clock ticker. The interop assertion therefore compares outputs of two different vmock drivers, not one shared driver. Worth a comment here or in the README so the assertion's scope isn't overstated.
| on: | ||
| push: | ||
| branches: ["main"] | ||
| paths: |
There was a problem hiding this comment.
nit: both jobs share this paths filter including **/*.rs, **/Cargo.toml, and Cargo.lock. The charon-simnet job builds no Rust and only exercises charon in-process, so it re-runs on every Rust-only change that can't affect it. Only pluto-simnet legitimately needs the Rust paths. Splitting the filters per job would avoid redundant runs; a unified filter is a reasonable simplicity tradeoff if you'd rather keep it.
What
Adds
test-infra/harness/, a Go test harness that runs simnet-style end-to-end attester duty tests over distributed-validator clusters mixing charon nodes (in-process, reusing charon's ownapp.Run) and pluto nodes (subprocesses of the pluto binary), plus a CI workflow.Charon
v1.7.1(our parity reference) is consumed as a regular Go module dependency — no fork. The harness reuses itscluster.NewForTdeterministic fixtures,testutil/beaconmock,testutil/validatormockandtestutil/relaydirectly.Design
BroadcastCallbackassertions.Scenarios
TestSimnetAttesterCharonInProcessTestSimnetAttesterCharonViaGatewayTestSimnetAttesterPlutopluto runlandsTestSimnetAttesterMixedpluto runlandsThe pluto scenarios probe
$PLUTO_BIN run --helpand activate automatically once theruncommand exists. The subprocess path is already validated end-to-end today: pointingPLUTO_BINat a charon binary (which accepts the same flags) passes the mixed scenario in ~5s, covering the on-disk fixture layout, run flags, readiness polling, HTTP-driven validator mock and capture assertions.CI
.github/workflows/go-harness.yml:pluto-cli(same steps as dkg-runner) and runs the full suite withPLUTO_BINset.Notes
replacedirectives ingo.modare copied from charon's owngo.mod(Go does not propagate a dependency's replaces); they must move in lockstep when bumping the pinned charon version.pluto runrequirements for the scenarios to activate are documented in the README (charon-equivalent flags + validator API on the configured address).🤖 Generated with Claude Code