diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 375d126b..14c3c03d 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -1,20 +1,18 @@ name: Compose smoke tests -# Manual only: each scenario stands up a full docker-compose cluster (N nodes + -# relay + prometheus) and observes it for 2 minutes. The resource-heavy -# very_large scenario remains local-only. Too heavy to attach to push or -# pull_request. +# Manual only: each scenario runs a full docker-compose cluster for two +# minutes, too heavy for push or pull_request. very_large stays local-only. on: workflow_dispatch: inputs: scenarios: - description: "Scenario filter (go test -run regex). Empty runs the CI matrix; very_large is excluded." + description: "Space-separated scenario names (e.g. `default_alpha pluto_dkg`). Empty runs the CI matrix; very_large is always excluded." type: string default: "" - go_timeout: - description: "go test -timeout. Must exceed the sum of the selected scenarios." - type: string - default: "50m" + smoke_timeout: + description: "Minutes allowed for the smoke run itself (image and harness builds are separate steps). Must exceed the sum of the selected scenarios' windows." + type: number + default: 50 concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,72 +22,71 @@ permissions: contents: read actions: read +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUSTFLAGS: "-Dwarnings -C debuginfo=0" + jobs: smoke: name: Compose smoke tests runs-on: ubuntu-24.04 - # Covers the pluto image build (release build of pluto-cli inside docker, - # uncached on a fresh runner) plus the scenario matrix. - timeout-minutes: 90 + timeout-minutes: 100 steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: test-infra/compose/go.mod - cache-dependency-path: test-infra/compose/go.sum + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Install `oas3-gen` + run: cargo install oas3-gen@0.24.0 --locked - name: Build pluto image - # Built here rather than letting the harness do it inside `go test`, so - # the release compile does not consume the -timeout budget (which should - # bound observation, not compilation) and a build break fails in its own - # step. The harness still calls `docker build` during its define step; - # replicating the tag and build-arg exactly makes that a cache hit. - # - # Deliberately plain `docker build`, not buildx: setup-buildx-action's - # docker-container driver keeps a separate cache that the harness's - # `docker build` would not see, so the image would be compiled twice. + # Built in its own step so the release compile is not charged to the smoke + # budget; the harness's own `docker build` then hits the cache. timeout-minutes: 40 run: | docker build -t pluto:local \ --build-arg "GIT_COMMIT_HASH_SHORT=$(git rev-parse --short=7 HEAD)" . + - name: Build smoke harness + run: cargo test --locked -p pluto-test-compose --test smoke --no-run + - name: Run smoke tests - working-directory: test-infra/compose - # Inputs are passed as env vars, never interpolated into the script: - # `${{ inputs.* }}` inside `run:` is substituted before the shell sees - # it, so a crafted value would execute as shell. + timeout-minutes: ${{ fromJSON(inputs.smoke_timeout) }} + # Inputs are passed through env, never interpolated into the script. env: - # The pluto image is built from this checkout during the define step. PLUTO_REPO: ${{ github.workspace }} SCENARIOS: ${{ inputs.scenarios }} - GO_TIMEOUT: ${{ inputs.go_timeout }} - LOG_DIR: ${{ runner.temp }}/smoke-logs + SMOKE_LOG_DIR: ${{ runner.temp }}/smoke-logs + # Containers run as root; without this the runner cannot clean up. + SMOKE_SUDO_PERMS: "1" run: | - mkdir -p "$LOG_DIR" - - args=( - ./smoke -v -integration - "-timeout=$GO_TIMEOUT" - "-log-dir=$LOG_DIR" - # Requires more CPU than a GitHub-hosted runner provides reliably. - "-skip=^TestSmoke/very_large$" - # Containers run as root, so the artefacts they leave in the compose - # dir are root-owned; without this the runner cannot clean them up. - -sudo-perms - ) + mkdir -p "$SMOKE_LOG_DIR" + + # very_large needs more CPU than a hosted runner has. + args=(--ignored --nocapture --test-threads=1 --skip scenario_very_large) if [ -n "$SCENARIOS" ]; then - args+=(-run "$SCENARIOS") + # Exact names: `dkg` alone would also select pluto_dkg. + args+=(--exact) + for name in $SCENARIOS; do + args+=("scenario_$name") + done + + # libtest passes with 0 tests for an unknown name; every name must select one. + wanted=$(echo "$SCENARIOS" | wc -w | tr -d ' ') + found=$(cargo test --locked -p pluto-test-compose --test smoke -- "${args[@]}" --list | grep -c ': test$' || true) + if [ "$found" -ne "$wanted" ]; then + echo "::error::$found of $wanted scenario names select a test (very_large is always excluded): $SCENARIOS" + exit 1 + fi fi - go test "${args[@]}" + cargo test --locked -p pluto-test-compose --test smoke -- "${args[@]}" - name: Upload scenario logs - # Always: a passing run's logs are the baseline for triaging the next - # failure, and these clusters are expensive to reproduce. if: always() uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index d046f5cb..0dc85f42 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,3 @@ test-infra/sszfixtures/sszfixtures .claude/worktrees/ .claude/scheduled_tasks.lock test-cluster - -# Smoke-test docker-compose logs (go test -log-dir) -test-infra/compose/**/*.log diff --git a/AGENTS.md b/AGENTS.md index a52e4dac..d5192a3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ pluto/ p2p/ # P2P networking (libp2p) peerinfo/ # Peer info utilities relay-server/ # Relay server implementation + test-compose/ # Docker-compose smoke-test harness (test infrastructure, not shipped) testutil/ # Test helpers/fixtures (workspace-internal) tracing/ # Observability/tracing utilities test-infra/ # Docker-compose and local infra for integration testing/observability diff --git a/Cargo.lock b/Cargo.lock index 5694b4f6..e6cb1c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5693,6 +5693,25 @@ dependencies = [ "tree_hash", ] +[[package]] +name = "pluto-test-compose" +version = "1.7.1" +dependencies = [ + "k256", + "nix", + "pluto-eth2util", + "pluto-k1util", + "serde", + "serde_json", + "tempfile", + "test-case", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pluto-testutil" version = "1.7.1" diff --git a/Cargo.toml b/Cargo.toml index 6648b2b1..9c895e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/frost", "crates/priority", "crates/infosync", + "crates/test-compose", ] # Vendored fork consumed only via [patch.crates-io]; excluded so it builds/tests # standalone (its upstream code isn't written to this workspace's lints) without @@ -59,6 +60,7 @@ futures-timer = "3.0" backon = "1.6.0" hex = { version = "0.4.3" } hex-literal = "0.4" +nix = { version = "0.30", features = ["user"] } prost = "0.14" prost-build = "0.14" prost-types = "0.14" @@ -173,6 +175,7 @@ pluto-peerinfo = { path = "crates/peerinfo" } pluto-frost = { path = "crates/frost" } pluto-priority = { path = "crates/priority" } pluto-infosync = { path = "crates/infosync" } +pluto-test-compose = { path = "crates/test-compose" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/cli/src/commands/create_cluster.rs b/crates/cli/src/commands/create_cluster.rs index 29fccb12..89f853e4 100644 --- a/crates/cli/src/commands/create_cluster.rs +++ b/crates/cli/src/commands/create_cluster.rs @@ -2864,7 +2864,7 @@ mod tests { /// `CHARON_*` env var. Charon binds env for all commands generically /// (viper `SetEnvPrefix`+`AutomaticEnv`), so tooling that configures a /// cluster purely through the environment — the compose harness in - /// `test-infra/compose` — works against charon and pluto alike. + /// `crates/test-compose` — works against charon and pluto alike. #[test] fn create_cluster_flags_use_charon_env_prefix() { use clap::CommandFactory as _; diff --git a/crates/test-compose/Cargo.toml b/crates/test-compose/Cargo.toml new file mode 100644 index 00000000..b0071f32 --- /dev/null +++ b/crates/test-compose/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pluto-test-compose" +description = "Docker-compose smoke-test harness for pluto and charon clusters. Test infrastructure, not shipped." +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +k256.workspace = true +nix.workspace = true +pluto-eth2util.workspace = true +pluto-k1util.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true +test-case.workspace = true +tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/crates/test-compose/README.md b/crates/test-compose/README.md new file mode 100644 index 00000000..d11ae62c --- /dev/null +++ b/crates/test-compose/README.md @@ -0,0 +1,67 @@ +# Pluto Compose + +Docker-compose smoke-test harness for pluto and charon clusters, adapted from +charon's `testutil/compose`. Test infrastructure: nothing here ships in the +`pluto` binary. + +A cluster is produced in steps (`define` → `lock` → `run`), each +rewriting `docker-compose.yml` from `config.json`. `auto` chains the steps +against a docker daemon, brings the cluster up and watches Prometheus for +alerts. Nodes are charon or pluto per `node_impls`; key generation follows +`key_gen_impl`. + +## Smoke tests + +`tests/smoke.rs` holds one `#[ignore]`d test per scenario, named +`scenario_`. Each stands up a cluster for two minutes and fails on any +firing alert. Prerequisites: docker with compose v2, and `oas3-gen` from +`CONTRIBUTING.md` (the harness links `pluto-eth2util`, whose API types are +generated at build time). + +```bash +# one or more scenarios +cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture --exact scenario_default_alpha scenario_pluto_dkg +# the CI matrix (very_large needs a big machine) +cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture --test-threads=1 --skip scenario_very_large +# keep per-scenario logs +SMOKE_LOG_DIR=. cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture --exact scenario_default_alpha +``` + +| Variable | Effect | +|---|---| +| `PLUTO_REPO` | Repository root the `pluto:local` image is built from (default: this workspace). | +| `SMOKE_SUDO_PERMS` | Set to `1` when containers run as root, so the harness can `sudo chown` its artefacts. | +| `SMOKE_LOG_DIR` | Write `/.log` with the `docker compose up` output. | +| `SMOKE_EXTERNAL_RELAY` | Use this relay URL instead of the in-cluster relay. | + +The CI workflow (`.github/workflows/smoke-tests.yml`) is manual-only and runs +the same command. + +## Alert criteria vs. charon + +Adapted from charon's `testutil/compose` alert rules, but the gate is corrected and the +criteria calibrated to actually fire: charon's collector matches Prometheus alert state +`"active"`, which is never emitted (only `inactive` / `pending` / `firing`), so upstream +nothing is ever gated. This harness matches `"firing"`, so several rules necessarily differ: + +| Rule | Charon v1.7.1 | Pluto | Change & why | +|------|---------------|-------|--------------| +| `Pluto Down` | `up == 0` | `up == 0` | identical | +| `Validator API Error Rate` | `increase(…{endpoint!="proxy"}[30s]) > 1` | same | identical | +| `Proxy API Error Rate` | `increase(…{endpoint="proxy"}[30s]) > 5` | same | identical | +| `Warn Log Rate` | `increase(app_log_warn_total[30s]) > 2` | same + `{topic!~"vmock\|tracker"}` | exclude charon mock-noise topics (vmock has no builder-registration handler; the beacon mock never includes broadcasts on-chain) | +| `Error Log Rate` | `app_log_error_total > 0` | `increase(app_log_error_total[30s]) > 0` | windowed — an absolute counter can't recover from the inherent cold-start consensus timeout (mock-VC startup delay → no randao); a window + warmup can | +| `Broadcast Duty Rate` | `increase(core_bcast_broadcast_total[30s]) < 0.5` | `(sum by (job) (increase(…{job=~"node[0-9]+"}[30s])) or on (job) max by (job) (0 * up)) < 0.5` | per-node sum + absent-series fallback, so a node emitting *no* broadcast series fails (charon's per-series form missed it) | +| `Outstanding Duty Rate` | `core_bcast_broadcast_total − core_scheduler_duty_total > 50` | *removed* | dead rule — a duty is broadcast at most as often as scheduled, so it can never be positive | +| _gate (alert state)_ | `"active"` — never emitted | `"firing"` + readiness wait + 60s warmup allowlist | charon's gate is vacuous; pluto's enforces | + +Scenarios that intentionally degrade the cluster tune the gate via config, not the code: + +| Config knob | Effect | Used by | +|-------------|--------|---------| +| `alert_exclude_jobs` | exempt a node from the per-node rules (never from `Pluto Down`) | `1_of_4_down`, `1_of_3_down` | +| `alert_disable_rules` | drop an entire rule | `1_of_3_down` (disables the error-rate gates — a downed round-1 leader makes every third proposer duty unrecoverable on the mock) | + +## Versioning + +The charon image tag is `CHARON_IMAGE_TAG` in `src/smoke.rs`. diff --git a/crates/test-compose/src/alert.rs b/crates/test-compose/src/alert.rs new file mode 100644 index 00000000..e0c9dba6 --- /dev/null +++ b/crates/test-compose/src/alert.rs @@ -0,0 +1,558 @@ +//! Prometheus alert collection for the automated flow. +//! +//! While the cluster runs, the collector polls the Prometheus rules API +//! through the compose `curl` container and reports every alert that starts +//! firing. Rules known to fire on any healthy cluster while it boots are +//! ignored during a warmup window after Prometheus first answers. + +use std::{ + collections::HashSet, + future::Future, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::Deserialize; +use tokio::{ + process::Command, + sync::mpsc, + time::{self, Instant}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::{ + define::{BROADCAST_RULE, ERROR_RATE_RULE, WARN_RATE_RULE}, + duration::go_duration_string, + error::{CommandError, ComposeError, Result}, +}; + +/// Window after Prometheus first answers during which the cold-start +/// transients are ignored. +pub const ALERT_WARMUP: Duration = Duration::from_secs(60); + +/// Interval between two polls of the rules API. +pub const ALERT_POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Alert rules that fire on any healthy cluster while it boots: log rates and +/// broadcast latency spike while the nodes find each other and sync. +pub const STARTUP_TRANSIENT_RULES: [&str; 3] = [ERROR_RATE_RULE, WARN_RATE_RULE, BROADCAST_RULE]; + +/// Returns whether `rule` may fire during warmup without being reported. +pub fn is_startup_transient(rule: impl AsRef) -> bool { + STARTUP_TRANSIENT_RULES.contains(&rule.as_ref()) +} + +/// What the collector reports on its channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AlertEvent { + /// A newly firing alert, or a non-success status from Prometheus. + Alert(String), + /// Sent last, only when polling was still healthy at the end of the + /// observation window. + Polled, +} + +/// A firing alert: the rule name and its rendered description. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveAlert { + /// The alert rule name. + pub rule: String, + /// The rendered `description` annotation. + pub description: String, +} + +/// Response of `GET /api/v1/rules?type=alert`. Unknown fields are ignored and +/// missing ones default, as with Go's `encoding/json`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromAlerts { + /// `"success"` on a healthy response. + #[serde(default)] + pub status: String, + /// The rule groups. + #[serde(default)] + pub data: PromData, +} + +/// The `data` object of a rules API response. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromData { + /// The rule groups. + #[serde(default)] + pub groups: Vec, +} + +/// A rule group. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromGroup { + /// The group name. + #[serde(default)] + pub name: String, + /// The alerting rules in the group. + #[serde(default)] + pub rules: Vec, +} + +/// An alerting rule with its current alerts. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromRule { + /// The rule name. + #[serde(default)] + pub name: String, + /// The alerts the rule currently produces. + #[serde(default)] + pub alerts: Vec, +} + +/// One alert instance of a rule. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromAlert { + /// `firing`, `pending` or `inactive`. + #[serde(default)] + pub state: String, + /// The alert annotations. + #[serde(default)] + pub annotations: PromAlertAnnotations, +} + +/// The annotations of an alert. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromAlertAnnotations { + /// The rendered description. + #[serde(default)] + pub description: String, +} + +/// Source of alert rule snapshots. +pub trait AlertPoller: Send + Sync + 'static { + /// Fetches the current alerting rules. + fn query(&self) -> impl Future> + Send; +} + +/// Polls Prometheus through the compose `curl` container. +#[derive(Debug, Clone)] +pub struct DockerCurlPoller { + dir: PathBuf, +} + +impl DockerCurlPoller { + /// A poller for the cluster in compose directory `dir`. + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } +} + +impl AlertPoller for DockerCurlPoller { + async fn query(&self) -> Result { + query_alerts(&self.dir).await + } +} + +/// Runs `docker compose exec -T curl curl -s ` in `dir` and parses +/// the response. +async fn query_alerts(dir: &Path) -> Result { + let output = Command::new("docker") + .args([ + "compose", + "exec", + "-T", + "curl", + "curl", + "-s", + "http://prometheus:9090/api/v1/rules?type=alert", + ]) + .current_dir(dir) + .kill_on_drop(true) + .output() + .await; + + let output = + CommandError::check_output(output).map_err(ComposeError::exec("exec curl alerts"))?; + + // curl -s puts the body on stdout; a plain-text error page it may have + // fetched belongs in the message too. + let out = crate::error::combined_output(&output); + + serde_json::from_str(out.trim()).map_err(|source| ComposeError::UnmarshalAlerts { source, out }) +} + +/// Starts polling alerts on a background task until `token` is cancelled. +/// +/// Every newly firing alert description is sent on the returned channel. When +/// the token fires, the collector sends [`AlertEvent::Polled`] as its last +/// message if the final poll succeeded and at least one poll succeeded after +/// the warmup window, then closes the channel. +pub fn start_collector( + token: CancellationToken, + poller: impl AlertPoller, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(100); + tokio::spawn(collect(token, poller, tx)); + rx +} + +async fn collect(token: CancellationToken, poller: impl AlertPoller, tx: mpsc::Sender) { + let Some(ready_at) = await_prometheus_ready(&token, &poller).await else { + return; + }; + + info!( + warmup = %go_duration_string(ALERT_WARMUP), + "Prometheus ready, collecting alerts" + ); + + // `None` only on Instant overflow, in which case warmup never ends. + let warmup_end = ready_at.checked_add(ALERT_WARMUP); + let mut reported = HashSet::new(); + let mut ignored = HashSet::new(); + let mut last_poll_ok = false; + let mut post_warmup_poll_ok = false; + + while !token.is_cancelled() { + let Some(result) = query_or_cancel(&token, &poller).await else { + break; + }; + + match result { + Err(err) => { + last_poll_ok = false; + error!(%err, "Poll prometheus alerts"); + } + Ok(alerts) if alerts.status != "success" => { + last_poll_ok = false; + let _ = tx + .send(AlertEvent::Alert(format!( + "non success status from prometheus alerts: {}", + alerts.status + ))) + .await; + } + Ok(alerts) => { + last_poll_ok = true; + + let in_warmup = warmup_end.is_none_or(|end| Instant::now() < end); + if !in_warmup { + post_warmup_poll_ok = true; + } + + for active in get_active_alerts(&alerts) { + if in_warmup && is_startup_transient(&active.rule) { + if ignored.insert(active.description.clone()) { + info!( + alert = %active.description, + "Ignoring known cold-start transient during warmup" + ); + } + + continue; + } + + if !reported.insert(active.description.clone()) { + continue; + } + + info!(alert = %active.description, "Detected new alert"); + + let _ = tx.send(AlertEvent::Alert(active.description)).await; + } + } + } + + sleep_or_cancel(&token, ALERT_POLL_INTERVAL).await; + } + + if post_warmup_poll_ok && last_poll_ok { + let _ = tx.send(AlertEvent::Polled).await; + } +} + +/// Polls until the rules API answers with a success status and returns when +/// it did, or `None` when the token fired first. +async fn await_prometheus_ready( + token: &CancellationToken, + poller: &impl AlertPoller, +) -> Option { + info!("Waiting for prometheus to answer the rules API"); + + while !token.is_cancelled() { + if let Some(Ok(alerts)) = query_or_cancel(token, poller).await + && alerts.status == "success" + { + return Some(Instant::now()); + } + + sleep_or_cancel(token, ALERT_POLL_INTERVAL).await; + } + + None +} + +/// Runs one poll unless the token fires first. +/// +/// Returns `None` when the window closed before or while the poll ran: that +/// failure is expected and must not count against the verdict. Abandoning the +/// query drops its future, which terminates the `docker compose exec` behind +/// it, so a stalled daemon cannot hold the collector (and with it the final +/// teardown) past the observation window. +async fn query_or_cancel( + token: &CancellationToken, + poller: &impl AlertPoller, +) -> Option> { + let result = token.run_until_cancelled(poller.query()).await?; + + (!token.is_cancelled()).then_some(result) +} + +async fn sleep_or_cancel(token: &CancellationToken, duration: Duration) { + let _ = token.run_until_cancelled(time::sleep(duration)).await; +} + +/// Extracts the firing alerts of a rules API response, in response order. +pub fn get_active_alerts(alerts: &PromAlerts) -> Vec { + let mut active = Vec::new(); + for group in &alerts.data.groups { + for rule in &group.rules { + for alert in &rule.alerts { + if alert.state != "firing" { + continue; + } + + active.push(ActiveAlert { + rule: rule.name.clone(), + description: alert.annotations.description.clone(), + }); + } + } + } + + active +} + +#[cfg(test)] +mod tests { + use std::io; + + use super::*; + use crate::define::{PLUTO_DOWN_RULE, PROXY_RATE_RULE, VAPI_RATE_RULE}; + + #[test] + fn get_active_alerts_firing_only() { + let payload = r#"{ + "status": "success", + "data": { + "groups": [{ + "name": "cluster", + "rules": [ + { + "name": "Error Log Rate", + "alerts": [ + {"state": "firing", "annotations": {"description": "node0 has a high error rate"}}, + {"state": "pending", "annotations": {"description": "node1 has a high error rate"}} + ] + }, + { + "name": "Pluto Down", + "alerts": [ + {"state": "inactive", "annotations": {"description": "node2 is down"}}, + {"state": "active", "annotations": {"description": "node3 is down"}} + ] + } + ] + }] + } + }"#; + let alerts: PromAlerts = serde_json::from_str(payload).expect("parse payload"); + + let active = get_active_alerts(&alerts); + + assert_eq!( + active, + vec![ActiveAlert { + rule: "Error Log Rate".to_string(), + description: "node0 has a high error rate".to_string(), + }] + ); + } + + #[test] + fn startup_transient_rules_scoped() { + assert!(is_startup_transient(ERROR_RATE_RULE)); + assert!(is_startup_transient(WARN_RATE_RULE)); + assert!(is_startup_transient(BROADCAST_RULE)); + assert!(!is_startup_transient(PLUTO_DOWN_RULE)); + assert!(!is_startup_transient(VAPI_RATE_RULE)); + assert!(!is_startup_transient(PROXY_RATE_RULE)); + assert_eq!(STARTUP_TRANSIENT_RULES.len(), 3); + } + + /// Answers each poll from a script keyed by the time elapsed since the + /// poller was created. + struct ScriptedPoller { + start: Instant, + script: Box Result + Send + Sync>, + } + + impl AlertPoller for ScriptedPoller { + async fn query(&self) -> Result { + (self.script)(self.start.elapsed()) + } + } + + fn healthy() -> Result { + Ok(PromAlerts { + status: "success".to_string(), + data: PromData::default(), + }) + } + fn firing(rule: &str, description: &str) -> Result { + Ok(PromAlerts { + status: "success".to_string(), + data: PromData { + groups: vec![PromGroup { + name: "cluster".to_string(), + rules: vec![PromRule { + name: rule.to_string(), + alerts: vec![PromAlert { + state: "firing".to_string(), + annotations: PromAlertAnnotations { + description: description.to_string(), + }, + }], + }], + }], + }, + }) + } + + fn failing() -> Result { + Err(ComposeError::exec("exec curl alerts")(io::Error::other( + "no such container", + ))) + } + + /// Runs the collector with the harness cadence under paused time for + /// `window` (an odd number of seconds, so the deadline never coincides + /// with a poll), then cancels it and drains the channel. + async fn run_collector( + window: Duration, + script: impl Fn(Duration) -> Result + Send + Sync + 'static, + ) -> Vec { + let token = CancellationToken::new(); + let poller = ScriptedPoller { + start: Instant::now(), + script: Box::new(script), + }; + let mut rx = start_collector(token.clone(), poller); + + time::sleep(window).await; + token.cancel(); + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + + events + } + + const WINDOW: Duration = Duration::from_secs(125); + + fn secs(n: u64) -> Duration { + Duration::from_secs(n) + } + + #[tokio::test(start_paused = true)] + async fn healthy_window_reports_polled_only() { + let events = run_collector(WINDOW, |_| healthy()).await; + assert_eq!(events, vec![AlertEvent::Polled]); + } + + #[tokio::test(start_paused = true)] + async fn never_ready_reports_nothing() { + let events = run_collector(WINDOW, |_| failing()).await; + assert!(events.is_empty(), "{events:?}"); + } + + #[tokio::test(start_paused = true)] + async fn transient_alert_only_during_warmup_is_ignored() { + let events = run_collector(WINDOW, |t| { + if t < secs(30) { + firing(ERROR_RATE_RULE, "node0 has a high error rate") + } else { + healthy() + } + }) + .await; + assert_eq!(events, vec![AlertEvent::Polled]); + } + + #[tokio::test(start_paused = true)] + async fn persistent_alert_is_reported_once() { + let events = run_collector(WINDOW, |t| { + if t >= secs(70) { + firing(VAPI_RATE_RULE, "node1 has a high validator api error rate") + } else { + healthy() + } + }) + .await; + assert_eq!( + events, + vec![ + AlertEvent::Alert("node1 has a high validator api error rate".to_string()), + AlertEvent::Polled + ] + ); + } + + /// Answers every poll with a healthy response until `stall_after` has + /// elapsed since creation, then never answers again. + struct StallingPoller { + start: Instant, + stall_after: Duration, + } + + impl AlertPoller for StallingPoller { + async fn query(&self) -> Result { + if self.start.elapsed() < self.stall_after { + return healthy(); + } + + std::future::pending().await + } + } + + /// Runs the collector against a poller that stalls after `stall_after`, + /// cancels it after `window` and drains the channel, failing if the + /// collector does not shut down promptly once cancelled. + async fn run_stalling(window: Duration, stall_after: Duration) -> Vec { + let token = CancellationToken::new(); + let poller = StallingPoller { + start: Instant::now(), + stall_after, + }; + let mut rx = start_collector(token.clone(), poller); + + time::sleep(window).await; + token.cancel(); + + let drain = async { + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + + events + }; + + time::timeout(secs(10), drain) + .await + .expect("collector did not stop after cancel") + } + + #[tokio::test(start_paused = true)] + async fn stalled_poll_during_warmup_stops_on_cancel_without_verdict() { + let events = run_stalling(WINDOW, secs(1)).await; + assert!(events.is_empty(), "{events:?}"); + } +} diff --git a/crates/test-compose/src/auto.rs b/crates/test-compose/src/auto.rs new file mode 100644 index 00000000..87d55cb4 --- /dev/null +++ b/crates/test-compose/src/auto.rs @@ -0,0 +1,240 @@ +//! The automated flow: define, lock and run a cluster back to back against +//! docker compose, then keep it running while Prometheus is watched for +//! alerts. + +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use tokio::{sync::mpsc, task}; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::{ + alert::{AlertEvent, DockerCurlPoller, start_collector}, + config::{Config, load_config}, + define::{DefineOptions, define}, + error::{ComposeError, Result}, + lock::lock, + process::{LogSink, UpOutcome, build_and_create, down, fix_perms, print_docker_compose, up}, + run::run, + template::{TmplData, write_docker_compose}, +}; + +/// Hook that adjusts a step's template data before `docker-compose.yml` is +/// rewritten. +pub type TmplFn = fn(&mut TmplData); + +/// Configuration of [`auto`]. +#[derive(Debug, Clone)] +pub struct AutoConfig { + /// The compose directory holding `config.json`. + pub dir: PathBuf, + /// How long to keep the cluster running while collecting alerts. Zero + /// runs the cluster until it exits on its own. + pub alert_timeout: Duration, + /// Fix artefact permissions with `sudo` after each step and before each + /// `docker compose down`. + pub sudo_perms: bool, + /// Print `docker-compose.yml` after each step. + pub print_yml: bool, + /// Adjusts the run step template data. + pub run_tmpl_fn: Option, + /// Append the `docker compose up` output to this file instead of stdout. + pub log_file: Option, +} + +impl AutoConfig { + /// A config for compose directory `dir` with everything else at its + /// defaults: no alert window, no sudo, no printing, stdout logging. + pub fn new(dir: impl Into) -> Self { + Self { + dir: dir.into(), + alert_timeout: Duration::ZERO, + sudo_perms: false, + print_yml: false, + run_tmpl_fn: None, + log_file: None, + } + } +} + +/// Runs the define, lock and run steps in `conf.dir`, brings the cluster up +/// and, when `alert_timeout` is set, keeps it running for that long while +/// polling Prometheus. Fails when the cluster stops early, when Prometheus +/// could not be polled through the end of the window, or when alerts fired. +/// +/// The cluster is torn down with `docker compose down` before returning. +pub async fn auto(conf: AutoConfig) -> Result<()> { + let AutoConfig { + dir, + alert_timeout, + sudo_perms, + print_yml, + run_tmpl_fn, + log_file, + } = conf; + + let mut sink = LogSink::open(log_file.as_deref())?; + let never = CancellationToken::new(); + let step = StepRunner { + dir: &dir, + sudo_perms, + print_yml, + }; + + step.run("define", None, |dir: &Path, conf| { + define(dir, conf, &DefineOptions::default()) + }) + .await?; + sink.banner("===== define step: docker compose up =====\n"); + up(&dir, &sink, &never).await?; + + step.run("lock", None, |dir: &Path, conf| lock(dir, conf)) + .await?; + sink.banner("===== lock step: docker compose up =====\n"); + up(&dir, &sink, &never).await?; + + step.run("run", run_tmpl_fn, |dir: &Path, conf| run(dir, conf)) + .await?; + + // Ensure everything is clean before the alert test starts. Permissions + // were fixed right after the run step, so plain down suffices here. + let _ = down(&dir, false).await; + + sink.banner("===== run step: docker compose up --no-start --build =====\n"); + build_and_create(&dir).await?; + + let token = CancellationToken::new(); + if !alert_timeout.is_zero() { + let deadline = token.clone(); + tokio::spawn(async move { + tokio::time::sleep(alert_timeout).await; + deadline.cancel(); + }); + } + + let mut alerts = start_collector(token.clone(), DockerCurlPoller::new(&dir)); + + sink.banner("===== run step: docker compose up =====\n"); + let result = observe(&dir, &sink, &token, alert_timeout, &mut alerts).await; + + let _ = down(&dir, sudo_perms).await; + token.cancel(); + + result +} + +/// Brings the cluster up and turns the collected alerts into a verdict. +async fn observe( + dir: &Path, + sink: &LogSink, + token: &CancellationToken, + alert_timeout: Duration, + alerts: &mut mpsc::Receiver, +) -> Result<()> { + match up(dir, sink, token).await? { + // `--abort-on-container-exit` exits 0 when a container stops cleanly; + // the window was not observed, so this is a failure, not "no alerts". + UpOutcome::Exited if !alert_timeout.is_zero() => return Err(ComposeError::ClusterStopped), + // Without a window the cluster ran to completion. Stop the collector + // so the channel drains and a verdict can be reached. + UpOutcome::Exited => token.cancel(), + UpOutcome::Cancelled => {} + } + + let mut detected = Vec::new(); + let mut polled = false; + while let Some(event) = alerts.recv().await { + match event { + AlertEvent::Alert(alert) => detected.push(alert), + AlertEvent::Polled => polled = true, + } + } + + if !polled { + return Err(ComposeError::PrometheusNotPolled); + } + if !detected.is_empty() { + return Err(ComposeError::AlertsDetected { alerts: detected }); + } + + info!("No alerts detected"); + + Ok(()) +} + +/// The per-step work shared by define, lock and run. +struct StepRunner<'a> { + dir: &'a Path, + sudo_perms: bool, + print_yml: bool, +} + +impl StepRunner<'_> { + async fn run(&self, name: &'static str, tmpl_fn: Option, run_fn: F) -> Result<()> + where + F: FnOnce(&Path, Config) -> Result + Send + 'static, + { + let mut tmpl = run_step(name, self.dir, run_fn).await?; + + if self.sudo_perms { + fix_perms(self.dir).await?; + } + + if let Some(tmpl_fn) = tmpl_fn { + tmpl_fn(&mut tmpl); + write_docker_compose(self.dir, &tmpl)?; + } + + if self.print_yml { + print_docker_compose(self.dir).await?; + } + + Ok(()) + } +} + +/// Loads the config in `dir` and runs the generator step `run_fn` on it off +/// the async runtime. `topic` names the step in the log. +async fn run_step(topic: &'static str, dir: &Path, run_fn: F) -> Result +where + F: FnOnce(&Path, Config) -> Result + Send + 'static, +{ + let conf = load_config(dir)?; + + info!(command = topic, "Running compose command"); + + let step_dir = dir.to_path_buf(); + task::spawn_blocking(move || run_fn(&step_dir, conf)) + .await + .map_err(|err| { + if err.is_panic() { + std::panic::resume_unwind(err.into_panic()) + } else { + ComposeError::StepCancelled(err) + } + })? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Step, write_config}; + + #[tokio::test] + async fn run_step_runs_the_generator_on_the_loaded_config() { + let dir = tempfile::tempdir().expect("tempdir"); + write_config(dir.path(), &Config::new_default()).expect("write config"); + + let err = run_step("lock", dir.path(), |dir: &Path, conf| lock(dir, conf)) + .await + .expect_err("lock on a new config must fail"); + + assert!( + matches!(err, ComposeError::NotDefined { step: Step::New }), + "{err:?}" + ); + } +} diff --git a/crates/test-compose/src/config.rs b/crates/test-compose/src/config.rs new file mode 100644 index 00000000..12a4888b --- /dev/null +++ b/crates/test-compose/src/config.rs @@ -0,0 +1,502 @@ +//! Compose cluster configuration (`config.json`). + +use std::{fmt, fs, path::Path, time::Duration}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + Result, define::ALERT_RULE_NAMES, error::ComposeError, fsutil::write_file, template::Port, +}; + +/// Version of the compose config format. +pub const VERSION: &str = "obol/charon/compose/1.0.0"; + +pub(crate) const CONFIG_FILE: &str = "config.json"; + +const DEFAULT_IMAGE_TAG: &str = "latest"; +const DEFAULT_BEACON_NODE: &str = "mock"; +const DEFAULT_NUM_VALS: usize = 1; +const DEFAULT_NUM_NODES: usize = 4; +const DEFAULT_THRESHOLD: usize = 3; +const DEFAULT_FEATURE_SET: &str = "alpha"; + +pub(crate) const CHARON_IMAGE: &str = "obolnetwork/charon"; +const PLUTO_IMAGE: &str = "pluto"; + +/// Env var holding the path of the charon repo to build `charon:local` from. +pub const CHARON_REPO_ENV: &str = "CHARON_REPO"; +/// Env var holding the path of the pluto repo to build `pluto:local` from. +pub const PLUTO_REPO_ENV: &str = "PLUTO_REPO"; + +pub(crate) const CMD_RUN: &str = "run"; +pub(crate) const CMD_UNSAFE_RUN: &str = "[unsafe,run]"; +pub(crate) const CMD_DKG: &str = "[dkg,--shutdown-delay=2s]"; +pub(crate) const CMD_CREATE_CLUSTER: &str = "[create,cluster]"; +pub(crate) const CMD_CREATE_DKG: &str = "[create,dkg]"; + +/// Ports every charon node exposes; `run` offsets the external side per node. +pub const CHARON_PORTS: [Port; 4] = [ + Port { + external: 3600, + internal: 3600, + }, + Port { + external: 3610, + internal: 3610, + }, + Port { + external: 3620, + internal: 3620, + }, + Port { + external: 3630, + internal: 3630, + }, +]; + +/// Validator client type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VcType { + /// Charon's built-in validator mock. + Mock, + /// Consensys Teku. + Teku, + /// Sigma Prime Lighthouse. + Lighthouse, + /// Attestant Vouch. + Vouch, + /// ChainSafe Lodestar. + Lodestar, +} + +impl VcType { + /// The lowercase name used in configs and compose labels. + pub fn as_str(self) -> &'static str { + match self { + VcType::Mock => "mock", + VcType::Teku => "teku", + VcType::Lighthouse => "lighthouse", + VcType::Vouch => "vouch", + VcType::Lodestar => "lodestar", + } + } +} + +impl fmt::Display for VcType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Key generation process. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum KeyGen { + /// Distributed key generation between the nodes. + Dkg, + /// `charon create cluster` on a single node. + #[default] + Create, +} + +impl KeyGen { + /// The lowercase name used in configs. + pub fn as_str(self) -> &'static str { + match self { + KeyGen::Dkg => "dkg", + KeyGen::Create => "create", + } + } +} + +impl fmt::Display for KeyGen { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Node implementation to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NodeImpl { + /// The reference Go implementation. + Charon, + /// This Rust implementation. + Pluto, +} + +impl NodeImpl { + /// The lowercase name used in configs. + pub fn as_str(self) -> &'static str { + match self { + NodeImpl::Charon => "charon", + NodeImpl::Pluto => "pluto", + } + } + + /// Env var naming the repo a local image of this implementation is built + /// from. + pub fn repo_env(self) -> &'static str { + match self { + NodeImpl::Charon => CHARON_REPO_ENV, + NodeImpl::Pluto => PLUTO_REPO_ENV, + } + } + + /// Image reference a local build of this implementation is tagged with. + pub fn local_image(self) -> String { + match self { + NodeImpl::Charon => format!("{CHARON_IMAGE}:local"), + NodeImpl::Pluto => format!("{PLUTO_IMAGE}:local"), + } + } +} + +impl fmt::Display for NodeImpl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Compose workflow step. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Step { + /// Config written, nothing generated yet. + #[default] + New, + /// Cluster definition compose file generated. + Defined, + /// Cluster lock compose file generated. + Locked, +} + +impl Step { + /// The lowercase name used in configs. + pub fn as_str(self) -> &'static str { + match self { + Step::New => "new", + Step::Defined => "defined", + Step::Locked => "locked", + } + } +} + +impl fmt::Display for Step { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Serde adaptor that writes an empty list as `null` (a nil slice) and reads +/// `null` back as an empty list. +pub(crate) mod nullable_vec { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(crate) fn serialize( + items: &[T], + serializer: S, + ) -> std::result::Result { + if items.is_empty() { + serializer.serialize_none() + } else { + items.serialize(serializer) + } + } + + pub(crate) fn deserialize<'de, T: Deserialize<'de>, D: Deserializer<'de>>( + deserializer: D, + ) -> std::result::Result, D::Error> { + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) + } +} + +/// Serde adaptor for the optional keygen implementation: absent is the empty +/// string. +mod keygen_impl { + use serde::{Deserialize, Deserializer, Serializer, de::IntoDeserializer as _}; + + use super::NodeImpl; + + pub(super) fn serialize( + value: &Option, + serializer: S, + ) -> std::result::Result { + serializer.serialize_str(value.map_or("", NodeImpl::as_str)) + } + + pub(super) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> std::result::Result, D::Error> { + let name = String::deserialize(deserializer)?; + if name.is_empty() { + return Ok(None); + } + + NodeImpl::deserialize(name.into_deserializer()).map(Some) + } +} + +/// Serde adaptor storing a duration as Go `time.Duration` does: an integer +/// count of nanoseconds. +mod nanos { + use std::time::Duration; + + use serde::{Deserialize, Deserializer, Serializer, de, ser}; + + pub(super) fn serialize( + value: &Duration, + serializer: S, + ) -> std::result::Result { + let nanos = i64::try_from(value.as_nanos()).map_err(ser::Error::custom)?; + serializer.serialize_i64(nanos) + } + + pub(super) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> std::result::Result { + let nanos = i64::deserialize(deserializer)?; + let nanos = u64::try_from(nanos) + .map_err(|_| de::Error::custom(format!("negative duration: {nanos}")))?; + + Ok(Duration::from_nanos(nanos)) + } +} + +/// Compose cluster configuration, persisted as `config.json` in the compose +/// directory. +/// +/// Fields missing from a hand-edited file take their zero value, except the +/// enum-typed `step` and `key_gen`, which default to `new` and `create`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// Config format version, see [`VERSION`]. + pub version: String, + /// Current workflow step. + pub step: Step, + /// Number of charon/pluto nodes in the cluster. + pub num_nodes: usize, + /// Signature threshold of the cluster. + pub threshold: usize, + /// Number of distributed validators. + pub num_validators: usize, + /// Docker image tag of the charon image. + pub image_tag: String, + /// Build the charon image locally from `CHARON_REPO`. + pub build_local: bool, + /// Implementation per node index, cycled when shorter than `num_nodes`. + /// Empty means every node runs charon. + #[serde(with = "nullable_vec")] + pub node_impls: Vec, + /// Implementation running the key generation container. Absent means the + /// implementation of node 0. + #[serde(rename = "keygen_impl", with = "keygen_impl")] + pub key_gen_impl: Option, + /// Docker image tag of the pluto image; `local` builds it from + /// `PLUTO_REPO`. + pub pluto_image_tag: String, + /// Key generation process. + pub key_gen: KeyGen, + /// Directory of existing validator keys to split, relative to the compose + /// directory. Empty generates new keys. + pub split_keys_dir: String, + /// Beacon node endpoint(s), or `mock` for the built-in beacon mock. + pub beacon_nodes: String, + /// External relay address; empty runs a relay container. + pub external_relay: String, + /// Validator client per node index, cycled when shorter than `num_nodes`. + #[serde(rename = "validator_clients", with = "nullable_vec")] + pub vcs: Vec, + /// Charon feature set to enable. + pub feature_set: String, + /// Do not publish node and prometheus ports on the host. + pub disable_monitoring_ports: bool, + /// Use insecure (deterministic) validator keys. + pub insecure_keys: bool, + /// Simnet slot duration. + #[serde(with = "nanos")] + pub slot_duration: Duration, + /// Fuzz the beacon mock. + #[serde(rename = "beacon-fuzz")] + pub beacon_fuzz: bool, + /// Fuzz p2p messages sent by node 0. + #[serde(rename = "p2p-fuzz")] + pub p2p_fuzz: bool, + /// Enable synthetic block proposals. + pub synthetic_block_proposals: bool, + /// Run the grafana/tempo/loki monitoring stack. + pub monitoring: bool, + /// Enable the builder API. + pub builder_api: bool, + /// Prometheus jobs exempt from the behavioural alert rules. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub alert_exclude_jobs: Vec, + /// Alert rules to leave out of `rules.yml`, by name. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub alert_disable_rules: Vec, +} + +impl Config { + /// Returns the default config: four charon nodes with threshold three, + /// one validator, `create` key generation, the beacon mock, two lighthouse + /// validator clients plus a mock, monitoring on and a one second slot. + pub fn new_default() -> Self { + Self { + version: VERSION.to_string(), + num_nodes: DEFAULT_NUM_NODES, + threshold: DEFAULT_THRESHOLD, + num_validators: DEFAULT_NUM_VALS, + image_tag: DEFAULT_IMAGE_TAG.to_string(), + node_impls: vec![NodeImpl::Charon], + pluto_image_tag: "local".to_string(), + vcs: vec![VcType::Lighthouse, VcType::Lighthouse, VcType::Mock], + key_gen: KeyGen::Create, + beacon_nodes: DEFAULT_BEACON_NODE.to_string(), + step: Step::New, + feature_set: DEFAULT_FEATURE_SET.to_string(), + slot_duration: Duration::from_secs(1), + synthetic_block_proposals: true, + monitoring: true, + ..Self::default() + } + } + + /// Checks the config for values the generator cannot act on. + pub fn validate(&self) -> Result<()> { + for rule in &self.alert_disable_rules { + if !ALERT_RULE_NAMES.contains(&rule.as_str()) { + return Err(ComposeError::UnknownAlertRule { rule: rule.clone() }); + } + } + + Ok(()) + } + + /// Returns the implementation of the node at `index`, cycling through + /// `node_impls`; charon when none are configured. + pub fn node_impl(&self, index: usize) -> NodeImpl { + self.node_impls + .iter() + .cycle() + .nth(index) + .copied() + .unwrap_or(NodeImpl::Charon) + } + + /// Returns the implementation that runs key generation: `key_gen_impl` + /// when set, otherwise node 0's implementation. + pub fn keygen_impl(&self) -> NodeImpl { + self.key_gen_impl.unwrap_or_else(|| self.node_impl(0)) + } + + /// Returns the per-service image override for the compose template: the + /// pluto image for pluto, empty for charon (which uses the shared base). + pub fn image_override(&self, node_impl: NodeImpl) -> String { + match node_impl { + NodeImpl::Pluto => { + let tag = &self.pluto_image_tag; + format!("{PLUTO_IMAGE}:{tag}") + } + NodeImpl::Charon => String::new(), + } + } + + /// Whether any node or the keygen container runs pluto. + pub fn uses_pluto(&self) -> bool { + (0..self.num_nodes).any(|i| self.node_impl(i) == NodeImpl::Pluto) + || self.keygen_impl() == NodeImpl::Pluto + } +} + +/// Serialises `value` as JSON indented by one space, the layout Go's +/// `json.MarshalIndent(v, "", " ")` produces and the golden files record. +pub(crate) fn marshal_indent(value: &T) -> serde_json::Result> { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut serializer = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut serializer)?; + + Ok(buf) +} + +/// Validates `conf` and writes it to `config.json` in `dir`. +pub fn write_config(dir: impl AsRef, conf: &Config) -> Result<()> { + conf.validate()?; + + let json = marshal_indent(conf).map_err(ComposeError::MarshalConfig)?; + + write_file(dir.as_ref().join(CONFIG_FILE), json, 0o755) + .map_err(ComposeError::io("write config")) +} + +/// Loads and validates `config.json` from `dir`. +pub fn load_config(dir: impl AsRef) -> Result { + let bytes = + fs::read(dir.as_ref().join(CONFIG_FILE)).map_err(ComposeError::io("load config"))?; + + let conf: Config = serde_json::from_slice(&bytes).map_err(ComposeError::UnmarshalConfig)?; + conf.validate()?; + + Ok(conf) +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + use super::*; + + #[test_case(&[NodeImpl::Pluto], 3, NodeImpl::Pluto ; "single_cycles")] + #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 2, NodeImpl::Charon ; "mixed_wraps")] + #[test_case(&[], 1, NodeImpl::Charon ; "empty_is_charon")] + fn node_impl_cycles(impls: &[NodeImpl], index: usize, want: NodeImpl) { + let conf = Config { + node_impls: impls.to_vec(), + ..Config::new_default() + }; + assert_eq!(conf.node_impl(index), want); + } + + #[test] + fn config_roundtrips_through_json() { + let mut conf = Config::new_default(); + conf.node_impls = vec![NodeImpl::Charon, NodeImpl::Pluto]; + conf.key_gen_impl = Some(NodeImpl::Pluto); + conf.alert_exclude_jobs = vec!["node0".to_string()]; + conf.alert_disable_rules = vec!["Pluto Down".to_string()]; + + let json = marshal_indent(&conf).expect("marshal"); + let back: Config = serde_json::from_slice(&json).expect("unmarshal"); + assert_eq!(back, conf); + } + + #[test] + fn config_validate_rejects_unknown_impl() { + // Enum-typed impls cannot hold unknown names; only a hand-edited config + // can carry one. + let dir = tempfile::tempdir().expect("tempdir"); + let bad_json = r#"{"version":"obol/charon/compose/1.0.0","node_impls":["geth"]}"#; + fs::write(dir.path().join(CONFIG_FILE), bad_json).expect("write"); + let err = load_config(dir.path()).expect_err("must fail"); + assert!(err.to_string().contains("unknown variant `geth`"), "{err}"); + + let dir = tempfile::tempdir().expect("tempdir"); + let bad_json = r#"{"version":"obol/charon/compose/1.0.0","keygen_impl":"plutoo"}"#; + fs::write(dir.path().join(CONFIG_FILE), bad_json).expect("write"); + let err = load_config(dir.path()).expect_err("must fail"); + assert!( + err.to_string().contains("unknown variant `plutoo`"), + "{err}" + ); + + // The happy path still validates. + let mut conf = Config::new_default(); + conf.node_impls = vec![NodeImpl::Charon, NodeImpl::Pluto]; + conf.key_gen_impl = Some(NodeImpl::Pluto); + let dir = tempfile::tempdir().expect("tempdir"); + write_config(dir.path(), &conf).expect("write config"); + assert_eq!(load_config(dir.path()).expect("load config"), conf); + } +} diff --git a/crates/test-compose/src/define.rs b/crates/test-compose/src/define.rs new file mode 100644 index 00000000..86547ea7 --- /dev/null +++ b/crates/test-compose/src/define.rs @@ -0,0 +1,599 @@ +//! Cluster definition step and local image builds. + +use std::{collections::BTreeSet, fs, io, path::Path, process::Command}; + +use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; +use pluto_eth2util::{enr::Record, network::GOERLI}; +use tracing::info; + +use crate::{ + Result, + config::{CHARON_IMAGE, CMD_CREATE_DKG, Config, KeyGen, NodeImpl, Step, write_config}, + error::{CommandError, ComposeError}, + fsutil::{env_non_empty, go_abs, go_path_join, go_rel, write_file}, + static_files::STATIC_FILES, + template::{Kv, TmplData, TmplNode, write_docker_compose}, +}; + +/// The zero address, quoted for the compose environment: not owned by any +/// user and commonly used as a generic null address. +pub(crate) const ZERO_ADDRESS: &str = r#""0x0000000000000000000000000000000000000000""#; + +/// Alert rule: a node stopped answering scrapes. +pub const PLUTO_DOWN_RULE: &str = "Pluto Down"; +/// Alert rule: error logs in the last 30 seconds. +pub const ERROR_RATE_RULE: &str = "Error Log Rate"; +/// Alert rule: more than two warning logs in the last 30 seconds. +pub const WARN_RATE_RULE: &str = "Warn Log Rate"; +/// Alert rule: validator API errors (excluding the proxy). +pub const VAPI_RATE_RULE: &str = "Validator API Error Rate"; +/// Alert rule: proxied validator API errors. +pub const PROXY_RATE_RULE: &str = "Proxy API Error Rate"; +/// Alert rule: fewer than half a duty broadcast per 30 seconds. +pub const BROADCAST_RULE: &str = "Broadcast Duty Rate"; + +/// Every alert rule [`alert_rules`] can generate; `alert_disable_rules` +/// entries must name one of these. +pub const ALERT_RULE_NAMES: [&str; 6] = [ + PLUTO_DOWN_RULE, + ERROR_RATE_RULE, + WARN_RATE_RULE, + VAPI_RATE_RULE, + PROXY_RATE_RULE, + BROADCAST_RULE, +]; + +/// Generator for node p2p private keys. +pub type KeyGenFn = fn() -> SecretKey; + +/// Knobs for [`define`] that are process-wide toggles in the Go harness. +#[derive(Debug, Clone, Copy)] +pub struct DefineOptions { + /// Pull the `latest` charon image and build `pluto:local` when the config + /// asks for them. Disabled by tests, which have no docker. + pub pull_images: bool, + /// Generator for the per-node ENR private keys of DKG clusters. Tests + /// swap in a deterministic generator to get reproducible ENRs. + pub key_gen: KeyGenFn, +} + +impl Default for DefineOptions { + fn default() -> Self { + Self { + pull_images: true, + key_gen: || SecretKey::random(&mut OsRng), + } + } +} + +/// Creates `path` and its parents with `mode` (subject to the umask). +pub(crate) fn mkdir_all(path: impl AsRef, mode: u32) -> io::Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + builder.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + + builder.create(path) +} + +/// Defines a compose cluster: writes the `defined` config, the static +/// monitoring files, the Prometheus scrape config and alert rules, and a +/// `docker-compose.yml` that either runs `charon create dkg` (DKG key +/// generation) or a no-op echo container (`create` key generation). +/// +/// For DKG clusters the per-node ENR private keys are generated with +/// `opts.key_gen` and saved as `node/charon-enr-private-key`. +pub fn define(dir: impl AsRef, mut conf: Config, opts: &DefineOptions) -> Result { + let dir = dir.as_ref(); + let dir_str = dir.to_string_lossy().into_owned(); + + if conf.step != Step::New { + return Err(ComposeError::NotNew { step: conf.step }); + } + + if conf.build_local { + build_local(NodeImpl::Charon)?; + } + + if opts.pull_images && !conf.build_local && conf.image_tag == "latest" { + pull_latest()?; + } + + if opts.pull_images && conf.uses_pluto() && conf.pluto_image_tag == "local" { + build_local(NodeImpl::Pluto)?; + } + + if !conf.split_keys_dir.is_empty() { + validate_split_keys_dir(&dir_str, &conf.split_keys_dir)?; + } + + let data = if conf.key_gen == KeyGen::Dkg { + info!("Creating node*/charon-enr-private-key for ENRs required for charon create dkg"); + + // charon create dkg requires operator ENRs, so we need to create + // p2pkeys now. + let mut enrs = Vec::with_capacity(conf.num_nodes); + + for i in 0..conf.num_nodes { + let key = (opts.key_gen)(); + + // Best effort creation of folder, rather fail when saving p2pkey + // file next. + let _ = mkdir_all(node_file(&dir_str, i, ""), 0o755); + + let key_file = node_file(&dir_str, i, "charon-enr-private-key"); + pluto_k1util::save(&key, Path::new(&key_file))?; + + enrs.push(Record::from_key(&key)?.to_string()); + } + + let kvs = vec![ + Kv::new("name", "compose"), + Kv::new("num_validators", conf.num_validators.to_string()), + Kv::new("operator_enrs", enrs.join(",")), + Kv::new("threshold", conf.threshold.to_string()), + Kv::new("withdrawal_addresses", ZERO_ADDRESS), + Kv::new("fee-recipient_addresses", ZERO_ADDRESS), + Kv::new("dkg_algorithm", "frost"), + Kv::new("output_dir", "/compose"), + Kv::new("network", GOERLI.name), + ]; + + let node = TmplNode { + image: conf.image_override(conf.keygen_impl()), + env_vars: kvs, + ..TmplNode::default() + }; + + TmplData { + compose_dir: dir_str.clone(), + charon_image_tag: conf.image_tag.clone(), + charon_command: CMD_CREATE_DKG.to_string(), + nodes: vec![node], + ..TmplData::default() + } + } else { + // Other keygens only need a noop docker compose, since + // charon-compose.yml is used directly in their compose lock. + let key_gen = conf.key_gen; + + TmplData { + compose_dir: dir_str.clone(), + charon_image_tag: conf.image_tag.clone(), + charon_entrypoint: "echo".to_string(), + charon_command: format!("No charon commands needed for keygen={key_gen} define step"), + nodes: vec![TmplNode::default()], + ..TmplData::default() + } + }; + + info!("Creating config.json"); + + conf.step = Step::Defined; + write_config(dir, &conf)?; + + copy_static_folders(dir)?; + + let prom_dir = dir.join("prometheus"); + mkdir_all(&prom_dir, 0o755).map_err(ComposeError::io("mkdir prometheus"))?; + write_file( + prom_dir.join("prometheus.yml"), + prometheus_config(&conf), + 0o644, + ) + .map_err(ComposeError::io("write prometheus.yml"))?; + write_file(prom_dir.join("rules.yml"), alert_rules(&conf), 0o644) + .map_err(ComposeError::io("write rules.yml"))?; + + info!("Creating docker-compose.yml"); + info!("Create cluster definition: docker compose up"); + + write_docker_compose(dir, &data)?; + + Ok(data) +} + +/// Fails unless the split keys dir is a child of the compose dir. +fn validate_split_keys_dir(dir: &str, split_keys_dir: &str) -> Result<()> { + let rel = rel_split_keys_dir(dir, split_keys_dir)?; + if rel.starts_with("..") { + return Err(ComposeError::SplitKeysDirNotChild { relative: rel }); + } + + Ok(()) +} + +/// Returns `split_keys_dir` relative to `dir`, or empty when unset. +pub(crate) fn rel_split_keys_dir(dir: &str, split_keys_dir: &str) -> Result { + if split_keys_dir.is_empty() { + return Ok(String::new()); + } + + let base = go_abs(dir).map_err(ComposeError::io("abs dir"))?; + let target = go_abs(split_keys_dir).map_err(ComposeError::io("abs dir"))?; + + go_rel(&base, &target).ok_or(ComposeError::RelativeSplitKeysDir { base, target }) +} + +/// Pulls the latest charon docker image. +fn pull_latest() -> Result<()> { + info!("Pulling latest charon docker image"); + + let status = Command::new("docker") + .args(["pull", &format!("{CHARON_IMAGE}:latest")]) + .status(); + + CommandError::check(status).map_err(ComposeError::exec("run docker pull")) +} + +/// Builds the `:local` docker image of `node_impl` from the checkout its repo +/// environment variable points at. +/// +/// For pluto the repo's short git hash is baked in as `GIT_COMMIT_HASH_SHORT` +/// when available: peers exchange it over peerinfo and warn about an empty or +/// unparseable hash. +pub fn build_local(node_impl: NodeImpl) -> Result<()> { + let var = node_impl.repo_env(); + let repo = env_non_empty(var).ok_or(ComposeError::RepoNotSet { node_impl, var })?; + let image = node_impl.local_image(); + + info!(repo = %repo, "Building `{image}` docker container"); + + let mut args = vec!["build".to_string(), "-t".to_string(), image]; + + let git_hash = match node_impl { + NodeImpl::Pluto => git_commit_hash_short(&repo).ok(), + NodeImpl::Charon => None, + }; + if let Some(hash) = git_hash { + args.push("--build-arg".to_string()); + args.push(format!("GIT_COMMIT_HASH_SHORT={hash}")); + } + + args.push(".".to_string()); + + let output = Command::new("docker") + .args(&args) + .current_dir(&repo) + .output(); + + CommandError::check_output(output) + .map(drop) + .map_err(ComposeError::exec("exec docker build")) +} + +/// Returns the repo's short (7 char) commit hash. +fn git_commit_hash_short(repo: &str) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .current_dir(repo) + .output(); + + let output = CommandError::check_output(output).map_err(ComposeError::exec("git rev-parse"))?; + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Copies the embedded static folders to the compose dir; scripts are made +/// executable. +fn copy_static_folders(dir: &Path) -> Result<()> { + let sub_dirs: BTreeSet<&str> = STATIC_FILES.iter().map(|file| file.dir).collect(); + for sub_dir in sub_dirs { + mkdir_all(dir.join(sub_dir), 0o755).map_err(ComposeError::io("mkdir all"))?; + } + + for file in STATIC_FILES { + let mode = if file.name.ends_with(".sh") { + 0o755 + } else { + 0o644 + }; + + write_file(dir.join(file.dir).join(file.name), file.bytes, mode) + .map_err(ComposeError::io("write file"))?; + } + + Ok(()) +} + +/// Renders Prometheus scrape configs for the actual cluster size, replacing +/// the static default: the relay plus every node, so the `up == 0` alert +/// sees all of them. +pub(crate) fn prometheus_config(conf: &Config) -> String { + let mut b = String::from( + "global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: 'relay' + static_configs: + - targets: [ 'relay:3620' ] +", + ); + + for i in 0..conf.num_nodes { + b.push_str(&format!( + " - job_name: 'node{i}' + static_configs: + - targets: ['node{i}:3620'] +" + )); + } + + b.push_str( + " +rule_files: + - /etc/prometheus/rules.yml +", + ); + + b +} + +/// Renders the Prometheus alert rules the smoke test gates on. +/// +/// `alert_exclude_jobs` exempts jobs from every behavioural rule (never from +/// `Pluto Down`); `alert_disable_rules` drops whole rules by name. +pub(crate) fn alert_rules(conf: &Config) -> String { + // Label matcher excluding the configured jobs, or empty. + let job_excl = if conf.alert_exclude_jobs.is_empty() { + String::new() + } else { + let jobs = conf.alert_exclude_jobs.join("|"); + format!(r#"job!~"{jobs}""#) + }; + + // Builds a `{a,b}` selector from the non-empty matchers, or "". + let sel = |matchers: &[&str]| -> String { + let parts: Vec<&str> = matchers.iter().copied().filter(|m| !m.is_empty()).collect(); + if parts.is_empty() { + String::new() + } else { + format!("{{{}}}", parts.join(",")) + } + }; + + // Mock artefacts, not node behaviour: vmock warns before the first epoch, + // the tracker about broadcasts the mock beacon node never includes. + let warn_topics = "vmock|tracker"; + + // `0 * up` gives every node job a zero series so a node that never + // broadcast (no counter yet) alerts too; summed per job, node jobs only. + let bcast_sel = sel(&[r#"job=~"node[0-9]+""#, &job_excl]); + + let error_sel = sel(&[&job_excl]); + let warn_sel = sel(&[&format!(r#"topic!~"{warn_topics}""#), &job_excl]); + let vapi_sel = sel(&[r#"endpoint!="proxy""#, &job_excl]); + let proxy_sel = sel(&[r#"endpoint="proxy""#, &job_excl]); + + // Blocks keyed by rule name so conf.alert_disable_rules can drop whole + // rules; the names double as the collector's warmup allowlist keys. + let rule_blocks = [ + ( + PLUTO_DOWN_RULE, + rule_block(PLUTO_DOWN_RULE, "up == 0", "is down"), + ), + // Windowed, unlike charon's absolute `> 0`: a fresh simnet cluster logs + // one consensus timeout per node at the first epoch boundary. + ( + ERROR_RATE_RULE, + rule_block( + ERROR_RATE_RULE, + &format!("increase(app_log_error_total{error_sel}[30s]) > 0"), + "has a high error rate", + ), + ), + ( + WARN_RATE_RULE, + rule_block( + WARN_RATE_RULE, + &format!("increase(app_log_warn_total{warn_sel}[30s]) > 2"), + "has a high warning rate", + ), + ), + ( + VAPI_RATE_RULE, + rule_block( + VAPI_RATE_RULE, + &format!("increase(core_validatorapi_request_error_total{vapi_sel}[30s]) > 1"), + "validator API a high error rate", + ), + ), + ( + PROXY_RATE_RULE, + rule_block( + PROXY_RATE_RULE, + &format!("increase(core_validatorapi_request_error_total{proxy_sel}[30s]) > 5"), + "proxy API a high error rate", + ), + ), + ( + BROADCAST_RULE, + rule_block( + BROADCAST_RULE, + &format!( + "(sum by (job) (increase(core_bcast_broadcast_total{bcast_sel}[30s])) or on (job) max by (job) (0 * up{bcast_sel})) < 0.5" + ), + "is not broadcasting enough duties", + ), + ), + ]; + + let blocks: Vec<&str> = rule_blocks + .iter() + .filter(|(name, _)| !conf.alert_disable_rules.iter().any(|rule| rule == name)) + .map(|(_, block)| block.as_str()) + .collect(); + + format!("groups:\n- name: pluto\n rules:\n{}", blocks.join("\n")) +} + +/// Formats one alert rule block, firing after 15 seconds of `expr`. +fn rule_block(name: &str, expr: &str, description: &str) -> String { + format!( + " - alert: {name} + expr: {expr} + for: 15s + annotations: + description: \"Pluto {{{{ $labels.job }}}} {description}\" +" + ) +} + +/// Returns the path of `file` in node `i`'s folder; the folder itself when +/// `file` is empty. +pub(crate) fn node_file(dir: &str, i: usize, file: &str) -> String { + go_path_join(&go_path_join(dir, &format!("node{i}")), file) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The generated scrape config covers every configured node plus the + /// relay, so the `up == 0` and injected-zero broadcast alerts see all of + /// them. + #[test] + fn prometheus_config_scrapes_all_nodes() { + let mut conf = Config::new_default(); + conf.num_nodes = 10; + + let content = prometheus_config(&conf); + assert!(content.contains("- targets: [ 'relay:3620' ]"), "{content}"); + + for i in 0..conf.num_nodes { + assert!( + content.contains(&format!("job_name: 'node{i}'")), + "{content}" + ); + assert!( + content.contains(&format!("- targets: ['node{i}:3620']")), + "{content}" + ); + } + + assert!( + !content.contains("node10"), + "must not scrape beyond num_nodes: {content}" + ); + } + + /// The broadcast liveness expression injects a zero for scraped node + /// jobs with no core_bcast_broadcast_total series, so a node that never + /// broadcasts fails instead of silently passing. + #[test] + fn alert_rules_broadcast_covers_missing_series() { + let content = alert_rules(&Config::new_default()); + + assert!( + content.contains( + r#"expr: (sum by (job) (increase(core_bcast_broadcast_total{job=~"node[0-9]+"}[30s])) or on (job) max by (job) (0 * up{job=~"node[0-9]+"})) < 0.5"# + ), + "{content}" + ); + } + + /// `alert_exclude_jobs` exempts a node from every behavioural rule while + /// "Pluto Down" keeps watching it. + #[test] + fn alert_rules_excludes_degraded_jobs() { + let mut conf = Config::new_default(); + conf.alert_exclude_jobs = vec!["node0".to_string()]; + + let content = alert_rules(&conf); + + assert!( + content.contains(r#"increase(app_log_error_total{job!~"node0"}[30s]) > 0"#), + "{content}" + ); + assert!( + content.contains( + r#"increase(app_log_warn_total{topic!~"vmock|tracker",job!~"node0"}[30s]) > 2"# + ), + "{content}" + ); + assert!( + content.contains( + r#"increase(core_validatorapi_request_error_total{endpoint!="proxy",job!~"node0"}[30s]) > 1"# + ), + "{content}" + ); + assert!( + content.contains( + r#"increase(core_validatorapi_request_error_total{endpoint="proxy",job!~"node0"}[30s]) > 5"# + ), + "{content}" + ); + assert!( + content.contains( + r#"(sum by (job) (increase(core_bcast_broadcast_total{job=~"node[0-9]+",job!~"node0"}[30s])) or on (job) max by (job) (0 * up{job=~"node[0-9]+",job!~"node0"})) < 0.5"# + ), + "{content}" + ); + + // The scrape-liveness rule must never carry exclusions. + assert!(content.contains("expr: up == 0"), "{content}"); + } + + /// The Warn Log Rate gate excludes exactly the two charon mock-noise + /// topics. + #[test] + fn alert_rules_warn_topics() { + let content = alert_rules(&Config::new_default()); + assert!( + content.contains(r#"increase(app_log_warn_total{topic!~"vmock|tracker"}[30s]) > 2"#), + "{content}" + ); + } + + /// Charon's dead "Outstanding Duty Rate" rule stays removed: broadcast + /// counts can never exceed scheduled counts, so it could never fire. + #[test] + fn alert_rules_drops_outstanding_duty() { + let content = alert_rules(&Config::new_default()); + assert!(!content.contains("Outstanding Duty"), "{content}"); + assert!(!content.contains("core_scheduler_duty_total"), "{content}"); + } + + /// `alert_disable_rules` drops exactly the named rules and validation + /// rejects unknown names. + #[test] + fn alert_rules_disable_rules() { + let mut conf = Config::new_default(); + conf.alert_disable_rules = vec![ERROR_RATE_RULE.to_string(), VAPI_RATE_RULE.to_string()]; + + let content = alert_rules(&conf); + assert!(!content.contains("Error Log Rate"), "{content}"); + assert!(!content.contains(r#"endpoint!="proxy""#), "{content}"); + // The remaining gates stay. + assert!(content.contains("Pluto Down"), "{content}"); + assert!(content.contains("Warn Log Rate"), "{content}"); + assert!(content.contains("Proxy API Error Rate"), "{content}"); + assert!(content.contains("Broadcast Duty Rate"), "{content}"); + + let mut conf = Config::new_default(); + conf.alert_disable_rules = vec!["No Such Rule".to_string()]; + let dir = tempfile::tempdir().expect("tempdir"); + let err = write_config(dir.path(), &conf).expect_err("must reject unknown rule"); + assert!(err.to_string().contains("unknown alert rule name"), "{err}"); + } + + #[test] + fn define_rejects_non_new_step() { + let mut conf = Config::new_default(); + conf.step = Step::Locked; + let dir = tempfile::tempdir().expect("tempdir"); + let err = define(dir.path(), conf, &DefineOptions::default()).expect_err("must fail"); + assert_eq!( + err.to_string(), + "compose config not new, so can't be defined: step=locked" + ); + } +} diff --git a/crates/test-compose/src/duration.rs b/crates/test-compose/src/duration.rs new file mode 100644 index 00000000..52e9f03c --- /dev/null +++ b/crates/test-compose/src/duration.rs @@ -0,0 +1,88 @@ +//! Go-compatible duration formatting. + +use std::time::Duration; + +const NANOS_PER_MICRO: u64 = 1_000; +const NANOS_PER_MILLI: u64 = 1_000_000; +const NANOS_PER_SECOND: u64 = 1_000_000_000; + +/// Formats a duration the way Go's `time.Duration.String()` does, e.g. `1s`, +/// `1.5s`, `1m0s`, `1h1m1.5s`, `12ms`, `1.5µs`, `999ns` and `0s`. +/// +/// Durations beyond Go's `int64` nanosecond range are clamped to its maximum. +pub fn go_duration_string(duration: Duration) -> String { + let total = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX); + let total = total.min(u64::try_from(i64::MAX).unwrap_or(u64::MAX)); + + if total < NANOS_PER_SECOND { + if total == 0 { + return "0s".to_string(); + } + + let (precision, unit) = if total < NANOS_PER_MICRO { + (0, "ns") + } else if total < NANOS_PER_MILLI { + (3, "µs") + } else { + (6, "ms") + }; + + let (fraction, whole) = fmt_frac(total, precision); + + return format!("{whole}{fraction}{unit}"); + } + + let (fraction, seconds) = fmt_frac(total, 9); + let secs = seconds.checked_rem(60).unwrap_or(0); + let minutes_total = seconds.checked_div(60).unwrap_or(0); + + let mut out = String::new(); + if minutes_total > 0 { + let hours = minutes_total.checked_div(60).unwrap_or(0); + let minutes = minutes_total.checked_rem(60).unwrap_or(0); + if hours > 0 { + out.push_str(&format!("{hours}h")); + } + out.push_str(&format!("{minutes}m")); + } + out.push_str(&format!("{secs}{fraction}s")); + + out +} + +/// Splits `value` into `(fraction, whole)` where `whole = value / 10^precision` +/// and `fraction` is the decimal fraction with trailing zeros removed +/// (including the point when the fraction is zero). +fn fmt_frac(value: u64, precision: u32) -> (String, u64) { + let scale = 10u64.checked_pow(precision).unwrap_or(u64::MAX); + let whole = value.checked_div(scale).unwrap_or(0); + let frac = value.checked_rem(scale).unwrap_or(0); + + if frac == 0 { + return (String::new(), whole); + } + + let width = usize::try_from(precision).unwrap_or(0); + let digits = format!("{frac:0width$}"); + let digits = digits.trim_end_matches('0'); + + (format!(".{digits}"), whole) +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + use super::*; + + // Vectors generated with Go's time.Duration.String(). + #[test_case(0, "0s")] + #[test_case(1500, "1.5µs" ; "fractional_microseconds")] + #[test_case(999_999_999, "999.999999ms")] + #[test_case(60_000_000_000, "1m0s")] + #[test_case(3_661_500_000_000, "1h1m1.5s")] + #[test_case(9_223_372_036_854_775_807, "2562047h47m16.854775807s")] + fn matches_go(nanos: u64, want: &str) { + assert_eq!(go_duration_string(Duration::from_nanos(nanos)), want); + } +} diff --git a/crates/test-compose/src/error.rs b/crates/test-compose/src/error.rs new file mode 100644 index 00000000..ee754381 --- /dev/null +++ b/crates/test-compose/src/error.rs @@ -0,0 +1,158 @@ +use std::{ + io, + process::{ExitStatus, Output}, +}; + +use pluto_eth2util::enr::RecordError; +use pluto_k1util::K1UtilError; + +use crate::config::{NodeImpl, Step}; + +/// Failure of a child process such as `docker` or `git`. +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Io(#[from] io::Error), + + #[error("{0}")] + Exit(ExitStatus), + + #[error("{status}: output={output}")] + ExitOutput { status: ExitStatus, output: String }, +} + +impl CommandError { + /// Turns the result of waiting for a command into an error unless it + /// exited successfully. + pub fn check(status: io::Result) -> std::result::Result<(), Self> { + let status = status?; + if status.success() { + Ok(()) + } else { + Err(Self::Exit(status)) + } + } + + /// Returns the captured output of a command that exited successfully; on + /// failure the combined stdout and stderr travel with the error. + pub fn check_output(output: io::Result) -> std::result::Result { + let output = output?; + if output.status.success() { + Ok(output) + } else { + Err(Self::ExitOutput { + status: output.status, + output: combined_output(&output), + }) + } + } +} + +/// Joins captured stdout and stderr, lossily decoded. +pub(crate) fn combined_output(output: &Output) -> String { + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + text +} + +/// Errors returned by the compose generator. +#[derive(Debug, thiserror::Error)] +pub enum ComposeError { + #[error("compose config not new, so can't be defined: step={step}")] + NotNew { step: Step }, + + #[error("compose config not defined, so can't be locked: step={step}")] + NotDefined { step: Step }, + + #[error("compose config not locked, so can't be run: step={step}")] + NotLocked { step: Step }, + + #[error("save charon-enr-private-key: {0}")] + SaveEnrPrivateKey(#[from] K1UtilError), + + #[error(transparent)] + Enr(#[from] RecordError), + + #[error("split-keys-dir must be a child of compose dir: relative={relative}")] + SplitKeysDirNotChild { relative: String }, + + #[error("relative split keys dir: Rel: can't make {target} relative to {base}")] + RelativeSplitKeysDir { base: String, target: String }, + + #[error( + "cannot build local {node_impl} binary; {var} env var, the path to the {node_impl} repo, is not set" + )] + RepoNotSet { + node_impl: NodeImpl, + var: &'static str, + }, + + /// A file system operation named by `context` failed. + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + /// A child process named by `cmd` could not be run or failed. + #[error("{cmd}: {source}")] + Exec { + cmd: String, + #[source] + source: CommandError, + }, + + #[error("unknown alert rule name in alert_disable_rules: rule={rule}")] + UnknownAlertRule { rule: String }, + + #[error("marshal config: {0}")] + MarshalConfig(#[source] serde_json::Error), + + #[error("unmarshal Config: {0}")] + UnmarshalConfig(#[source] serde_json::Error), + + #[error("no validator clients configured")] + NoValidatorClients, + + #[error("external port overflow: node={index}")] + PortOverflow { index: usize }, + + #[error("cluster stopped before the observation window elapsed")] + ClusterStopped, + + #[error("prometheus was not polled successfully through the end of the observation window")] + PrometheusNotPolled, + + #[error("alerts detected: alerts=[{}]", .alerts.join(" "))] + AlertsDetected { alerts: Vec }, + + #[error("unmarshal alerts: {source}: out={out}")] + UnmarshalAlerts { + #[source] + source: serde_json::Error, + out: String, + }, + + #[error("run step: {0}")] + StepCancelled(#[source] tokio::task::JoinError), +} + +impl ComposeError { + /// Wraps an I/O error with the operation it came from. + pub(crate) fn io(context: &'static str) -> impl FnOnce(io::Error) -> Self { + move |source| Self::Io { context, source } + } + + /// Wraps a command failure with the command it came from. + pub(crate) fn exec>(cmd: impl Into) -> impl FnOnce(E) -> Self { + let cmd = cmd.into(); + move |source| Self::Exec { + cmd, + source: source.into(), + } + } +} + +/// Result alias for compose operations. +pub type Result = std::result::Result; diff --git a/crates/test-compose/src/fsutil.rs b/crates/test-compose/src/fsutil.rs new file mode 100644 index 00000000..c9f6943a --- /dev/null +++ b/crates/test-compose/src/fsutil.rs @@ -0,0 +1,158 @@ +//! File and path helpers with Go `os`/`path` semantics where the generated +//! output depends on them. + +use std::{env, fs, io, path::Path}; + +/// Reads an environment variable, treating unset, empty and non-UTF-8 values +/// as absent. +pub fn env_non_empty(var: impl AsRef) -> Option { + env::var(var.as_ref()) + .ok() + .filter(|value| !value.is_empty()) +} + +/// Writes `data` to `path`, creating or truncating it. +/// +/// On unix the file is created with `mode` (subject to the umask); the mode +/// of an existing file is left unchanged. +pub(crate) fn write_file( + path: impl AsRef, + data: impl AsRef<[u8]>, + mode: u32, +) -> io::Result<()> { + let path = path.as_ref(); + let data = data.as_ref(); + + #[cfg(unix)] + { + use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _}; + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(mode) + .open(path)?; + file.write_all(data) + } + + #[cfg(not(unix))] + { + let _ = mode; + fs::write(path, data) + } +} + +/// Lexically cleans a slash-separated path: collapses repeated separators, +/// drops `.` elements, resolves `..` against preceding elements (or the root) +/// and returns `.` for an empty result. +pub(crate) fn go_path_clean(path: &str) -> String { + if path.is_empty() { + return ".".to_string(); + } + + let rooted = path.starts_with('/'); + let mut out: Vec<&str> = Vec::new(); + + for elem in path.split('/') { + match elem { + "" | "." => {} + ".." => match out.last() { + Some(&last) if last != ".." => { + out.pop(); + } + _ if rooted => {} + _ => out.push(".."), + }, + other => out.push(other), + } + } + + let body = out.join("/"); + if rooted { + format!("/{body}") + } else if body.is_empty() { + ".".to_string() + } else { + body + } +} + +/// Joins two path elements the way Go's `path.Join` does: empty elements are +/// ignored and the result is cleaned. +pub(crate) fn go_path_join(a: &str, b: &str) -> String { + match (a.is_empty(), b.is_empty()) { + (true, true) => String::new(), + (true, false) => go_path_clean(b), + (false, true) => go_path_clean(a), + (false, false) => go_path_clean(&format!("{a}/{b}")), + } +} + +/// Returns the absolute, cleaned form of `path` (Go's `filepath.Abs`). +pub(crate) fn go_abs(path: impl AsRef) -> io::Result { + let abs = std::path::absolute(path.as_ref())?; + Ok(go_path_clean(&abs.to_string_lossy())) +} + +/// Returns `target` expressed relative to `base` using only lexical +/// processing. Both must be cleaned absolute paths as produced by [`go_abs`]. +/// Returns `None` when `base` contains `..` elements that cannot be +/// resolved, in which case no relative path exists. +pub(crate) fn go_rel(base: &str, target: &str) -> Option { + if base == target { + return Some(".".to_string()); + } + + let base_elems: Vec<&str> = base.split('/').filter(|e| !e.is_empty()).collect(); + let target_elems: Vec<&str> = target.split('/').filter(|e| !e.is_empty()).collect(); + + let common = base_elems + .iter() + .zip(target_elems.iter()) + .take_while(|(b, t)| b == t) + .count(); + + let base_rest: Vec<&str> = base_elems.iter().skip(common).copied().collect(); + if base_rest.contains(&"..") { + return None; + } + + let mut parts: Vec<&str> = vec![".."; base_rest.len()]; + parts.extend(target_elems.iter().skip(common).copied()); + + Some(parts.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_clean() { + assert_eq!(go_path_clean("a//b/./c/"), "a/b/c"); + assert_eq!(go_path_clean("a/b/../c"), "a/c"); + } + + #[test] + fn rel() { + assert_eq!(go_rel("/a/b", "/c"), Some("../../c".to_string())); + } + + #[test] + fn write_file_sets_mode_on_creation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("f.sh"); + write_file(&path, b"echo", 0o755).expect("write"); + assert_eq!(fs::read(&path).expect("read"), b"echo"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; + // The umask may clear group/other bits but never the owner's. + assert_eq!(mode & 0o700, 0o700); + } + } +} diff --git a/crates/test-compose/src/golden_tests.rs b/crates/test-compose/src/golden_tests.rs new file mode 100644 index 00000000..e73c7a72 --- /dev/null +++ b/crates/test-compose/src/golden_tests.rs @@ -0,0 +1,169 @@ +//! Golden-file parity tests against the goldens generated by the Go harness +//! this crate replaced. +//! +//! Every case renders through the same public entry points the Go tests use +//! and compares the resulting `docker-compose.yml` and template data byte for +//! byte with the goldens the Go suite generated, copied verbatim into this +//! crate's `testdata/`. + +use std::{fs, path::Path}; + +use k256::SecretKey; +use test_case::test_case; + +use crate::{ + config::{Config, KeyGen, NodeImpl, Step, marshal_indent, write_config}, + define::{DefineOptions, define}, + error::Result, + lock::lock, + run::run, + template::TmplData, +}; + +const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata"); + +/// Deterministic define options matching the Go test: the insecure seed-0 +/// key for every node, no image pulls or builds. +fn test_define_options() -> DefineOptions { + DefineOptions { + pull_images: false, + key_gen: || SecretKey::from_slice(&[1u8; 32]).expect("valid secret key"), + } +} + +fn define_step(dir: &Path, conf: Config) -> Result { + define(dir, conf, &test_define_options()) +} + +fn lock_step(dir: &Path, conf: Config) -> Result { + lock(dir, conf) +} + +fn run_step(dir: &Path, conf: Config) -> Result { + run(dir, conf) +} + +fn golden(name: &str) -> Vec { + let path = Path::new(TESTDATA_DIR).join(name); + fs::read(&path).unwrap_or_else(|err| panic!("read golden {path:?}: {err}")) +} + +fn assert_golden(name: &str, got: &[u8]) { + // Optional dump of the rendered bytes for out-of-band comparison + // (`PLUTO_COMPOSE_GOLDEN_OUT=`); never affects the assertion. + if let Some(out_dir) = std::env::var_os("PLUTO_COMPOSE_GOLDEN_OUT") { + fs::create_dir_all(&out_dir).expect("create golden out dir"); + fs::write(Path::new(&out_dir).join(name), got).expect("write rendered golden"); + } + + let want = golden(name); + if got != want.as_slice() { + panic!( + "{name} differs from golden\n--- want ---\n{}\n--- got ---\n{}", + String::from_utf8_lossy(&want), + String::from_utf8_lossy(got), + ); + } +} + +#[test_case("define_dkg", |c| c.key_gen = KeyGen::Dkg, define_step ; "define_dkg")] +#[test_case("define_create", |c| c.key_gen = KeyGen::Create, define_step ; "define_create")] +#[test_case("lock_dkg", |c| { c.step = Step::Defined; c.key_gen = KeyGen::Dkg; }, lock_step ; "lock_dkg")] +#[test_case("lock_create", |c| { c.step = Step::Defined; c.key_gen = KeyGen::Create; }, lock_step ; "lock_create")] +#[test_case("run", |c| { c.num_validators = 2; c.step = Step::Locked; }, run_step ; "run")] +#[test_case("lock_dkg_mixed_impls", |c| { + c.step = Step::Defined; + c.key_gen = KeyGen::Dkg; + c.node_impls = vec![NodeImpl::Charon, NodeImpl::Pluto]; +}, lock_step ; "lock_dkg_mixed_impls")] +#[test_case("run_mixed_impls", |c| { + c.num_validators = 2; + c.step = Step::Locked; + c.node_impls = vec![NodeImpl::Charon, NodeImpl::Charon, NodeImpl::Pluto, NodeImpl::Pluto]; +}, run_step ; "run_mixed_impls")] +#[test_case("lock_create_pluto_keygen", |c| { + c.step = Step::Defined; + c.key_gen = KeyGen::Create; + c.key_gen_impl = Some(NodeImpl::Pluto); +}, lock_step ; "lock_create_pluto_keygen")] +fn docker_compose( + name: &str, + conf_fn: fn(&mut Config), + run_fn: fn(&Path, Config) -> Result, +) { + let dir = tempfile::tempdir().expect("tempdir"); + let dir_str = dir.path().to_str().expect("utf-8 temp dir"); + + let mut conf = Config::new_default(); + conf_fn(&mut conf); + + let mut data = run_fn(dir.path(), conf).expect("run step"); + + // yml + let yml = + fs::read_to_string(dir.path().join("docker-compose.yml")).expect("read docker-compose.yml"); + let yml = yml.replace(dir_str, "testdir"); + assert_golden( + &format!("TestDockerCompose_{name}_yml.golden"), + yml.as_bytes(), + ); + + // template + data.compose_dir = "testdir".to_string(); + let json = marshal_indent(&data).expect("marshal template data"); + assert_golden(&format!("TestDockerCompose_{name}_template.golden"), &json); +} + +#[test] +fn new_default_config() { + let dir = tempfile::tempdir().expect("tempdir"); + + write_config(dir.path(), &Config::new_default()).expect("write config"); + + let conf = fs::read(dir.path().join("config.json")).expect("read config.json"); + assert_golden("TestNewDefaultConfig.golden", &conf); +} + +#[test] +fn every_golden_is_covered() { + let mut names: Vec = fs::read_dir(TESTDATA_DIR) + .expect("read testdata") + .map(|entry| { + entry + .expect("entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .filter(|name| name.ends_with(".golden")) + .collect(); + names.sort(); + + let cases = [ + "define_dkg", + "define_create", + "lock_dkg", + "lock_create", + "run", + "lock_dkg_mixed_impls", + "run_mixed_impls", + "lock_create_pluto_keygen", + ]; + let mut covered: Vec = cases + .iter() + .flat_map(|case| { + [ + format!("TestDockerCompose_{case}_yml.golden"), + format!("TestDockerCompose_{case}_template.golden"), + ] + }) + .collect(); + covered.push("TestNewDefaultConfig.golden".to_string()); + covered.sort(); + + assert_eq!( + names, covered, + "testdata/ holds goldens no test here compares against" + ); + assert_eq!(names.len(), 17); +} diff --git a/crates/test-compose/src/lib.rs b/crates/test-compose/src/lib.rs new file mode 100644 index 00000000..dd83acee --- /dev/null +++ b/crates/test-compose/src/lib.rs @@ -0,0 +1,38 @@ +//! Docker-compose cluster generator for smoke testing pluto and charon nodes. +//! +//! A cluster is produced in steps; each reads `config.json` from the compose +//! directory, advances its `step` and rewrites `docker-compose.yml`: +//! +//! 1. `define` writes the key-generation compose file, the static monitoring +//! configs and the Prometheus scrape and alert-rule files. +//! 2. `lock` writes the cluster-lock compose file (`create cluster` or `dkg`). +//! 3. `run` writes the compose file that runs nodes, validator clients, relay +//! and monitoring. +//! +//! [`auto`] chains the steps against a docker daemon and watches Prometheus +//! for alerts; [`smoke`] holds the scenario matrix. + +// Test infrastructure: item names and error strings carry the meaning. +#![allow(missing_docs)] + +mod alert; +mod auto; +mod config; +mod define; +mod duration; +mod error; +mod fsutil; +mod lock; +mod process; +mod run; +pub mod smoke; +mod static_files; +mod template; + +#[cfg(test)] +mod golden_tests; + +pub use auto::{AutoConfig, auto}; +pub use config::{Config, PLUTO_REPO_ENV, write_config}; +pub use error::{ComposeError, Result}; +pub use fsutil::env_non_empty; diff --git a/crates/test-compose/src/lock.rs b/crates/test-compose/src/lock.rs new file mode 100644 index 00000000..d23726fb --- /dev/null +++ b/crates/test-compose/src/lock.rs @@ -0,0 +1,259 @@ +//! Cluster lock step and the shared node environment. + +use std::path::Path; + +use tracing::info; + +use crate::{ + Result, + config::{CMD_CREATE_CLUSTER, CMD_DKG, Config, KeyGen, Step, VcType, write_config}, + define::{ZERO_ADDRESS, rel_split_keys_dir}, + duration::go_duration_string, + error::ComposeError, + fsutil::go_path_join, + template::{Kv, TmplData, TmplNode, write_docker_compose}, +}; + +/// What a node container does, which selects its flag set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NodeMode { + /// `charon dkg` against the cluster definition. + Dkg, + /// `charon run` alongside the given validator client. + Run(VcType), +} + +/// Writes the `locked` config and a `docker-compose.yml` that generates the +/// validator keys and cluster lock: a single `charon create cluster` +/// container for `create` key generation, or one `charon dkg` container per +/// node plus the relay for DKG. +pub fn lock(dir: impl AsRef, mut conf: Config) -> Result { + let dir = dir.as_ref(); + let dir_str = dir.to_string_lossy().into_owned(); + + if conf.step != Step::Defined { + return Err(ComposeError::NotDefined { step: conf.step }); + } + + let data = match conf.key_gen { + KeyGen::Create => { + let mut split_keys_dir = rel_split_keys_dir(&dir_str, &conf.split_keys_dir)?; + if !split_keys_dir.is_empty() { + split_keys_dir = go_path_join("/compose", &split_keys_dir); + } + + // Only single node to call charon create cluster generate keys + let kvs = vec![ + Kv::new( + "name", + format!("compose-{}-{}", conf.num_nodes, conf.num_validators), + ), + Kv::new("threshold", conf.threshold.to_string()), + Kv::new("nodes", conf.num_nodes.to_string()), + Kv::new("cluster-dir", "/compose"), + Kv::new( + "split-existing-keys", + quoted_bool(!conf.split_keys_dir.is_empty()), + ), + Kv::new("split-keys-dir", split_keys_dir), + Kv::new("num-validators", conf.num_validators.to_string()), + Kv::new("insecure-keys", quoted_bool(conf.insecure_keys)), + Kv::new("withdrawal-addresses", ZERO_ADDRESS), + Kv::new("fee-recipient-addresses", ZERO_ADDRESS), + Kv::new("network", pluto_eth2util::network::GOERLI.name), + ]; + + let node = TmplNode { + image: conf.image_override(conf.keygen_impl()), + env_vars: kvs, + ..TmplNode::default() + }; + + TmplData { + compose_dir: dir_str, + charon_image_tag: conf.image_tag.clone(), + charon_command: CMD_CREATE_CLUSTER.to_string(), + nodes: vec![node], + ..TmplData::default() + } + } + KeyGen::Dkg => { + let nodes = (0..conf.num_nodes) + .map(|i| TmplNode { + env_vars: new_node_envs(i, &conf, NodeMode::Dkg), + image: conf.image_override(conf.node_impl(i)), + command: CMD_DKG.to_string(), + ..TmplNode::default() + }) + .collect(); + + TmplData { + compose_dir: dir_str, + charon_image_tag: conf.image_tag.clone(), + charon_command: "not used".to_string(), + relay: true, + nodes, + ..TmplData::default() + } + } + }; + + info!("Creating docker-compose.yml"); + info!("Create keys and cluster lock with: docker compose up"); + + conf.step = Step::Locked; + write_config(dir, &conf)?; + + write_docker_compose(dir, &data)?; + + Ok(data) +} + +/// Formats a bool quoted for the compose environment, e.g. `"true"`. +pub(crate) fn quoted_bool(value: bool) -> String { + format!("\"{value}\"") +} + +/// Returns the environment variables for a charon node container: the +/// common flags, then either the DKG flags or the run flags plus the +/// loki/tempo flags when monitoring is on. +pub(crate) fn new_node_envs(index: usize, conf: &Config, mode: NodeMode) -> Vec { + let mut beacon_mock = false; + + let mut beacon_node = conf.beacon_nodes.as_str(); + if beacon_node == "mock" { + beacon_mock = true; + beacon_node = ""; + } + + // Path-less URL (multiaddrs response): pluto's relay parsing roundtrips + // URLs through a multiaddr, which cannot carry a path. Charon accepts both. + let p2p_relay_addr = if conf.external_relay.is_empty() { + "http://relay:3640" + } else { + conf.external_relay.as_str() + }; + + // Common config + let mut kvs = vec![ + Kv::new( + "private-key-file", + format!("/compose/node{index}/charon-enr-private-key"), + ), + Kv::new("monitoring-address", "0.0.0.0:3620"), + Kv::new("p2p-external-hostname", format!("node{index}")), + Kv::new("p2p-tcp-address", "0.0.0.0:3610"), + Kv::new("p2p-relays", p2p_relay_addr), + Kv::new("log-level", "debug"), + Kv::new("log-color", "force"), + Kv::new("feature-set", conf.feature_set.as_str()), + ]; + + let vc_type = match mode { + NodeMode::Dkg => { + kvs.extend([ + Kv::new("data-dir", format!("/compose/node{index}")), + Kv::new("definition-file", "/compose/cluster-definition.json"), + Kv::new("insecure-keys", quoted_bool(conf.insecure_keys)), + ]); + + return kvs; + } + NodeMode::Run(vc_type) => vc_type, + }; + + kvs.extend([ + Kv::new( + "lock-file", + format!("/compose/node{index}/cluster-lock.json"), + ), + Kv::new("validator-api-address", "0.0.0.0:3600"), + Kv::new("beacon-node-endpoints", beacon_node), + Kv::new("simnet-beacon_mock", quoted_bool(beacon_mock)), + Kv::new( + "simnet-validator-mock", + quoted_bool(vc_type == VcType::Mock), + ), + Kv::new( + "simnet-slot-duration", + go_duration_string(conf.slot_duration), + ), + Kv::new( + "simnet-validator-keys-dir", + format!("/compose/node{index}/validator_keys"), + ), + Kv::new("simnet-beacon-mock-fuzz", quoted_bool(conf.beacon_fuzz)), + Kv::new( + "synthetic-block-proposals", + quoted_bool(conf.synthetic_block_proposals), + ), + Kv::new("builder-api", quoted_bool(conf.builder_api)), + ]); + + // Only point nodes at loki/tempo when they run: failed pushes are logged + // as errors and trip the Error Log Rate alert. + if conf.monitoring { + kvs.extend([ + Kv::new("otlp-address", "tempo:4317"), + Kv::new("otlp-service-name", format!("node{index}")), + Kv::new("loki-addresses", "http://loki:3100/loki/api/v1/push"), + Kv::new("loki-service", format!("node{index}")), + ]); + } + + kvs +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(kvs: &[Kv]) -> Vec<&str> { + kvs.iter().map(|kv| kv.key.as_str()).collect() + } + + fn value<'a>(kvs: &'a [Kv], key: &str) -> &'a str { + kvs.iter() + .find(|kv| kv.key == key) + .map(|kv| kv.value.as_str()) + .unwrap_or_else(|| panic!("missing {key}")) + } + + #[test] + fn run_step_reflects_config_toggles() { + let mut conf = Config::new_default(); + conf.monitoring = false; + conf.external_relay = "http://example.org:3640".to_string(); + conf.beacon_nodes = "http://beacon:5052".to_string(); + + let kvs = new_node_envs(0, &conf, NodeMode::Run(VcType::Mock)); + assert_eq!(value(&kvs, "p2p-relays"), "http://example.org:3640"); + assert_eq!(value(&kvs, "beacon-node-endpoints"), "http://beacon:5052"); + assert_eq!(value(&kvs, "simnet-beacon_mock"), "\"false\""); + assert_eq!(value(&kvs, "simnet-validator-mock"), "\"true\""); + assert_eq!(value(&kvs, "simnet-slot-duration"), "1s"); + assert!(!keys(&kvs).contains(&"otlp-address")); + assert!(!keys(&kvs).contains(&"loki-addresses")); + + conf.monitoring = true; + let kvs = new_node_envs(3, &conf, NodeMode::Run(VcType::Teku)); + assert_eq!(value(&kvs, "simnet-validator-mock"), "\"false\""); + assert_eq!(value(&kvs, "otlp-service-name"), "node3"); + assert_eq!(value(&kvs, "loki-service"), "node3"); + + let kvs = new_node_envs(1, &conf, NodeMode::Dkg); + assert_eq!(value(&kvs, "data-dir"), "/compose/node1"); + assert!(!keys(&kvs).contains(&"lock-file")); + } + + #[test] + fn lock_rejects_non_defined_step() { + let conf = Config::new_default(); + let dir = tempfile::tempdir().expect("tempdir"); + let err = lock(dir.path(), conf).expect_err("must fail"); + assert_eq!( + err.to_string(), + "compose config not defined, so can't be locked: step=new" + ); + } +} diff --git a/crates/test-compose/src/process.rs b/crates/test-compose/src/process.rs new file mode 100644 index 00000000..bb142053 --- /dev/null +++ b/crates/test-compose/src/process.rs @@ -0,0 +1,255 @@ +//! Docker compose process control for the automated flow: bring clusters up +//! and down, build images, fix artefact permissions and print the compose file. +//! +//! Every command is resolved through `PATH` and run in the compose directory. + +use std::{ + fs::{File, OpenOptions}, + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::Path, + process::Stdio, +}; + +use tokio::process::Command; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::error::{CommandError, ComposeError, Result}; + +/// Destination of the `docker compose up` output: the stdout of this process +/// or an append-only log file. +#[derive(Debug)] +pub enum LogSink { + /// Write to the stdout of this process. + Stdout, + /// Append to a log file. + File(File), +} + +impl LogSink { + /// Opens `path` for appending, creating it with mode `0o644`, or writes to + /// stdout when `path` is `None`. + pub fn open(path: Option<&Path>) -> Result { + match path { + None => Ok(Self::Stdout), + Some(path) => OpenOptions::new() + .append(true) + .create(true) + .mode(0o644) + .open(path) + .map(Self::File) + .map_err(ComposeError::io("open log file")), + } + } + + /// Writes a step banner. Write failures are ignored: the banner only helps + /// a reader find their way through the log. + pub fn banner(&mut self, text: impl AsRef) { + let text = text.as_ref().as_bytes(); + match self { + Self::Stdout => { + let mut stdout = io::stdout().lock(); + let _ = stdout.write_all(text); + let _ = stdout.flush(); + } + Self::File(file) => { + let _ = file.write_all(text); + } + } + } + + /// A child-process output handle writing into this sink. Both stdout and + /// stderr of the child are pointed here, so with [`LogSink::Stdout`] the + /// child's stderr lands on this process's stdout. + fn stdio(&self) -> io::Result { + match self { + Self::Stdout => Ok(Stdio::from(io::stdout())), + Self::File(file) => file.try_clone().map(Stdio::from), + } + } +} + +/// How `docker compose up` ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpOutcome { + /// The cluster exited on its own with a zero status. + Exited, + /// The cluster was killed because the cancellation token fired. + Cancelled, +} + +/// Streams `docker-compose.yml` to stdout by running `cat` in `dir`. +pub async fn print_docker_compose(dir: impl AsRef) -> Result<()> { + info!("Printing docker-compose.yml"); + + let status = Command::new("cat") + .arg("docker-compose.yml") + .current_dir(dir.as_ref()) + .status() + .await; + + CommandError::check(status).map_err(ComposeError::exec("exec cat docker-compose.yml")) +} + +/// Hands the compose artefacts back to the current user. Containers run as +/// root and leave root-owned files behind, so this runs +/// `sudo chown -R : .` followed by `sudo chmod -R a+wrX .` in `dir`. +pub async fn fix_perms(dir: impl AsRef) -> Result<()> { + let dir = dir.as_ref(); + let owner = format!("{}:{}", nix::unistd::getuid(), nix::unistd::getgid()); + let commands: [(&str, [&str; 3]); 2] = [ + ("chown", ["-R", owner.as_str(), "."]), + ("chmod", ["-R", "a+wrX", "."]), + ]; + + for (program, args) in commands { + let status = Command::new("sudo") + .arg(program) + .args(args) + .current_dir(dir) + .status() + .await; + + CommandError::check(status).map_err(ComposeError::exec(format!("exec sudo {program}")))?; + } + + Ok(()) +} + +/// Stops and removes the cluster with +/// `docker compose down --remove-orphans --timeout=2`, preceded by +/// [`fix_perms`] when `sudo_perms` is set. +pub async fn down(dir: impl AsRef, sudo_perms: bool) -> Result<()> { + let dir = dir.as_ref(); + if sudo_perms { + fix_perms(dir).await?; + } + + info!("Executing docker compose down"); + + let status = Command::new("docker") + .args(["compose", "down", "--remove-orphans", "--timeout=2"]) + .current_dir(dir) + .status() + .await; + + CommandError::check(status).map_err(ComposeError::exec("run down")) +} + +/// Builds the images in parallel, then runs `docker compose up` with its +/// output going to `sink` until the cluster exits or `token` is cancelled. +/// +/// Cancellation kills the process and reports [`UpOutcome::Cancelled`], also +/// when the killed process reports a failing exit status. A cancellation that +/// interrupts the build is an error, as the build never produced a cluster. +pub async fn up( + dir: impl AsRef, + sink: &LogSink, + token: &CancellationToken, +) -> Result { + let dir = dir.as_ref(); + + info!("Executing docker compose build"); + + let mut build = Command::new("docker"); + build + .args(["compose", "build", "--parallel"]) + .current_dir(dir) + .kill_on_drop(true); + + let output = token + .run_until_cancelled(build.output()) + .await + .unwrap_or_else(|| Err(io::Error::other("signal: killed"))); + CommandError::check_output(output).map_err(ComposeError::exec("exec docker compose build"))?; + + info!("Executing docker compose up"); + + const UP: &str = "exec docker compose up"; + + let mut child = Command::new("docker") + .args([ + "compose", + "up", + "--remove-orphans", + "--abort-on-container-exit", + "--quiet-pull", + ]) + .current_dir(dir) + .stdout(sink.stdio().map_err(ComposeError::exec(UP))?) + .stderr(sink.stdio().map_err(ComposeError::exec(UP))?) + .kill_on_drop(true) + .spawn() + .map_err(ComposeError::exec(UP))?; + + let Some(status) = token.run_until_cancelled(child.wait()).await else { + let _ = child.kill().await; + return Ok(UpOutcome::Cancelled); + }; + let status = status.map_err(ComposeError::exec(UP))?; + + if status.success() { + Ok(UpOutcome::Exited) + } else if token.is_cancelled() { + Ok(UpOutcome::Cancelled) + } else { + Err(ComposeError::exec(UP)(CommandError::Exit(status))) + } +} + +/// Builds the images and creates the containers without starting them: +/// `docker compose up --no-start --build`. +pub async fn build_and_create(dir: impl AsRef) -> Result<()> { + info!("Executing docker compose up --no-start --build"); + + let output = Command::new("docker") + .args(["compose", "up", "--no-start", "--build"]) + .current_dir(dir.as_ref()) + .output() + .await; + + CommandError::check_output(output) + .map(drop) + .map_err(ComposeError::exec( + "exec docker compose up --no-start --build", + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn log_sink_file_is_created_and_appended() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("compose.log"); + fs::write(&path, "existing\n").expect("seed log"); + + let mut sink = LogSink::open(Some(&path)).expect("open sink"); + sink.banner("===== define step: docker compose up =====\n"); + sink.banner("===== lock step: docker compose up =====\n"); + drop(sink); + + let content = fs::read_to_string(&path).expect("read log"); + assert_eq!( + content, + "existing\n===== define step: docker compose up =====\n===== lock step: docker compose up =====\n" + ); + } + + #[tokio::test] + async fn print_docker_compose_reports_cat_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + + let err = print_docker_compose(dir.path()) + .await + .expect_err("cat of a missing file fails"); + assert_eq!( + err.to_string(), + "exec cat docker-compose.yml: exit status: 1" + ); + } +} diff --git a/crates/test-compose/src/run.rs b/crates/test-compose/src/run.rs new file mode 100644 index 00000000..7f0cc4d1 --- /dev/null +++ b/crates/test-compose/src/run.rs @@ -0,0 +1,185 @@ +//! Cluster run step: node, validator client and monitoring services. + +use std::path::Path; + +use tracing::info; + +use crate::{ + Result, + config::{CHARON_PORTS, CMD_RUN, CMD_UNSAFE_RUN, Config, Step, VcType}, + error::ComposeError, + lock::{NodeMode, new_node_envs, quoted_bool}, + template::{Kv, TmplData, TmplNode, TmplVc, write_docker_compose}, +}; + +/// Writes the `docker-compose.yml` that runs the cluster: one node service +/// per configured node with its validator client, the relay, prometheus and +/// (when enabled) the monitoring stack. +/// +/// Validator client types cycle through `conf.vcs`; node ports are published +/// on the host offset by 10000 per node unless monitoring ports are +/// disabled. With `p2p_fuzz` node 0 fuzzes its p2p messages and the nodes +/// run `charon unsafe run`. +pub fn run(dir: impl AsRef, conf: Config) -> Result { + let dir = dir.as_ref(); + + if conf.step != Step::Locked { + return Err(ComposeError::NotLocked { step: conf.step }); + } + + if conf.vcs.is_empty() { + return Err(ComposeError::NoValidatorClients); + } + + let mut nodes = Vec::with_capacity(conf.num_nodes); + let mut vcs = Vec::with_capacity(conf.num_nodes); + + for (i, &typ) in conf.vcs.iter().cycle().take(conf.num_nodes).enumerate() { + vcs.push(get_vc( + typ, + i, + conf.num_validators, + conf.insecure_keys, + conf.builder_api, + )); + + let mut node = TmplNode { + env_vars: new_node_envs(i, &conf, NodeMode::Run(typ)), + image: conf.image_override(conf.node_impl(i)), + ..TmplNode::default() + }; + + if !conf.disable_monitoring_ports { + let offset = u32::try_from(i) + .ok() + .and_then(|i| i.checked_mul(10_000)) + .ok_or(ComposeError::PortOverflow { index: i })?; + + for mut port in CHARON_PORTS { + port.external = port + .external + .checked_add(offset) + .ok_or(ComposeError::PortOverflow { index: i })?; + node.ports.push(port); + } + } + + nodes.push(node); + } + + let mut charon_cmd = CMD_RUN; + + if conf.p2p_fuzz { + if let Some(first) = nodes.first_mut() { + first + .env_vars + .push(Kv::new("p2p-fuzz", quoted_bool(conf.p2p_fuzz))); + } + + charon_cmd = CMD_UNSAFE_RUN; + } + + let data = TmplData { + compose_dir: dir.to_string_lossy().into_owned(), + charon_image_tag: conf.image_tag.clone(), + charon_command: charon_cmd.to_string(), + nodes, + relay: true, + monitoring: conf.monitoring, + alerting: true, + monitoring_ports: !conf.disable_monitoring_ports, + vcs, + ..TmplData::default() + }; + + info!("Created docker-compose.yml"); + info!("Run the cluster with: docker compose up"); + + write_docker_compose(dir, &data)?; + + Ok(data) +} + +/// Returns the validator client service for `typ` on node `node_idx`; the +/// mock client is charon's built-in one and needs no service. +fn get_vc( + typ: VcType, + node_idx: usize, + num_vals: usize, + insecure: bool, + builder_api: bool, +) -> TmplVc { + match typ { + VcType::Mock => TmplVc::default(), + VcType::Vouch | VcType::Lighthouse | VcType::Lodestar => TmplVc { + label: typ.to_string(), + build: typ.to_string(), + ..TmplVc::default() + }, + VcType::Teku => TmplVc { + label: typ.to_string(), + image: "consensys/teku:latest".to_string(), + command: teku_command(node_idx, num_vals, insecure, builder_api), + ..TmplVc::default() + }, + } +} + +/// The teku validator-client command as a YAML block scalar, one +/// `--validator-keys` pair per validator. +fn teku_command(node_idx: usize, num_vals: usize, insecure: bool, builder_api: bool) -> String { + let mut cmd = format!( + "|\n validator-client\n --network=auto\n --beacon-node-api-endpoint=\"http://node{node_idx}:3600\"\n" + ); + for i in 0..num_vals { + let stem = if insecure { + "keystore-insecure" + } else { + "keystore" + }; + let dir = format!("/compose/node{node_idx}/validator_keys/{stem}-{i}"); + cmd.push_str(&format!( + " --validator-keys=\"{dir}.json:{dir}.txt\"\n" + )); + } + cmd.push_str( + " --validators-proposer-default-fee-recipient=\"0x0000000000000000000000000000000000000000\"\n", + ); + cmd.push_str(&format!( + " --validators-proposer-blinded-blocks-enabled={builder_api}" + )); + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn teku_command_renders() { + let vc = get_vc(VcType::Teku, 0, 1, false, true); + assert_eq!(vc.label, "teku"); + assert_eq!(vc.image, "consensys/teku:latest"); + assert_eq!( + vc.command, + "| + validator-client + --network=auto + --beacon-node-api-endpoint=\"http://node0:3600\" + --validator-keys=\"/compose/node0/validator_keys/keystore-0.json:/compose/node0/validator_keys/keystore-0.txt\" + --validators-proposer-default-fee-recipient=\"0x0000000000000000000000000000000000000000\" + --validators-proposer-blinded-blocks-enabled=true" + ); + } + + #[test] + fn run_rejects_non_locked_step() { + let conf = Config::new_default(); + let dir = tempfile::tempdir().expect("tempdir"); + let err = run(dir.path(), conf).expect_err("must fail"); + assert_eq!( + err.to_string(), + "compose config not locked, so can't be run: step=new" + ); + } +} diff --git a/crates/test-compose/src/smoke.rs b/crates/test-compose/src/smoke.rs new file mode 100644 index 00000000..3d372b26 --- /dev/null +++ b/crates/test-compose/src/smoke.rs @@ -0,0 +1,295 @@ +//! The smoke scenario matrix: cluster configurations that are stood up with +//! docker compose and watched for alerts by the integration tests. +//! +//! The matrix is library code so the tests and the CI workflow share it. + +use std::{path::PathBuf, time::Duration}; + +use crate::{ + auto::AutoConfig, + config::{Config, KeyGen, NodeImpl, VcType}, + define::{BROADCAST_RULE, ERROR_RATE_RULE, VAPI_RATE_RULE}, + fsutil::env_non_empty, + template::TmplData, +}; + +/// The charon release the smoke clusters run. +pub const CHARON_IMAGE_TAG: &str = "v1.7.1"; + +/// How long a scenario keeps its cluster running while collecting alerts, +/// unless the scenario sets its own timeout. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2 * 60); + +/// Environment variable naming an external relay for the clusters to use +/// instead of the bundled one. +pub const EXTERNAL_RELAY_ENV: &str = "SMOKE_EXTERNAL_RELAY"; + +/// The config every scenario starts from: monitoring off, ports unexposed, +/// insecure keys, a mock validator client and the pinned charon release. +pub fn base_config() -> Config { + let mut conf = Config::new_default(); + conf.monitoring = false; + conf.disable_monitoring_ports = true; + conf.image_tag = CHARON_IMAGE_TAG.to_string(); + conf.insecure_keys = true; + conf.vcs = vec![VcType::Mock]; + + if let Some(relay) = env_non_empty(EXTERNAL_RELAY_ENV) { + conf.external_relay = relay; + } + + conf +} + +/// One entry of the smoke matrix. +#[derive(Debug, Clone, Copy)] +pub struct Scenario { + /// Unique scenario name, also the test name. + pub name: &'static str, + /// Adjusts the base config. + pub config_fn: fn(&mut Config), + /// Adjusts the run step template data. + pub run_tmpl_fn: Option, + /// Print `docker-compose.yml` after each step. + pub print_yml: bool, + /// Alert observation window. + pub timeout: Duration, +} + +impl Scenario { + const fn new(name: &'static str) -> Self { + Self { + name, + config_fn: |_| {}, + run_tmpl_fn: None, + print_yml: false, + timeout: DEFAULT_TIMEOUT, + } + } + + /// The scenario's cluster config. + pub fn config(&self) -> Config { + let mut conf = base_config(); + (self.config_fn)(&mut conf); + + conf + } + + /// Whether the scenario builds and runs pluto, so it needs + /// [`crate::PLUTO_REPO_ENV`]. + pub fn requires_pluto(&self) -> bool { + self.config().uses_pluto() + } + + /// An [`AutoConfig`] running this scenario in compose directory `dir`. + pub fn auto_config(&self, dir: impl Into) -> AutoConfig { + let mut conf = AutoConfig::new(dir); + conf.alert_timeout = self.timeout; + conf.print_yml = self.print_yml; + conf.run_tmpl_fn = self.run_tmpl_fn; + + conf + } +} + +/// Renames node0's `p2p*` environment variables so they are not applied, +/// leaving the node unable to join the cluster. +fn unset_node0_p2p(data: &mut TmplData) { + if let Some(node0) = data.nodes.first_mut() { + for kv in &mut node0.env_vars { + if kv.key.starts_with("p2p") { + kv.key.push_str("-unset"); + } + } + } +} + +/// The smoke matrix. +pub const SCENARIOS: &[Scenario] = &[ + Scenario { + print_yml: true, + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "alpha".to_string(); + }, + ..Scenario::new("default_alpha") + }, + Scenario { + config_fn: |conf| { + conf.num_nodes = 3; + conf.threshold = 2; + conf.key_gen = KeyGen::Create; + conf.feature_set = "beta".to_string(); + }, + ..Scenario::new("default_beta") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "stable".to_string(); + }, + ..Scenario::new("default_stable") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Dkg; + }, + ..Scenario::new("dkg") + }, + Scenario { + config_fn: |conf| { + conf.num_nodes = 10; + conf.threshold = 7; + conf.num_validators = 100; + conf.key_gen = KeyGen::Create; + conf.slot_duration = Duration::from_secs(6); + conf.synthetic_block_proposals = false; + }, + timeout: Duration::from_secs(3 * 60), + ..Scenario::new("very_large") + }, + Scenario { + config_fn: |conf| { + conf.alert_exclude_jobs = vec!["node0".to_string()]; + conf.alert_disable_rules = vec![ + ERROR_RATE_RULE.to_string(), + VAPI_RATE_RULE.to_string(), + BROADCAST_RULE.to_string(), + ]; + }, + run_tmpl_fn: Some(unset_node0_p2p), + ..Scenario::new("1_of_4_down") + }, + Scenario { + config_fn: |conf| { + conf.num_nodes = 3; + conf.threshold = 2; + conf.alert_exclude_jobs = vec!["node0".to_string()]; + conf.alert_disable_rules = + vec![ERROR_RATE_RULE.to_string(), VAPI_RATE_RULE.to_string()]; + }, + run_tmpl_fn: Some(unset_node0_p2p), + ..Scenario::new("1_of_3_down") + }, + Scenario { + config_fn: |conf| { + conf.builder_api = true; + }, + ..Scenario::new("blinded_blocks_vmock") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.key_gen_impl = Some(NodeImpl::Pluto); + }, + ..Scenario::new("pluto_keygen_create") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.node_impls = vec![NodeImpl::Pluto]; + conf.synthetic_block_proposals = false; + }, + ..Scenario::new("all_pluto") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.node_impls = vec![ + NodeImpl::Charon, + NodeImpl::Charon, + NodeImpl::Pluto, + NodeImpl::Pluto, + ]; + conf.synthetic_block_proposals = false; + }, + ..Scenario::new("mixed_2_charon_2_pluto") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Dkg; + conf.node_impls = vec![NodeImpl::Pluto]; + conf.synthetic_block_proposals = false; + }, + ..Scenario::new("pluto_dkg") + }, +]; + +/// Looks a scenario up by name. +pub fn scenario(name: impl AsRef) -> Option { + let name = name.as_ref(); + SCENARIOS + .iter() + .find(|scenario| scenario.name == name) + .copied() +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use crate::{ + config::{load_config, write_config}, + template::{Kv, TmplNode}, + }; + + #[test] + fn scenario_matrix() { + let mut names = HashSet::new(); + + for scenario in SCENARIOS { + assert!(!scenario.name.is_empty(), "scenario without a name"); + assert!( + names.insert(scenario.name), + "duplicate scenario name: {}", + scenario.name + ); + + let conf = scenario.config(); + let dir = tempfile::tempdir().expect("tempdir"); + write_config(dir.path(), &conf).expect("write config"); + let loaded = load_config(dir.path()).expect("load config"); + assert_eq!(loaded, conf, "{}: config round trip", scenario.name); + } + + assert_eq!(SCENARIOS.len(), 12); + assert_eq!( + SCENARIOS + .iter() + .filter(|scenario| scenario.requires_pluto()) + .count(), + 4 + ); + } + + #[test] + fn unset_node0_p2p_renames_only_node0_p2p_keys() { + let node = |keys: &[&str]| TmplNode { + env_vars: keys.iter().map(|key| Kv::new(*key, "v")).collect(), + ..TmplNode::default() + }; + let mut data = TmplData { + nodes: vec![ + node(&["p2p-relays", "log-level", "p2p-tcp-address"]), + node(&["p2p-relays"]), + ], + ..TmplData::default() + }; + + unset_node0_p2p(&mut data); + + let keys = |index: usize| -> Vec<&str> { + data.nodes[index] + .env_vars + .iter() + .map(|kv| kv.key.as_str()) + .collect() + }; + assert_eq!( + keys(0), + vec!["p2p-relays-unset", "log-level", "p2p-tcp-address-unset"] + ); + assert_eq!(keys(1), vec!["p2p-relays"]); + } +} diff --git a/crates/test-compose/src/static_files.rs b/crates/test-compose/src/static_files.rs new file mode 100644 index 00000000..07bdf9f5 --- /dev/null +++ b/crates/test-compose/src/static_files.rs @@ -0,0 +1,97 @@ +//! Static configuration files copied into every compose directory. +//! +//! The files are embedded at build time from the crate's `static/` directory, +//! so the generator has no runtime dependency on the source tree. A test checks +//! the table against the directory so a file added or removed there fails +//! the build's test run instead of silently drifting. + +/// One embedded static file. +#[derive(Debug)] +pub(crate) struct StaticFile { + /// Directory under the compose dir (and under `static/`). + pub(crate) dir: &'static str, + /// File name within `dir`. + pub(crate) name: &'static str, + /// File contents. + pub(crate) bytes: &'static [u8], +} + +macro_rules! static_file { + ($dir:literal, $name:literal) => { + StaticFile { + dir: $dir, + name: $name, + bytes: include_bytes!(concat!("../static/", $dir, "/", $name)), + } + }; +} + +/// All static files, sorted by directory then name. +pub(crate) const STATIC_FILES: &[StaticFile] = &[ + static_file!("grafana", "dash_alerts.json"), + static_file!("grafana", "dash_charon_overview.json"), + static_file!("grafana", "dash_duty_details.json"), + static_file!("grafana", "dashboards.yml"), + static_file!("grafana", "datasource.yml"), + static_file!("grafana", "grafana.ini"), + static_file!("grafana", "notifiers.yml"), + static_file!("lighthouse", "Dockerfile"), + static_file!("lighthouse", "run.sh"), + static_file!("lodestar", "Dockerfile"), + static_file!("lodestar", "run.sh"), + static_file!("loki", "loki.yml"), + static_file!("tempo", "tempo.yaml"), + static_file!("vouch", "Dockerfile"), + static_file!("vouch", "run.sh"), + static_file!("vouch", "vouch.yml"), +]; + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, fs, path::Path}; + + use super::*; + + const STATIC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/static"); + + #[test] + fn table_matches_static_dir() { + let mut on_disk = BTreeMap::new(); + for dir_entry in fs::read_dir(STATIC_DIR).expect("read static dir") { + let dir_entry = dir_entry.expect("dir entry"); + assert!( + dir_entry.file_type().expect("file type").is_dir(), + "static files at the top level are not supported: {:?}", + dir_entry.path() + ); + let dir_name = dir_entry.file_name().to_string_lossy().into_owned(); + + for file_entry in fs::read_dir(dir_entry.path()).expect("read static sub dir") { + let file_entry = file_entry.expect("file entry"); + assert!( + file_entry.file_type().expect("file type").is_file(), + "child static dirs are not supported: {:?}", + file_entry.path() + ); + let file_name = file_entry.file_name().to_string_lossy().into_owned(); + let bytes = fs::read(file_entry.path()).expect("read static file"); + on_disk.insert(format!("{dir_name}/{file_name}"), bytes); + } + } + + let embedded: BTreeMap> = STATIC_FILES + .iter() + .map(|f| (format!("{}/{}", f.dir, f.name), f.bytes.to_vec())) + .collect(); + + let disk_names: Vec<&String> = on_disk.keys().collect(); + let embedded_names: Vec<&String> = embedded.keys().collect(); + assert_eq!( + embedded_names, disk_names, + "STATIC_FILES must list exactly the files under static/" + ); + assert_eq!(embedded, on_disk, "embedded bytes must match static/"); + assert_eq!(STATIC_FILES.len(), 16); + assert!(Path::new(STATIC_DIR).is_dir()); + } +} diff --git a/crates/test-compose/src/template.rs b/crates/test-compose/src/template.rs new file mode 100644 index 00000000..a0379497 --- /dev/null +++ b/crates/test-compose/src/template.rs @@ -0,0 +1,295 @@ +//! Data model for `docker-compose.yml` and the writer that renders it. + +use std::path::Path; + +use serde::Serialize; + +use crate::{ + Result, + config::{CHARON_IMAGE, nullable_vec}, + error::ComposeError, + fsutil::write_file, +}; + +/// Everything `docker-compose.yml` is rendered from. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct TmplData { + /// Host directory mounted as `/compose` in every container. + pub compose_dir: String, + /// Tag of the shared `obolnetwork/charon` base image. + pub charon_image_tag: String, + /// Entrypoint override for the node base service; empty keeps the image's. + pub charon_entrypoint: String, + /// Command for the node base service. + pub charon_command: String, + /// Node services. + #[serde(with = "nullable_vec")] + pub nodes: Vec, + /// Validator client services, one per node. + #[serde(rename = "VCs", with = "nullable_vec")] + pub vcs: Vec, + /// Run the relay service. + pub relay: bool, + /// Run the grafana/tempo/loki stack. + pub monitoring: bool, + /// Run prometheus and the curl helper. + pub alerting: bool, + /// Publish the prometheus port on the host. + pub monitoring_ports: bool, +} + +/// A validator client service. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct TmplVc { + /// Service name suffix; empty renders no service. + pub label: String, + /// Docker image; empty when built from `build`. + pub image: String, + /// Build context under `static/`; empty when using `image`. + pub build: String, + /// Command override. + pub command: String, + /// Published ports. + #[serde(with = "nullable_vec")] + pub ports: Vec, +} + +/// A node service. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct TmplNode { + /// Image override; empty inherits the node base image. + pub image: String, + /// Entrypoint override. + pub entrypoint: String, + /// Command override. + pub command: String, + /// Environment variables, rendered as `CHARON_`. + #[serde(with = "nullable_vec")] + pub env_vars: Vec, + /// Published ports. + #[serde(with = "nullable_vec")] + pub ports: Vec, +} + +/// A charon flag and its value, rendered as an environment variable. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Kv { + /// Flag name, e.g. `p2p-tcp-address`. + pub key: String, + /// Flag value. + pub value: String, +} + +impl Kv { + /// Builds a key/value pair. + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } + + /// The environment variable form of the key: upper-cased with dashes + /// replaced by underscores, e.g. `P2P_TCP_ADDRESS`. + pub fn env_key(&self) -> String { + self.key.to_uppercase().replace('-', "_") + } +} + +/// A published port mapping. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Port { + /// Host port. + pub external: u32, + /// Container port. + pub internal: u32, +} + +/// Writes `docker-compose.yml` for `data` into `dir`. +pub fn write_docker_compose(dir: impl AsRef, data: &TmplData) -> Result<()> { + write_file( + dir.as_ref().join("docker-compose.yml"), + compose_yaml(data), + 0o755, + ) + .map_err(ComposeError::io("write docker-compose.yml")) +} + +/// Renders the compose file: a `node-base` anchor shared by the nodes and the +/// relay, one service per node and validator client, then the optional +/// alerting (curl + prometheus) and monitoring (grafana, tempo, loki) stacks. +fn compose_yaml(data: &TmplData) -> String { + let mut y = Yaml::default(); + + y.line("x-node-base: &node-base"); + let tag = &data.charon_image_tag; + y.line(format!(" image: {CHARON_IMAGE}:{tag}")); + y.opt(" entrypoint: ", &data.charon_entrypoint); + y.line(format!(" command: {}", data.charon_command)); + y.line(" networks: [compose]"); + y.line(format!(" volumes: [{}:/compose]", data.compose_dir)); + if data.relay { + y.line(" depends_on: [relay]"); + } + y.line(""); + y.line("services:"); + + for (i, node) in data.nodes.iter().enumerate() { + y.line(format!(" node{i}:")); + y.line(" <<: *node-base"); + y.line(format!(" container_name: node{i}")); + y.opt(" image: ", &node.image); + y.opt(" entrypoint: ", &node.entrypoint); + y.opt(" command: ", &node.command); + if !node.env_vars.is_empty() { + y.line(" environment:"); + for kv in &node.env_vars { + y.line(format!(" CHARON_{}: {}", kv.env_key(), kv.value)); + } + } + y.ports(&node.ports); + y.line(""); + } + + if data.relay { + y.line(RELAY_SERVICE); + } + + for (i, vc) in data.vcs.iter().enumerate() { + if vc.label.is_empty() { + continue; + } + y.line(format!(" vc{i}-{}:", vc.label)); + y.line(format!(" container_name: vc{i}-{}", vc.label)); + y.opt(" build: ", &vc.build); + y.opt(" image: ", &vc.image); + y.opt(" command: ", &vc.command); + y.line(" networks: [compose]"); + y.line(format!(" depends_on: [node{i}]")); + y.line(" environment:"); + y.line(format!(" NODE: node{i}")); + y.line(" volumes:"); + y.line(" - .:/compose"); + y.line(""); + } + + if data.alerting { + y.line(CURL_SERVICE); + y.line(" prometheus:"); + y.line(" container_name: prometheus"); + y.line(" image: prom/prometheus:${PROMETHEUS_VERSION:-v2.50.1}"); + if data.monitoring_ports { + y.line(" ports:"); + y.line(" - \"9090:9090\""); + } + y.line(" networks: [compose]"); + y.line(" volumes:"); + y.line(" - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml"); + y.line(" - ./prometheus/rules.yml:/etc/prometheus/rules.yml"); + y.line(""); + } + + if data.monitoring { + y.line(" grafana:"); + y.line(" container_name: grafana"); + y.line(" image: grafana/grafana:${GRAFANA_VERSION:-10.4.2}"); + if data.monitoring_ports { + y.line(" ports:"); + y.line(" - \"3000:3000\""); + } + y.line(GRAFANA_TAIL); + y.line(TEMPO_LOKI_SERVICES); + } + + y.line("networks:"); + y.line(" compose:"); + y.0 +} + +/// Line-oriented YAML output; every value is inserted verbatim. +#[derive(Default)] +struct Yaml(String); + +impl Yaml { + fn line(&mut self, s: impl AsRef) { + self.0.push_str(s.as_ref()); + self.0.push('\n'); + } + + /// `prefix` + `value` on one line, or nothing when the value is empty. + fn opt(&mut self, prefix: &str, value: &str) { + if !value.is_empty() { + self.line(format!("{prefix}{value}")); + } + } + + fn ports(&mut self, ports: &[Port]) { + if ports.is_empty() { + return; + } + self.line(" ports:"); + for port in ports { + self.line(format!(" - \"{}:{}\"", port.external, port.internal)); + } + } +} + +const RELAY_SERVICE: &str = r#" relay: + <<: *node-base + container_name: relay + command: relay + depends_on: [] + environment: + CHARON_HTTP_ADDRESS: 0.0.0.0:3640 + CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 + CHARON_DATA_DIR: /compose/relay + CHARON_P2P_RELAYS: "" + CHARON_P2P_EXTERNAL_HOSTNAME: relay + CHARON_P2P_TCP_ADDRESS: 0.0.0.0:3610 + CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 + CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" + CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push +"#; + +const CURL_SERVICE: &str = r#" curl: + container_name: curl + # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert + image: curlimages/curl:latest + command: sleep 1d + networks: [compose] +"#; + +const GRAFANA_TAIL: &str = r#" networks: [compose] + volumes: + - ./grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml + - ./grafana/dashboards.yml:/etc/grafana/provisioning/dashboards/datasource.yml + - ./grafana/notifiers.yml:/etc/grafana/provisioning/notifiers/notifiers.yml + - ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro + - ./grafana/dash_charon_overview.json:/etc/dashboards/dash_charon_overview.json + - ./grafana/dash_duty_details.json:/etc/dashboards/dash_duty_details.json + - ./grafana/dash_alerts.json:/etc/dashboards/dash_alerts.json +"#; + +const TEMPO_LOKI_SERVICES: &str = r#" tempo: + container_name: tempo + image: grafana/tempo:${TEMPO_VERSION:-2.7.1} + networks: [compose] + user: ":" + command: -config.file=/opt/tempo/tempo.yaml + volumes: + - ./tempo:/opt/tempo + + loki: + container_name: loki + image: grafana/loki:${LOKI_VERSION:-2.8.2} + networks: [compose] + user: ":" + command: -config.file=/opt/loki/loki.yml + volumes: + - ./loki:/opt/loki +"#; diff --git a/test-infra/compose/static/grafana/dash_alerts.json b/crates/test-compose/static/grafana/dash_alerts.json similarity index 100% rename from test-infra/compose/static/grafana/dash_alerts.json rename to crates/test-compose/static/grafana/dash_alerts.json diff --git a/test-infra/compose/static/grafana/dash_charon_overview.json b/crates/test-compose/static/grafana/dash_charon_overview.json similarity index 100% rename from test-infra/compose/static/grafana/dash_charon_overview.json rename to crates/test-compose/static/grafana/dash_charon_overview.json diff --git a/test-infra/compose/static/grafana/dash_duty_details.json b/crates/test-compose/static/grafana/dash_duty_details.json similarity index 100% rename from test-infra/compose/static/grafana/dash_duty_details.json rename to crates/test-compose/static/grafana/dash_duty_details.json diff --git a/test-infra/compose/static/grafana/dashboards.yml b/crates/test-compose/static/grafana/dashboards.yml similarity index 100% rename from test-infra/compose/static/grafana/dashboards.yml rename to crates/test-compose/static/grafana/dashboards.yml diff --git a/test-infra/compose/static/grafana/datasource.yml b/crates/test-compose/static/grafana/datasource.yml similarity index 100% rename from test-infra/compose/static/grafana/datasource.yml rename to crates/test-compose/static/grafana/datasource.yml diff --git a/test-infra/compose/static/grafana/grafana.ini b/crates/test-compose/static/grafana/grafana.ini similarity index 100% rename from test-infra/compose/static/grafana/grafana.ini rename to crates/test-compose/static/grafana/grafana.ini diff --git a/test-infra/compose/static/grafana/notifiers.yml b/crates/test-compose/static/grafana/notifiers.yml similarity index 100% rename from test-infra/compose/static/grafana/notifiers.yml rename to crates/test-compose/static/grafana/notifiers.yml diff --git a/test-infra/compose/static/lighthouse/Dockerfile b/crates/test-compose/static/lighthouse/Dockerfile similarity index 100% rename from test-infra/compose/static/lighthouse/Dockerfile rename to crates/test-compose/static/lighthouse/Dockerfile diff --git a/test-infra/compose/static/lighthouse/run.sh b/crates/test-compose/static/lighthouse/run.sh similarity index 100% rename from test-infra/compose/static/lighthouse/run.sh rename to crates/test-compose/static/lighthouse/run.sh diff --git a/test-infra/compose/static/lodestar/Dockerfile b/crates/test-compose/static/lodestar/Dockerfile similarity index 100% rename from test-infra/compose/static/lodestar/Dockerfile rename to crates/test-compose/static/lodestar/Dockerfile diff --git a/test-infra/compose/static/lodestar/run.sh b/crates/test-compose/static/lodestar/run.sh similarity index 100% rename from test-infra/compose/static/lodestar/run.sh rename to crates/test-compose/static/lodestar/run.sh diff --git a/test-infra/compose/static/loki/loki.yml b/crates/test-compose/static/loki/loki.yml similarity index 100% rename from test-infra/compose/static/loki/loki.yml rename to crates/test-compose/static/loki/loki.yml diff --git a/test-infra/compose/static/tempo/tempo.yaml b/crates/test-compose/static/tempo/tempo.yaml similarity index 100% rename from test-infra/compose/static/tempo/tempo.yaml rename to crates/test-compose/static/tempo/tempo.yaml diff --git a/test-infra/compose/static/vouch/Dockerfile b/crates/test-compose/static/vouch/Dockerfile similarity index 100% rename from test-infra/compose/static/vouch/Dockerfile rename to crates/test-compose/static/vouch/Dockerfile diff --git a/test-infra/compose/static/vouch/run.sh b/crates/test-compose/static/vouch/run.sh similarity index 100% rename from test-infra/compose/static/vouch/run.sh rename to crates/test-compose/static/vouch/run.sh diff --git a/test-infra/compose/static/vouch/vouch.yml b/crates/test-compose/static/vouch/vouch.yml similarity index 100% rename from test-infra/compose/static/vouch/vouch.yml rename to crates/test-compose/static/vouch/vouch.yml diff --git a/test-infra/compose/testdata/TestDockerCompose_define_create_template.golden b/crates/test-compose/testdata/TestDockerCompose_define_create_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_define_create_template.golden rename to crates/test-compose/testdata/TestDockerCompose_define_create_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_define_create_yml.golden b/crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden similarity index 95% rename from test-infra/compose/testdata/TestDockerCompose_define_create_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden index feb6b363..b18abf72 100644 --- a/test-infra/compose/testdata/TestDockerCompose_define_create_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden @@ -4,15 +4,11 @@ x-node-base: &node-base command: No charon commands needed for keygen=create define step networks: [compose] volumes: [testdir:/compose] - services: node0: <<: *node-base container_name: node0 - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_define_dkg_template.golden b/crates/test-compose/testdata/TestDockerCompose_define_dkg_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_define_dkg_template.golden rename to crates/test-compose/testdata/TestDockerCompose_define_dkg_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_define_dkg_yml.golden b/crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden similarity index 98% rename from test-infra/compose/testdata/TestDockerCompose_define_dkg_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden index 75984835..c93ec31a 100644 --- a/test-infra/compose/testdata/TestDockerCompose_define_dkg_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden @@ -3,13 +3,11 @@ x-node-base: &node-base command: [create,dkg] networks: [compose] volumes: [testdir:/compose] - services: node0: <<: *node-base container_name: node0 - environment: CHARON_NAME: compose CHARON_NUM_VALIDATORS: 1 @@ -20,9 +18,6 @@ services: CHARON_DKG_ALGORITHM: frost CHARON_OUTPUT_DIR: /compose CHARON_NETWORK: goerli - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_create_pluto_keygen_template.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_create_pluto_keygen_template.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden similarity index 97% rename from test-infra/compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden index 7eb1fa5b..9c08fe28 100644 --- a/test-infra/compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden @@ -3,14 +3,12 @@ x-node-base: &node-base command: [create,cluster] networks: [compose] volumes: [testdir:/compose] - services: node0: <<: *node-base container_name: node0 image: pluto:local - environment: CHARON_NAME: compose-4-1 CHARON_THRESHOLD: 3 @@ -23,9 +21,6 @@ services: CHARON_WITHDRAWAL_ADDRESSES: "0x0000000000000000000000000000000000000000" CHARON_FEE_RECIPIENT_ADDRESSES: "0x0000000000000000000000000000000000000000" CHARON_NETWORK: goerli - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_create_template.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_create_template.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_create_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_create_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden similarity index 97% rename from test-infra/compose/testdata/TestDockerCompose_lock_create_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden index f485f9f5..5e4ae573 100644 --- a/test-infra/compose/testdata/TestDockerCompose_lock_create_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden @@ -3,13 +3,11 @@ x-node-base: &node-base command: [create,cluster] networks: [compose] volumes: [testdir:/compose] - services: node0: <<: *node-base container_name: node0 - environment: CHARON_NAME: compose-4-1 CHARON_THRESHOLD: 3 @@ -22,9 +20,6 @@ services: CHARON_WITHDRAWAL_ADDRESSES: "0x0000000000000000000000000000000000000000" CHARON_FEE_RECIPIENT_ADDRESSES: "0x0000000000000000000000000000000000000000" CHARON_NETWORK: goerli - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_template.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_template.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden similarity index 98% rename from test-infra/compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden index 408a78a0..a02fce9d 100644 --- a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden @@ -10,7 +10,6 @@ services: <<: *node-base container_name: node0 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node0/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -23,13 +22,12 @@ services: CHARON_DATA_DIR: /compose/node0 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node1: <<: *node-base container_name: node1 image: pluto:local command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node1/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -42,12 +40,11 @@ services: CHARON_DATA_DIR: /compose/node1 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node2: <<: *node-base container_name: node2 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node2/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -60,13 +57,12 @@ services: CHARON_DATA_DIR: /compose/node2 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node3: <<: *node-base container_name: node3 image: pluto:local command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node3/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -79,7 +75,7 @@ services: CHARON_DATA_DIR: /compose/node3 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + relay: <<: *node-base container_name: relay @@ -95,9 +91,6 @@ services: CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_template.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_dkg_template.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_dkg_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden similarity index 98% rename from test-infra/compose/testdata/TestDockerCompose_lock_dkg_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden index c829bf1a..9a372e75 100644 --- a/test-infra/compose/testdata/TestDockerCompose_lock_dkg_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden @@ -10,7 +10,6 @@ services: <<: *node-base container_name: node0 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node0/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -23,12 +22,11 @@ services: CHARON_DATA_DIR: /compose/node0 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node1: <<: *node-base container_name: node1 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node1/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -41,12 +39,11 @@ services: CHARON_DATA_DIR: /compose/node1 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node2: <<: *node-base container_name: node2 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node2/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -59,12 +56,11 @@ services: CHARON_DATA_DIR: /compose/node2 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + node3: <<: *node-base container_name: node3 command: [dkg,--shutdown-delay=2s] - environment: CHARON_PRIVATE_KEY_FILE: /compose/node3/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -77,7 +73,7 @@ services: CHARON_DATA_DIR: /compose/node3 CHARON_DEFINITION_FILE: /compose/cluster-definition.json CHARON_INSECURE_KEYS: "false" - + relay: <<: *node-base container_name: relay @@ -93,9 +89,6 @@ services: CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push - - - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_template.golden b/crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_template.golden rename to crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden b/crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden similarity index 97% rename from test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden index 5d9e3abf..ffe4069b 100644 --- a/test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden @@ -9,7 +9,6 @@ services: node0: <<: *node-base container_name: node0 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node0/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -33,20 +32,15 @@ services: CHARON_OTLP_SERVICE_NAME: node0 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node0 - ports: - "3600:3600" - - "3610:3610" - - "3620:3620" - - "3630:3630" - + node1: <<: *node-base container_name: node1 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node1/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -70,21 +64,16 @@ services: CHARON_OTLP_SERVICE_NAME: node1 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node1 - ports: - "13600:3600" - - "13610:3610" - - "13620:3620" - - "13630:3630" - + node2: <<: *node-base container_name: node2 image: pluto:local - environment: CHARON_PRIVATE_KEY_FILE: /compose/node2/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -108,21 +97,16 @@ services: CHARON_OTLP_SERVICE_NAME: node2 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node2 - ports: - "23600:3600" - - "23610:3610" - - "23620:3620" - - "23630:3630" - + node3: <<: *node-base container_name: node3 image: pluto:local - environment: CHARON_PRIVATE_KEY_FILE: /compose/node3/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -146,16 +130,12 @@ services: CHARON_OTLP_SERVICE_NAME: node3 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node3 - ports: - "33600:3600" - - "33610:3610" - - "33620:3620" - - "33630:3630" - + relay: <<: *node-base container_name: relay @@ -171,7 +151,7 @@ services: CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push - + vc0-lighthouse: container_name: vc0-lighthouse build: lighthouse @@ -181,7 +161,7 @@ services: NODE: node0 volumes: - .:/compose - + vc1-lighthouse: container_name: vc1-lighthouse build: lighthouse @@ -191,7 +171,7 @@ services: NODE: node1 volumes: - .:/compose - + vc3-lighthouse: container_name: vc3-lighthouse build: lighthouse @@ -201,7 +181,7 @@ services: NODE: node3 volumes: - .:/compose - + curl: container_name: curl # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert @@ -218,9 +198,7 @@ services: volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus/rules.yml:/etc/prometheus/rules.yml - - grafana: container_name: grafana image: grafana/grafana:${GRAFANA_VERSION:-10.4.2} @@ -253,7 +231,6 @@ services: command: -config.file=/opt/loki/loki.yml volumes: - ./loki:/opt/loki - networks: compose: diff --git a/test-infra/compose/testdata/TestDockerCompose_run_template.golden b/crates/test-compose/testdata/TestDockerCompose_run_template.golden similarity index 100% rename from test-infra/compose/testdata/TestDockerCompose_run_template.golden rename to crates/test-compose/testdata/TestDockerCompose_run_template.golden diff --git a/test-infra/compose/testdata/TestDockerCompose_run_yml.golden b/crates/test-compose/testdata/TestDockerCompose_run_yml.golden similarity index 97% rename from test-infra/compose/testdata/TestDockerCompose_run_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_run_yml.golden index d642c188..210622c6 100644 --- a/test-infra/compose/testdata/TestDockerCompose_run_yml.golden +++ b/crates/test-compose/testdata/TestDockerCompose_run_yml.golden @@ -9,7 +9,6 @@ services: node0: <<: *node-base container_name: node0 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node0/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -33,20 +32,15 @@ services: CHARON_OTLP_SERVICE_NAME: node0 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node0 - ports: - "3600:3600" - - "3610:3610" - - "3620:3620" - - "3630:3630" - + node1: <<: *node-base container_name: node1 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node1/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -70,20 +64,15 @@ services: CHARON_OTLP_SERVICE_NAME: node1 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node1 - ports: - "13600:3600" - - "13610:3610" - - "13620:3620" - - "13630:3630" - + node2: <<: *node-base container_name: node2 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node2/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -107,20 +96,15 @@ services: CHARON_OTLP_SERVICE_NAME: node2 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node2 - ports: - "23600:3600" - - "23610:3610" - - "23620:3620" - - "23630:3630" - + node3: <<: *node-base container_name: node3 - environment: CHARON_PRIVATE_KEY_FILE: /compose/node3/charon-enr-private-key CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 @@ -144,16 +128,12 @@ services: CHARON_OTLP_SERVICE_NAME: node3 CHARON_LOKI_ADDRESSES: http://loki:3100/loki/api/v1/push CHARON_LOKI_SERVICE: node3 - ports: - "33600:3600" - - "33610:3610" - - "33620:3620" - - "33630:3630" - + relay: <<: *node-base container_name: relay @@ -169,7 +149,7 @@ services: CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push - + vc0-lighthouse: container_name: vc0-lighthouse build: lighthouse @@ -179,7 +159,7 @@ services: NODE: node0 volumes: - .:/compose - + vc1-lighthouse: container_name: vc1-lighthouse build: lighthouse @@ -189,7 +169,7 @@ services: NODE: node1 volumes: - .:/compose - + vc3-lighthouse: container_name: vc3-lighthouse build: lighthouse @@ -199,7 +179,7 @@ services: NODE: node3 volumes: - .:/compose - + curl: container_name: curl # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert @@ -216,9 +196,7 @@ services: volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus/rules.yml:/etc/prometheus/rules.yml - - grafana: container_name: grafana image: grafana/grafana:${GRAFANA_VERSION:-10.4.2} @@ -251,7 +229,6 @@ services: command: -config.file=/opt/loki/loki.yml volumes: - ./loki:/opt/loki - networks: compose: diff --git a/test-infra/compose/testdata/TestNewDefaultConfig.golden b/crates/test-compose/testdata/TestNewDefaultConfig.golden similarity index 100% rename from test-infra/compose/testdata/TestNewDefaultConfig.golden rename to crates/test-compose/testdata/TestNewDefaultConfig.golden diff --git a/crates/test-compose/tests/smoke.rs b/crates/test-compose/tests/smoke.rs new file mode 100644 index 00000000..c99f74be --- /dev/null +++ b/crates/test-compose/tests/smoke.rs @@ -0,0 +1,91 @@ +//! Docker-based smoke tests: each scenario stands up a full compose cluster +//! and watches it for alerts. All are ignored by default; run them with +//! +//! ```text +//! cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture [--skip very_large] +//! ``` +//! +//! Scenarios run one at a time whatever `--test-threads` says: clusters +//! competing for CPU and memory produce duty timeouts that a sequential run +//! never sees, so a concurrent pass would test the host, not the cluster. +//! +//! Environment: +//! - `PLUTO_REPO`: pluto checkout to build `pluto:local` from; scenarios that +//! run pluto are skipped when it is unset. +//! - `SMOKE_SUDO_PERMS=1`: fix root-owned artefacts with `sudo` after each +//! step. +//! - `SMOKE_LOG_DIR=`: write each scenario's `docker compose up` output to +//! `/.log` instead of stdout. +//! - `SMOKE_EXTERNAL_RELAY=`: route the cluster through an external relay. + +use std::path::PathBuf; + +use pluto_test_compose::{PLUTO_REPO_ENV, auto, env_non_empty, smoke, write_config}; +use tokio::sync::Mutex; + +/// Held for the whole of a scenario so the docker clusters never overlap. +static SERIAL: Mutex<()> = Mutex::const_new(()); + +async fn run_scenario(name: &str) { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + + let scenario = smoke::scenario(name).unwrap_or_else(|| panic!("unknown scenario {name}")); + if scenario.requires_pluto() && env_non_empty(PLUTO_REPO_ENV).is_none() { + eprintln!("skipping {name}: {PLUTO_REPO_ENV} not set"); + return; + } + + let _serial = SERIAL.lock().await; + + let dir = tempfile::Builder::new() + .prefix("smoke-") + .tempdir() + .expect("compose tempdir"); + write_config(dir.path(), &scenario.config()).expect("write config"); + + let mut conf = scenario.auto_config(dir.path()); + conf.sudo_perms = env_non_empty("SMOKE_SUDO_PERMS").is_some_and(|value| value != "0"); + conf.log_file = env_non_empty("SMOKE_LOG_DIR") + .map(|log_dir| PathBuf::from(log_dir).join(format!("{name}.log"))); + + // Display, not Debug: the failure line then reads as the Go harness prints + // it. + if let Err(err) = auto(conf).await { + panic!("smoke scenario {name} failed: {err}"); + } +} + +macro_rules! smoke_tests { + ($($test:ident => $name:literal),* $(,)?) => { + const SCENARIO_NAMES: &[&str] = &[$($name),*]; + + $( + #[tokio::test] + #[ignore = "docker-based smoke test; run with --ignored"] + async fn $test() { + run_scenario($name).await; + } + )* + }; +} + +smoke_tests! { + scenario_default_alpha => "default_alpha", + scenario_default_beta => "default_beta", + scenario_default_stable => "default_stable", + scenario_dkg => "dkg", + scenario_very_large => "very_large", + scenario_1_of_4_down => "1_of_4_down", + scenario_1_of_3_down => "1_of_3_down", + scenario_blinded_blocks_vmock => "blinded_blocks_vmock", + scenario_pluto_keygen_create => "pluto_keygen_create", + scenario_all_pluto => "all_pluto", + scenario_mixed_2_charon_2_pluto => "mixed_2_charon_2_pluto", + scenario_pluto_dkg => "pluto_dkg", +} + +#[test] +fn every_scenario_has_a_test() { + let names: Vec<&str> = smoke::SCENARIOS.iter().map(|s| s.name).collect(); + assert_eq!(names, SCENARIO_NAMES); +} diff --git a/test-infra/compose/README.md b/test-infra/compose/README.md deleted file mode 100644 index c70750a3..00000000 --- a/test-infra/compose/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# Pluto Compose - -> A docker-compose test harness for standing up insecure local pluto/charon clusters, used by the smoke integration tests. - -This is adapted from charon's [testutil/compose](https://github.com/ObolNetwork/charon/tree/main/testutil/compose) -(pinned at `v1.7.1`, the pluto parity reference) and extended with one extra axis: each node -in the cluster can run either **charon** or **pluto**, so clusters of N charon + M pluto nodes -can be composed for cross-implementation testing. - -The harness generates `docker-compose.yml` files that stand up a full cluster (keygen + -run) against a mock beacon node. It is driven programmatically by the integration tests -under `smoke/` — there is no standalone CLI. Cluster generation happens in -three stages, exposed as package functions and pinned by the golden tests in `testdata/`: - -1. **define** (`Define`): writes a `docker-compose.yml` that runs `create dkg` when keygen==dkg. -2. **lock** (`Lock`): writes a `docker-compose.yml` that runs `create cluster` or `dkg`. -3. **run** (`Run`): writes a `docker-compose.yml` that runs the cluster. - -`Auto` (see `auto.go`) chains define → lock → run and runs `docker compose up`; it is what -the tests call after writing a config with `WriteConfig`. - -## Node implementations - -Each node runs either charon or pluto, assigned round-robin from a scenario's `NodeImpls` -config (empty defaults to all charon): - -- Charon nodes run `obolnetwork/charon:{tag}` (smoke pins `v1.7.1`; the default config -uses `latest`). Set the tag to `local` to build from `CHARON_REPO`. -- Pluto nodes run `pluto:{tag}` (default `local`), built automatically from the repo -root `Dockerfile` during the define step. This requires the `PLUTO_REPO` env var -pointing at the pluto repo. -- `KeyGenImpl` selects which implementation runs the single-container keygen steps -(`create cluster` / `create dkg`); it defaults to node0's implementation. -- The relay always runs the charon node-base image. - -Pluto accepts `CHARON_*` env vars and charon-compatible flags by design (CLI parity), -so the generated docker-compose.yml services are identical for both implementations -apart from the image — no per-implementation command construction. All node roles are -supported for both implementations: keygen (`create cluster`, `create dkg`, `dkg`) and -run. Env parity includes charon's empty-value semantics: a `CHARON_*` variable that is -set but empty counts as unset, as Viper does, so an empty placeholder falls back to the -flag default instead of being parsed as `""`. - -Implementation names are validated when configs are written and loaded; anything -other than `charon` or `pluto` is rejected. - -## Smoke tests - -`smoke/smoke_test.go` mirrors charon's compose smoke tests: each scenario generates -and runs a full cluster with a mock beacon node (simnet), while a Prometheus container -evaluates the generated alert rules (see `writeAlertRules` in `define.go`). A scenario -fails if any alert fires. - -Alert semantics: collection starts once Prometheus answers its rules API. For the -next 60 seconds (the warmup window) exactly three known cold-start transients are -tolerated and must self-resolve — `Error Log Rate` (one consensus-timeout error per -node at the first epoch boundary, before the validator mock submits duties), -`Warn Log Rate` (charon's app-start warning burst), and `Broadcast Duty Rate` (no -duties broadcast before the p2p mesh forms). Any other alert fires the scenario -immediately, warmup or not, and so does anything still firing after the warmup. - -Prerequisites: a running Docker daemon and Go. The first run builds `pluto:local` -from `PLUTO_REPO` (a few minutes) and pulls `obolnetwork/charon:v1.7.1` — both -happen automatically, no manual build or `go install` needed. - -``` -cd test-infra/compose - -# Pluto scenarios only (builds pluto:local from PLUTO_REPO; relay and -# pluto_keygen_create runtime nodes pull obolnetwork/charon:v1.7.1): -PLUTO_REPO=$(git rev-parse --show-toplevel) go test ./smoke -v -integration -timeout=35m \ - -run 'TestSmoke/(pluto_keygen_create|all_pluto|mixed_2_charon_2_pluto|pluto_dkg)$' - -# Full matrix (pluto + charon-only scenarios): -PLUTO_REPO=$(git rev-parse --show-toplevel) go test ./smoke -v -integration -timeout=35m - -# Keep docker-compose logs per scenario: -go test ./smoke -v -integration -timeout=35m -log-dir=. -``` - -Scenarios that involve pluto (`pluto_keygen_create`, `all_pluto`, -`mixed_2_charon_2_pluto`, `pluto_dkg`) skip when the `PLUTO_REPO` env var is unset; -everything else always runs. `-timeout=35m` covers the full matrix (each scenario is -bounded by its own 2–3 minute alert window plus image builds); the Go default of 10m -is not enough. - -All smoke scenarios run the mock validator client. Real VCs cannot pass the alert -gate against charon v1.7.1's beaconmock: it reports `head_slot: "1"` from -`/eth/v1/node/syncing`, so e.g. lighthouse permanently treats the beacon node as -unsynced and performs no duties, starving the cluster below its signing threshold. -(Upstream charon runs lighthouse in these scenarios but its alert collector matches -a Prometheus state that never occurs, so nothing was ever gated.) The real-VC compose -service definitions remain in the harness (`static/`), but the tests always run the -mock VC. - -### Alert criteria vs. charon - -Adapted from charon's `testutil/compose` alert rules, but the gate is corrected and the -criteria calibrated to actually fire: charon's collector matches Prometheus alert state -`"active"`, which is never emitted (only `inactive` / `pending` / `firing`), so upstream -nothing is ever gated. This harness matches `"firing"`, so several rules necessarily differ: - -| Rule | Charon v1.7.1 | Pluto | Change & why | -|------|---------------|-------|--------------| -| `Pluto Down` | `up == 0` | `up == 0` | identical | -| `Validator API Error Rate` | `increase(…{endpoint!="proxy"}[30s]) > 1` | same | identical | -| `Proxy API Error Rate` | `increase(…{endpoint="proxy"}[30s]) > 5` | same | identical | -| `Warn Log Rate` | `increase(app_log_warn_total[30s]) > 2` | same + `{topic!~"vmock\|tracker"}` | exclude charon mock-noise topics (vmock has no builder-registration handler; the beacon mock never includes broadcasts on-chain) | -| `Error Log Rate` | `app_log_error_total > 0` | `increase(app_log_error_total[30s]) > 0` | windowed — an absolute counter can't recover from the inherent cold-start consensus timeout (mock-VC startup delay → no randao); a window + warmup can | -| `Broadcast Duty Rate` | `increase(core_bcast_broadcast_total[30s]) < 0.5` | `(sum by (job) (increase(…{job=~"node[0-9]+"}[30s])) or on (job) max by (job) (0 * up)) < 0.5` | per-node sum + absent-series fallback, so a node emitting *no* broadcast series fails (charon's per-series form missed it) | -| `Outstanding Duty Rate` | `core_bcast_broadcast_total − core_scheduler_duty_total > 50` | *removed* | dead rule — a duty is broadcast at most as often as scheduled, so it can never be positive | -| _gate (alert state)_ | `"active"` — never emitted | `"firing"` + readiness wait + 60s warmup allowlist | charon's gate is vacuous; pluto's enforces | - -Scenarios that intentionally degrade the cluster tune the gate via config, not the code: - -| Config knob | Effect | Used by | -|-------------|--------|---------| -| `AlertExcludeJobs` | exempt a node from the per-node rules (never from `Pluto Down`) | `1_of_4_down`, `1_of_3_down` | -| `AlertDisableRules` | drop an entire rule | `1_of_3_down` (disables the error-rate gates — a downed round-1 leader makes every third proposer duty unrecoverable on the mock) | - -## Versioning - -Charon is pinned to the pluto parity reference (`v1.7.1`): both the Go library in -`go.mod` and the docker image tag used by smoke tests. The two `replace` directives in -`go.mod` are copied from charon's own `go.mod` (Go does not propagate a dependency's -replaces) and must be kept in sync when bumping charon. Bump deliberately alongside the -parity target, not to track charon main. \ No newline at end of file diff --git a/test-infra/compose/alert.go b/test-infra/compose/alert.go deleted file mode 100644 index 05c551ed..00000000 --- a/test-infra/compose/alert.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "bytes" - "context" - "encoding/json" - "os/exec" - "time" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" -) - -const alertsPolled = "alerts_polled" - -// alertWarmup is the window after Prometheus first answers the rules API in -// which the known cold-start transients (startupTransientRules) may fire; they -// must resolve before it ends. Alerts outside that allowlist fail immediately, -// warmup or not. Callers must give the alert context comfortably more than -// this (the smoke suite uses 2m timeouts). -const alertWarmup = time.Second * 60 - -// startupTransientRules names the alert rules that fire on any healthy -// cluster while it boots and self-resolve within the warmup window (all rule -// expressions are windowed, so a transient ages out): -// - Error Log Rate: the first epoch-boundary proposer consensus fails on -// every node (the validatormock delays 2 slots before submitting duties, -// so no randao exists yet), logging one consensus-timeout ERROR each. -// - Warn Log Rate: charon's app-start warning burst (insecure relay URL, -// empty QUIC address, beacon version parse) exceeds the 30s-window -// threshold once at boot. -// - Broadcast Duty Rate: nodes are scraped before the p2p mesh forms and -// the first duties broadcast, so the injected absent-series zero fires. -// -// Anything else firing during warmup (e.g. Pluto Down, validator API error -// rates) is a real failure and is reported immediately. -var startupTransientRules = map[string]bool{ - errorRateRule: true, - warnRateRule: true, - broadcastRule: true, -} - -// activeAlert is a firing alert: the rule name that produced it and its -// rendered description. -type activeAlert struct { - Rule string - Description string -} - -// startAlertCollector starts a goroutine that polls prometheus alerts until the context is closed and returns -// a channel on which the received alert descriptions will be sent. -func startAlertCollector(ctx context.Context, dir string) chan string { - resp := make(chan string, 100) - - go func() { - defer close(resp) - - const iterSleep = time.Second * 2 - - // Wait for Prometheus to answer instead of sleeping blindly; the - // warmup window is anchored to readiness so slow container starts do - // not eat into it. - readyAt, ok := awaitPrometheusReady(ctx, dir, iterSleep) - if !ok { - return // Context closed: Auto reports "alerts couldn't be polled". - } - - log.Info(ctx, "Prometheus ready, collecting alerts", - z.Str("warmup", alertWarmup.String())) - - warmupEnd := readyAt.Add(alertWarmup) - - var ( - reported = make(map[string]bool) - ignored = make(map[string]bool) - // The oracle is only satisfied if polling was still working when - // the window closed. A single early success is not enough: if - // prometheus (or the whole stack) dies mid-run, every later poll - // fails and an "at least once" signal would report a clean pass on - // a cluster nobody observed. - lastPollOK bool - postWarmupPollOK bool - ) - - for ; ctx.Err() == nil; time.Sleep(iterSleep) { // Sleep for iterSleep before next iteration. - alerts, err := queryAlerts(ctx, dir) - if ctx.Err() != nil { - // The window closed mid-poll; that failure is expected and must - // not count against the verdict. - break - } else if err != nil { - lastPollOK = false - - log.Error(ctx, "Poll prometheus alerts", err) - - continue - } - - if alerts.Status != "success" { - lastPollOK = false - resp <- "non success status from prometheus alerts: " + alerts.Status - - continue - } - - lastPollOK = true - - inWarmup := time.Now().Before(warmupEnd) - if !inWarmup { - postWarmupPollOK = true - } - - for _, active := range getActiveAlerts(alerts) { - if inWarmup && startupTransientRules[active.Rule] { - if !ignored[active.Description] { - ignored[active.Description] = true - log.Info(ctx, "Ignoring known cold-start transient during warmup", - z.Str("alert", active.Description)) - } - - continue // Still fails if firing after warmup, see below. - } - - if reported[active.Description] { - continue - } - - reported[active.Description] = true - log.Info(ctx, "Detected new alert", z.Str("alert", active.Description)) - - resp <- active.Description - } - } - - // Only now can the run be called observed: polling reached past the - // warmup window and was still succeeding when the window closed. - if postWarmupPollOK && lastPollOK { - resp <- alertsPolled - } - }() - - return resp -} - -// awaitPrometheusReady polls the prometheus rules API until it answers -// successfully, returning the readiness time. Returns false if the context -// closes first. -func awaitPrometheusReady(ctx context.Context, dir string, interval time.Duration) (time.Time, bool) { - log.Info(ctx, "Waiting for prometheus to answer the rules API") - - for ctx.Err() == nil { - alerts, err := queryAlerts(ctx, dir) - if err == nil && alerts.Status == "success" { - return time.Now(), true - } - - time.Sleep(interval) - } - - return time.Time{}, false -} - -// queryAlerts fetches and parses the prometheus alert rules via the curl -// container. -func queryAlerts(ctx context.Context, dir string) (promAlerts, error) { - //nolint:revive // tls not required for testing. - cmd := exec.CommandContext(ctx, "docker", "compose", "exec", "-T", "curl", "curl", "-s", "http://prometheus:9090/api/v1/rules?type=alert") - cmd.Dir = dir - - out, err := cmd.CombinedOutput() - if err != nil { - return promAlerts{}, errors.Wrap(err, "exec curl alerts", z.Str("out", string(out))) - } - - var alerts promAlerts - if err := json.Unmarshal(bytes.TrimSpace(out), &alerts); err != nil { - return promAlerts{}, errors.Wrap(err, "unmarshal alerts", z.Str("out", string(out))) - } - - return alerts, nil -} - -func getActiveAlerts(alerts promAlerts) []activeAlert { - var resp []activeAlert - - for _, group := range alerts.Data.Groups { - for _, rule := range group.Rules { - for _, alert := range rule.Alerts { - // Prometheus reports alert states as inactive/pending/firing. - // Charon matches "active" here, which never occurs, so its - // alert gate silently passes everything (upstream bug). - if alert.State != "firing" { - continue - } - - resp = append(resp, activeAlert{ - Rule: rule.Name, - Description: alert.Annotations.Description, - }) - } - } - } - - return resp -} - -// promAlerts is the json response returned by querying prometheus alerts. -type promAlerts struct { - Status string `json:"status"` - Data struct { - Groups []struct { - Name string `json:"name"` - Rules []struct { - Name string `json:"name"` - Alerts []struct { - State string `json:"state"` - Annotations struct { - Description string `json:"description"` - } `json:"annotations"` - } `json:"alerts"` - } `json:"rules"` - } `json:"groups"` - } `json:"data"` -} diff --git a/test-infra/compose/alert_internal_test.go b/test-infra/compose/alert_internal_test.go deleted file mode 100644 index caa7947e..00000000 --- a/test-infra/compose/alert_internal_test.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestGetActiveAlertsFiringOnly asserts only firing alerts are reported: -// pending and inactive states (and charon's never-occurring "active") are -// ignored. -func TestGetActiveAlertsFiringOnly(t *testing.T) { - payload := `{ - "status": "success", - "data": { - "groups": [ - { - "name": "pluto", - "rules": [ - { - "name": "Error Log Rate", - "alerts": [ - {"state": "firing", "annotations": {"description": "node0 has a high error rate"}}, - {"state": "pending", "annotations": {"description": "node1 has a high error rate"}} - ] - }, - { - "name": "Pluto Down", - "alerts": [ - {"state": "inactive", "annotations": {"description": "node2 is down"}}, - {"state": "active", "annotations": {"description": "node3 is down"}} - ] - } - ] - } - ] - } - }` - - var alerts promAlerts - require.NoError(t, json.Unmarshal([]byte(payload), &alerts)) - - active := getActiveAlerts(alerts) - require.Equal(t, []activeAlert{{ - Rule: "Error Log Rate", - Description: "node0 has a high error rate", - }}, active) -} - -// TestStartupTransientRulesScoped pins the warmup allowlist: only the three -// proven cold-start transients may fire during warmup; scrape and API error -// alerts always fail. -func TestStartupTransientRulesScoped(t *testing.T) { - require.True(t, startupTransientRules["Error Log Rate"]) - require.True(t, startupTransientRules["Warn Log Rate"]) - require.True(t, startupTransientRules["Broadcast Duty Rate"]) - - require.False(t, startupTransientRules["Pluto Down"]) - require.False(t, startupTransientRules["Validator API Error Rate"]) - require.False(t, startupTransientRules["Proxy API Error Rate"]) - require.Len(t, startupTransientRules, 3) -} diff --git a/test-infra/compose/auto.go b/test-infra/compose/auto.go deleted file mode 100644 index c3cb188b..00000000 --- a/test-infra/compose/auto.go +++ /dev/null @@ -1,332 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "context" - "fmt" - "io" - "io/fs" - "os" - "os/exec" - "time" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" -) - -type AutoConfig struct { - // Dir is the directory to use for compose artifacts. - Dir string - // AlertTimeout is the timeout to collect alerts before shutdown. Zero disables timeout. - AlertTimeout time.Duration - // SudoPerms enables changing all compose artefacts file permissions using sudo. - SudoPerms bool - // Print generated docker-compose.yml files. - PrintYML bool - // RunTmplFunc allows arbitrary overrides in the run step template. - RunTmplFunc func(*TmplData) - // DefineTmplFunc allows arbitrary overrides if the define step template. - DefineTmplFunc func(*TmplData) - // LogFile enables writing (appending) docker compose output to this file path instead of stdout. - LogFile string -} - -// Auto runs all three steps (define,lock,run) sequentially with support for detecting alerts. -func Auto(ctx context.Context, conf AutoConfig) error { - ctx = log.WithTopic(ctx, "auto") - - w, closeFunc, err := newLogWriter(conf.LogFile) - if err != nil { - return err - } - defer closeFunc() //nolint:errcheck // non-critical - - steps := []struct { - Name string - RunFunc RunFunc - TmplFunc func(*TmplData) - RunStep bool - }{ - { - Name: "define", - RunFunc: Define, - TmplFunc: conf.DefineTmplFunc, - }, { - Name: "lock", - RunFunc: Lock, - }, { - Name: "run", - RunFunc: Run, - TmplFunc: conf.RunTmplFunc, - RunStep: true, - }, - } - - for _, step := range steps { - run := NewRunnerFunc(step.Name, conf.Dir, false, step.RunFunc) - - tmpl, err := run(ctx) - if err != nil { - return err - } - - if conf.SudoPerms { - if err := fixPerms(ctx, conf.Dir); err != nil { - return err - } - } - - if step.TmplFunc != nil { - step.TmplFunc(&tmpl) - - err := WriteDockerCompose(conf.Dir, tmpl) - if err != nil { - return err - } - } - - if conf.PrintYML { - if err := printDockerCompose(ctx, conf.Dir); err != nil { - return err - } - } - - if step.RunStep { // Continue below if final run step. - break - } - - _, _ = w.Write([]byte("===== " + step.Name + " step: docker compose up =====\n")) - - if err := execUp(ctx, conf.Dir, w); err != nil { - return err - } - } - - // Ensure everything is clean before we start with alert test. - _ = execDown(ctx, conf.Dir, conf.SudoPerms) - - _, _ = w.Write([]byte("===== run step: docker compose up --no-start --build =====\n")) - - // Build and create docker compose services before executing docker compose up. - if err = execBuildAndCreate(ctx, conf.Dir); err != nil { - return err - } - - if conf.AlertTimeout > 0 { - var cancel context.CancelFunc - - ctx, cancel = context.WithTimeout(ctx, conf.AlertTimeout) - defer cancel() - } - - alerts := startAlertCollector(ctx, conf.Dir) - - defer func() { - _ = execDown(context.Background(), conf.Dir, conf.SudoPerms) - }() - - _, _ = w.Write([]byte("===== run step: docker compose up =====\n")) - - err = execUp(ctx, conf.Dir, w) - - switch { - case err == nil && conf.AlertTimeout > 0: - // `docker compose up --abort-on-container-exit` exits 0 when a container - // stops cleanly, taking the whole cluster down with it. Returning here - // before the observation window elapsed means nothing was actually - // observed, so treat it as a failure rather than reporting "no alerts - // detected" on a cluster that was not running. - return errors.New("cluster stopped before the observation window elapsed") - case err != nil && !errors.Is(err, context.DeadlineExceeded): - return err - } - - var ( - alertMsgs []string - alertSuccess bool - ) - - for alert := range alerts { - if alert == alertsPolled { - alertSuccess = true - } else { - alertMsgs = append(alertMsgs, alert) - } - } - - if !alertSuccess { - return errors.New("prometheus was not polled successfully through the end of the observation window") - } else if len(alertMsgs) > 0 { - return errors.New("alerts detected", z.Any("alerts", alertMsgs)) - } - - log.Info(ctx, "No alerts detected") - - return nil -} - -// printDockerCompose prints the docker-compose.yml file to stdout. -func printDockerCompose(ctx context.Context, dir string) error { - log.Info(ctx, "Printing docker-compose.yml") - cmd := exec.CommandContext(ctx, "cat", "docker-compose.yml") - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err := cmd.Run() - if err != nil { - return errors.Wrap(err, "exec cat docker-compose.yml") - } - - return nil -} - -// fixPerms makes the compose artefacts writable and owned by the current user, -// a workaround for linux docker: containers run as root, so the files they -// create in the compose dir are root-owned and an unprivileged CI runner cannot -// clean them up afterwards. -// -// Charon hardcodes `sudo chown -R runner:docker`, which only resolves on its -// own GitHub Actions runner — elsewhere it fails with "illegal group name". -// Using the current uid:gid works on any runner and locally. Both commands need -// sudo, so this only runs under -sudo-perms; without it a local run is never -// prompted for a password. -func fixPerms(ctx context.Context, dir string) error { - owner := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) - - for _, args := range [][]string{ - {"chown", "-R", owner, "."}, - {"chmod", "-R", "a+wrX", "."}, - } { - cmd := exec.CommandContext(ctx, "sudo", args...) - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "exec sudo "+args[0]) - } - } - - return nil -} - -// execDown executes `docker compose down`. -func execDown(ctx context.Context, dir string, sudoPerms bool) error { - // Reclaim root-owned container artefacts before teardown (charon chowns - // here too); see fixPerms for why this is gated behind -sudo-perms. - if sudoPerms { - if err := fixPerms(ctx, dir); err != nil { - return err - } - } - - log.Info(ctx, "Executing docker compose down") - - cmd := exec.CommandContext(ctx, "docker", "compose", "down", - "--remove-orphans", - "--timeout=2", - ) - cmd.Dir = dir - cmd.Stdout = os.Stdout - - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "run down") - } - - return nil -} - -// execUp executes `docker compose up` and it writes docker compose logs to the given out io.Writer. -func execUp(ctx context.Context, dir string, out io.Writer) error { - // Build first so containers start at the same time below. - log.Info(ctx, "Executing docker compose build") - cmd := exec.CommandContext(ctx, "docker", "compose", "build", "--parallel") - - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - return errors.Wrap(err, "exec docker compose build", z.Str("output", string(out))) - } - - log.Info(ctx, "Executing docker compose up") - cmd = exec.CommandContext(ctx, "docker", "compose", "up", - "--remove-orphans", - "--abort-on-container-exit", - "--quiet-pull", - ) - cmd.Dir = dir - cmd.Stdout = out - cmd.Stderr = out - - if err := cmd.Run(); err != nil { - if ctx.Err() != nil { - err = ctx.Err() - } - - return errors.Wrap(err, "exec docker compose up") - } - - return nil -} - -// execBuildAndCreate builds and creates containers. It should be called before execUp for run step. -func execBuildAndCreate(ctx context.Context, dir string) error { - log.Info(ctx, "Executing docker compose up --no-start --build") - cmd := exec.CommandContext(ctx, "docker", "compose", "up", "--no-start", "--build") - - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - return errors.Wrap(err, "exec docker compose up --no-start --build", z.Str("output", string(out))) - } - - return nil -} - -// RunFunc defines a function that generates docker-compose.yml from config and returns the template data. -type RunFunc func(context.Context, string, Config) (TmplData, error) - -// NewRunnerFunc returns a function that wraps and runs a run function. -func NewRunnerFunc(topic string, dir string, up bool, runFunc RunFunc, -) func(ctx context.Context) (data TmplData, err error) { - return func(ctx context.Context) (data TmplData, err error) { - ctx = log.WithTopic(ctx, topic) - - conf, err := LoadConfig(dir) - if errors.Is(err, fs.ErrNotExist) { - return TmplData{}, errors.New("compose config.json not found; write one with WriteConfig or New first", z.Str("dir", dir)) - } else if err != nil { - return TmplData{}, err - } - - log.Info(ctx, "Running compose command", z.Str("command", topic)) - - data, err = runFunc(ctx, dir, conf) - if err != nil { - return TmplData{}, err - } - - if up { - return data, execUp(ctx, dir, os.Stdout) - } - - return data, nil - } -} - -// newLogWriter returns io writer and a close function or an error. -func newLogWriter(logFile string) (io.WriteCloser, func() error, error) { - if logFile == "" { - return os.Stdout, func() error { return nil }, nil - } - - // Preparing log file. - file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return nil, nil, errors.Wrap(err, "open log file") - } - - return file, file.Close, nil -} diff --git a/test-infra/compose/compose_internal_test.go b/test-infra/compose/compose_internal_test.go deleted file mode 100644 index c7f3f306..00000000 --- a/test-infra/compose/compose_internal_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "bytes" - "context" - "os" - "path" - "testing" - "text/template" - - k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" - "github.com/stretchr/testify/require" - - "github.com/obolnetwork/charon/testutil" -) - -//go:generate go test . -update -clean - -func TestDockerCompose(t *testing.T) { - tests := []struct { - Name string - ConfFunc func(*Config) - RunFunc func(ctx context.Context, dir string, conf Config) (TmplData, error) - }{ - { - Name: "define dkg", - ConfFunc: func(conf *Config) { - conf.KeyGen = KeyGenDKG - }, - RunFunc: Define, - }, - { - Name: "define create", - ConfFunc: func(conf *Config) { - conf.KeyGen = KeyGenCreate - }, - RunFunc: Define, - }, - { - Name: "lock dkg", - ConfFunc: func(conf *Config) { - conf.Step = stepDefined - conf.KeyGen = KeyGenDKG - }, - RunFunc: Lock, - }, - { - Name: "lock create", - ConfFunc: func(conf *Config) { - conf.Step = stepDefined - conf.KeyGen = KeyGenCreate - }, - RunFunc: Lock, - }, - { - Name: "run", - ConfFunc: func(conf *Config) { - conf.NumValidators = 2 - conf.Step = stepLocked - }, - RunFunc: Run, - }, - { - Name: "lock dkg mixed impls", - ConfFunc: func(conf *Config) { - conf.Step = stepDefined - conf.KeyGen = KeyGenDKG - conf.NodeImpls = []NodeImpl{ImplCharon, ImplPluto} - }, - RunFunc: Lock, - }, - { - Name: "run mixed impls", - ConfFunc: func(conf *Config) { - conf.NumValidators = 2 - conf.Step = stepLocked - conf.NodeImpls = []NodeImpl{ImplCharon, ImplCharon, ImplPluto, ImplPluto} - }, - RunFunc: Run, - }, - { - Name: "lock create pluto keygen", - ConfFunc: func(conf *Config) { - conf.Step = stepDefined - conf.KeyGen = KeyGenCreate - conf.KeyGenImpl = ImplPluto - }, - RunFunc: Lock, - }, - } - - const seed = 0 - - keyGenFunc = func() (*k1.PrivateKey, error) { - return testutil.GenerateInsecureK1Key(t, seed), nil - } - noPull = true - - for _, test := range tests { - t.Run(test.Name, func(t *testing.T) { - dir := t.TempDir() - - conf := NewDefaultConfig() - if test.ConfFunc != nil { - test.ConfFunc(&conf) - } - - data, err := test.RunFunc(context.Background(), dir, conf) - require.NoError(t, err) - - t.Run("yml", func(t *testing.T) { - b, err := os.ReadFile(path.Join(dir, "docker-compose.yml")) - require.NoError(t, err) - - b = bytes.ReplaceAll(b, []byte(dir), []byte("testdir")) - testutil.RequireGoldenBytes(t, b) - }) - - t.Run("template", func(t *testing.T) { - data.ComposeDir = "testdir" - testutil.RequireGoldenJSON(t, data) - }) - }) - } -} - -func TestParseTemplate(t *testing.T) { - _, err := template.New("").Parse(string(tmpl)) - require.NoError(t, err) - - _, err = getVC(VCTeku, 0, 1, false, true) - require.NoError(t, err) -} diff --git a/test-infra/compose/config.go b/test-infra/compose/config.go deleted file mode 100644 index c41c782b..00000000 --- a/test-infra/compose/config.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "fmt" - "time" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/z" -) - -const ( - version = "obol/charon/compose/1.0.0" - configFile = "config.json" - defaultImageTag = "latest" - defaultBeaconNode = "mock" - defaultKeyGen = KeyGenCreate - defaultNumVals = 1 - defaultNumNodes = 4 - defaultThreshold = 3 - defaultFeatureSet = "alpha" - - charonImage = "obolnetwork/charon" - plutoImage = "pluto" - cmdRun = "run" - cmdUnsafeRun = "[unsafe,run]" - // cmdDKG delays shutdown after completion to allow other nodes to finish. - // Uses a flag instead of charon's `sh -c '... && sleep 2'` since the pluto image is distroless (no shell). - cmdDKG = "[dkg,--shutdown-delay=2s]" - cmdCreateCluster = "[create,cluster]" - cmdCreateDKG = "[create,dkg]" -) - -var charonPorts = []port{ - {External: 3600, Internal: 3600}, // # Validator API - {External: 3610, Internal: 3610}, // # Libp2p - {External: 3620, Internal: 3620}, // # Monitoring - {External: 3630, Internal: 3630}, // # Discv5 -} - -// VCType defines a validator client type. -type VCType string - -const ( - VCMock VCType = "mock" - VCTeku VCType = "teku" - VCLighthouse VCType = "lighthouse" - VCVouch VCType = "vouch" - VCLodestar VCType = "lodestar" -) - -// KeyGen defines a key generation process. -type KeyGen string - -const ( - KeyGenDKG KeyGen = "dkg" - KeyGenCreate KeyGen = "create" -) - -// NodeImpl defines the implementation (charon or pluto) running a node. -type NodeImpl string - -const ( - ImplCharon NodeImpl = "charon" - ImplPluto NodeImpl = "pluto" -) - -// step defines the current completed compose step. -type step string - -const ( - stepNew step = "new" - stepDefined step = "defined" - stepLocked step = "locked" -) - -// Config defines a local compose cluster; including both keygen and running a cluster. -type Config struct { - // Version defines the compose config version. - Version string `json:"version"` - - // Step defines the current completed compose step. - Step step `json:"step"` - - // NumNodes is the number of charon nodes in the cluster. - NumNodes int `json:"num_nodes"` - - // Threshold required for signature reconstruction. Defaults to safe value for number of nodes/peers. - Threshold int `json:"threshold"` - - // NumValidators is the number of DVs to be created in the cluster lock file. - NumValidators int `json:"num_validators"` - - // ImageTag defines the charon docker image tag: obolnetwork/charon:{ImageTag}. - ImageTag string `json:"image_tag"` - - // BuildLocal enables building a local charon docker container from source overriding ImageTag with 'local'. - BuildLocal bool `json:"build_local"` - - // NodeImpls defines the implementation (charon or pluto) of each node. - // Nodes are assigned round-robin like VCs; node{i} runs NodeImpls[i%len(NodeImpls)]. - // Empty defaults to all charon. - NodeImpls []NodeImpl `json:"node_impls"` - - // KeyGenImpl defines the implementation running single-container keygen steps - // (`create cluster` and `create dkg`). Empty defaults to the implementation of node0. - KeyGenImpl NodeImpl `json:"keygen_impl"` - - // PlutoImageTag defines the pluto docker image tag: pluto:{PlutoImageTag}. - // The image is built from source (PLUTO_REPO env var) by the define step when a pluto impl is used. - PlutoImageTag string `json:"pluto_image_tag"` - - // KeyGen defines the key generation process. - KeyGen KeyGen `json:"key_gen"` - - // SplitKeysDir directory containing keys to split for keygen==create. - SplitKeysDir string `json:"split_keys_dir"` - - // BeaconNodes url endpoint or "mock" for simnet. - BeaconNodes string `json:"beacon_nodes"` - - // ExternalRelay HTTP url endpoint or empty to disable. - ExternalRelay string `json:"external_relay"` - - // VCs define the types of validator clients to use. - VCs []VCType `json:"validator_clients"` - - // FeatureSet defines the minimum feature set to enable. - FeatureSet string `json:"feature_set"` - - // DisableMonitoringPorts defines whether to disable prometheus and jaeger monitoring port binding. - DisableMonitoringPorts bool `json:"disable_monitoring_ports"` - - // InsecureKeys generates insecure keys. Useful when testing large validator sets - // as it speeds up keystore encryption and decryption. - InsecureKeys bool `json:"insecure_keys"` - - // SlotDuration configures slot duration on simnet beacon mock for all the nodes in the cluster. - SlotDuration time.Duration `json:"slot_duration"` - - // BeaconFuzz configures simnet beaconmock to return fuzzed responses. - BeaconFuzz bool `json:"beacon-fuzz"` - - // P2PFuzz configures charon p2p network to send and receive fuzzed messages. - P2PFuzz bool `json:"p2p-fuzz"` - - // SyntheticBlockProposals configures use of synthetic block proposals in simnet cluster. - SyntheticBlockProposals bool `json:"synthetic_block_proposals"` - - // Monitoring enables monitoring stack for the compose cluster. It includes grafana, loki and jaeger services. - Monitoring bool `json:"monitoring"` - - // BuilderAPI enables the builder API for the compose cluster. - BuilderAPI bool `json:"builder_api"` - - // AlertExcludeJobs exempts prometheus jobs (nodes) from the per-node - // behavioral alert rules (log rates, validator API rates, broadcast - // liveness) — the "Pluto Down" scrape check still applies. Used by smoke - // scenarios that deliberately degrade a node (e.g. 1_of_4_down isolates - // node0 from the p2p network): the degraded node is expected to log - // errors and stop broadcasting, while the rest of the cluster must stay - // clean. - AlertExcludeJobs []string `json:"alert_exclude_jobs,omitempty"` - - // AlertDisableRules drops entire alert rules (by name, see - // alertRuleNames) from the generated rules. Last-resort scenario knob - // for cluster-wide degradation that per-job exclusion cannot express: - // e.g. 1_of_3_down disables the error-rate gates because every third - // epoch-boundary proposer duty is round-1-led by the downed node and - // charon v1.7.1 cannot recover it (linear-timer bug #4537 plus the - // 1s-slot proposer deadline), so the HEALTHY nodes log the collateral - // consensus timeouts. - AlertDisableRules []string `json:"alert_disable_rules,omitempty"` -} - -// Validate rejects configs with unknown implementation names. It runs on -// every config write and load so a typo (e.g. --node-impls=plutoo) fails -// fast instead of silently selecting the charon image. -func (c Config) Validate() error { - validImpl := func(impl NodeImpl) bool { - return impl == ImplCharon || impl == ImplPluto - } - - for i, impl := range c.NodeImpls { - if !validImpl(impl) { - return errors.New("unknown node implementation; must be charon or pluto", - z.Str("impl", string(impl)), z.Int("index", i)) - } - } - - // Empty defaults to node0's implementation. - if c.KeyGenImpl != "" && !validImpl(c.KeyGenImpl) { - return errors.New("unknown keygen implementation; must be charon or pluto", - z.Str("impl", string(c.KeyGenImpl))) - } - - for _, rule := range c.AlertDisableRules { - if !alertRuleNames[rule] { - return errors.New("unknown alert rule name in alert_disable_rules", - z.Str("rule", rule)) - } - } - - return nil -} - -// VCStrings returns the VCs field as a slice of strings. -func (c Config) VCStrings() []string { - var resp []string - for _, vc := range c.VCs { - resp = append(resp, string(vc)) - } - - return resp -} - -// NodeImpl returns the implementation of node{index}, assigned round-robin like VCs. -func (c Config) NodeImpl(index int) NodeImpl { - if len(c.NodeImpls) == 0 { - return ImplCharon - } - - return c.NodeImpls[index%len(c.NodeImpls)] -} - -// KeygenImpl returns the implementation running single-container keygen steps. -func (c Config) KeygenImpl() NodeImpl { - if c.KeyGenImpl != "" { - return c.KeyGenImpl - } - - return c.NodeImpl(0) -} - -// ImplImage returns the full docker image reference for the provided implementation. -func (c Config) ImplImage(impl NodeImpl) string { - switch impl { - case ImplPluto: - return plutoImage + ":" + c.PlutoImageTag - case ImplCharon: - return charonImage + ":" + c.ImageTag - default: - // Impls are validated on config write and load (Validate); reaching - // here means a code path bypassed that boundary. - panic(fmt.Sprintf("bug: unvalidated node implementation %q", impl)) - } -} - -// ImageOverride returns the per-node image override for the provided implementation, -// or empty to use the default charon node-base image. -func (c Config) ImageOverride(impl NodeImpl) string { - if impl == ImplPluto { - return c.ImplImage(impl) - } - - return "" -} - -// UsesPluto returns true if any node or keygen step runs pluto. -func (c Config) UsesPluto() bool { - if c.KeygenImpl() == ImplPluto { - return true - } - - for i := range c.NumNodes { - if c.NodeImpl(i) == ImplPluto { - return true - } - } - - return false -} - -// NewDefaultConfig returns a new default config. -func NewDefaultConfig() Config { - return Config{ - Version: version, - NumNodes: defaultNumNodes, - Threshold: defaultThreshold, - NumValidators: defaultNumVals, - ImageTag: defaultImageTag, - NodeImpls: []NodeImpl{ImplCharon}, - PlutoImageTag: "local", - VCs: []VCType{VCLighthouse, VCLighthouse, VCMock}, - KeyGen: defaultKeyGen, - BeaconNodes: defaultBeaconNode, - Step: stepNew, - FeatureSet: defaultFeatureSet, - SlotDuration: time.Second, - SyntheticBlockProposals: true, - Monitoring: true, - } -} diff --git a/test-infra/compose/define.go b/test-infra/compose/define.go deleted file mode 100644 index 0836687d..00000000 --- a/test-infra/compose/define.go +++ /dev/null @@ -1,629 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path" - "path/filepath" - "strconv" - "strings" - - k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/k1util" - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" - "github.com/obolnetwork/charon/eth2util" - "github.com/obolnetwork/charon/eth2util/enr" -) - -// zeroAddress is not owned by any user, is often associated with token burn & mint/genesis events and used as a generic null address. -// See https://etherscan.io/address/0x0000000000000000000000000000000000000000. -const zeroAddress = `"0x0000000000000000000000000000000000000000"` - -// Clean deletes all compose directory files and artifacts. -func Clean(ctx context.Context, dir string) error { - ctx = log.WithTopic(ctx, "clean") - - files, err := filepath.Glob(path.Join(dir, "*")) - if err != nil { - return errors.Wrap(err, "glob dir") - } - - // Make sure we ONLY delete compose artifacts. - var ( - configFound bool - goFound bool - ) - - for _, file := range files { - if file == configFile { - configFound = true - } else if strings.HasSuffix(file, ".go") || strings.HasPrefix(file, "go.") { - goFound = true - } - } - - if !configFound { - log.Info(ctx, "Not cleaning since config.json not found") - return nil - } else if goFound { - return errors.New("go files found, compose dir incorrect", z.Str("dir", dir)) - } - - log.Info(ctx, "Cleaning compose dir", z.Int("files", len(files))) - - for _, file := range files { - if strings.Contains(file, "key") { - // Do not delete root folder with key in the name, since it might be long-lived split keys folder. - log.Info(ctx, "Not deleting *key* folder", z.Str("path", file)) - continue - } - - if err := os.RemoveAll(file); err != nil { - return errors.Wrap(err, "remove file") - } - } - - return nil -} - -// noPull allows disabling pulling during unit tests. -var noPull bool - -// Define defines a compose cluster; including both keygen and running definitions. -func Define(ctx context.Context, dir string, conf Config) (TmplData, error) { - if conf.Step != stepNew { - return TmplData{}, errors.New("compose config not new, so can't be defined", z.Any("step", conf.Step)) - } - - if conf.BuildLocal { - if err := BuildLocal(ctx); err != nil { - return TmplData{}, err - } - } - - if !noPull && !conf.BuildLocal && conf.ImageTag == "latest" { - if err := pullLatest(ctx); err != nil { - return TmplData{}, err - } - } - - if !noPull && conf.UsesPluto() && conf.PlutoImageTag == "local" { - if err := BuildLocalPluto(ctx); err != nil { - return TmplData{}, err - } - } - - if conf.SplitKeysDir != "" { - if err := validateSplitKeysDir(dir, conf.SplitKeysDir); err != nil { - return TmplData{}, err - } - } - - var data TmplData - - if conf.KeyGen == KeyGenDKG { - log.Info(ctx, "Creating node*/charon-enr-private-key for ENRs required for charon create dkg") - - // charon create dkg requires operator ENRs, so we need to create p2pkeys now. - p2pkeys, err := newP2PKeys(conf.NumNodes) - if err != nil { - return TmplData{}, err - } - - var enrs []string - - for i, key := range p2pkeys { - // Best effort creation of folder, rather fail when saving p2pkey file next. - _ = os.MkdirAll(nodeFile(dir, i, ""), 0o755) - - err := k1util.Save(key, nodeFile(dir, i, "charon-enr-private-key")) - if err != nil { - return TmplData{}, errors.Wrap(err, "save charon-enr-private-key") - } - - record, err := enr.New(key) - if err != nil { - return TmplData{}, err - } - - enrs = append(enrs, record.String()) - } - - kvs := []kv{ - {"name", "compose"}, - {"num_validators", strconv.Itoa(conf.NumValidators)}, - {"operator_enrs", strings.Join(enrs, ",")}, - {"threshold", strconv.Itoa(conf.Threshold)}, - {"withdrawal_addresses", zeroAddress}, - {"fee-recipient_addresses", zeroAddress}, - {"dkg_algorithm", "frost"}, - {"output_dir", "/compose"}, - {"network", eth2util.Goerli.Name}, - } - - n := TmplNode{Image: conf.ImageOverride(conf.KeygenImpl()), EnvVars: kvs} - - data = TmplData{ - ComposeDir: dir, - CharonImageTag: conf.ImageTag, - CharonCommand: cmdCreateDKG, - Nodes: []TmplNode{n}, - } - } else { - // Other keygens only need a noop docker compose, since charon-compose.yml - // is used directly in their compose lock. - data = TmplData{ - ComposeDir: dir, - CharonImageTag: conf.ImageTag, - CharonEntrypoint: "echo", - CharonCommand: fmt.Sprintf("No charon commands needed for keygen=%s define step", conf.KeyGen), - Nodes: []TmplNode{{}}, - } - } - - log.Info(ctx, "Creating config.json") - - conf.Step = stepDefined - if err := WriteConfig(dir, conf); err != nil { - return TmplData{}, err - } - - if err := copyStaticFolders(dir); err != nil { - return TmplData{}, err - } - - if err := writePrometheusConfig(dir, conf); err != nil { - return TmplData{}, err - } - - if err := writeAlertRules(dir, conf); err != nil { - return TmplData{}, err - } - - log.Info(ctx, "Creating docker-compose.yml") - log.Info(ctx, "Create cluster definition: docker compose up") - - if err := WriteDockerCompose(dir, data); err != nil { - return TmplData{}, err - } - - return data, nil -} - -// validateSplitKeysDir returns an error if the split keys dir is not a child of dir. -func validateSplitKeysDir(dir string, spitKeysDir string) error { - rel, err := getRelSplitKeysDir(dir, spitKeysDir) - if err != nil { - return err - } else if strings.HasPrefix(rel, "..") { - return errors.New("split-keys-dir must be a child of compose dir", z.Str("relative", rel)) - } - - return nil -} - -// getRelSplitKeysDir returns the splitKeysDir as a relative path to dir. -func getRelSplitKeysDir(dir, splitKeysDir string) (string, error) { - if splitKeysDir == "" { - return "", nil - } - - dir, err := filepath.Abs(dir) - if err != nil { - return "", errors.Wrap(err, "abs dir") - } - - splitKeysDir, err = filepath.Abs(splitKeysDir) - if err != nil { - return "", errors.Wrap(err, "abs dir") - } - - rel, err := filepath.Rel(dir, splitKeysDir) - if err != nil { - return "", errors.Wrap(err, "relative split keys dir") - } - - return rel, nil -} - -// pullLatest pulls the latest charon docker image. -func pullLatest(ctx context.Context) error { - log.Info(ctx, "Pulling latest charon docker image") - - cmd := exec.CommandContext(ctx, "docker", "pull", charonImage+":latest") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "run docker pull") - } - - return nil -} - -// BuildLocal builds an `obolnetwork/charon:local` docker container from source. Note this requires CHARON_REPO env var. -func BuildLocal(ctx context.Context) error { - repo, ok := os.LookupEnv("CHARON_REPO") - if !ok || repo == "" { - return errors.New("cannot build local charon binary; CHARON_REPO env var, the path to the charon repo, is not set") - } - - log.Info(ctx, "Building `obolnetwork/charon:local` docker container", z.Str("repo", repo)) - - var out bytes.Buffer // Only log output if there is an error. - - cmd := exec.CommandContext(ctx, "docker", "build", "-t", "obolnetwork/charon:local", ".") - cmd.Stdout = &out - cmd.Stderr = &out - cmd.Dir = repo - - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "exec docker build", z.Str("output", out.String())) - } - - return nil -} - -// BuildLocalPluto builds a `pluto:local` docker container from source. Note this requires PLUTO_REPO env var. -func BuildLocalPluto(ctx context.Context) error { - repo, ok := os.LookupEnv("PLUTO_REPO") - if !ok || repo == "" { - return errors.New("cannot build local pluto binary; PLUTO_REPO env var, the path to the pluto repo, is not set") - } - - log.Info(ctx, "Building `pluto:local` docker container", z.Str("repo", repo)) - - args := []string{"build", "-t", "pluto:local"} - - // Bake the git hash into the image: peers exchange it over peerinfo and - // warn about an empty/unparseable hash ("Invalid peer git hash"). - if hash, err := gitCommitHashShort(ctx, repo); err == nil { - args = append(args, "--build-arg", "GIT_COMMIT_HASH_SHORT="+hash) - } - - args = append(args, ".") - - var out bytes.Buffer // Only log output if there is an error. - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = &out - cmd.Stderr = &out - cmd.Dir = repo - - if err := cmd.Run(); err != nil { - return errors.Wrap(err, "exec docker build", z.Str("output", out.String())) - } - - return nil -} - -// gitCommitHashShort returns the repo's short (7 char) commit hash. -func gitCommitHashShort(ctx context.Context, repo string) (string, error) { - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--short=7", "HEAD") - cmd.Dir = repo - - out, err := cmd.Output() - if err != nil { - return "", errors.Wrap(err, "git rev-parse") - } - - return strings.TrimSpace(string(out)), nil -} - -// copyStaticFolders copies the embedded static folders to the compose dir. -func copyStaticFolders(dir string) error { - const staticRoot = "static" - - dirs, err := static.ReadDir(staticRoot) - if err != nil { - return errors.Wrap(err, "read dirs") - } - - for _, d := range dirs { - if !d.IsDir() { - return errors.New("static files not supported") - } - - if err := os.MkdirAll(path.Join(dir, d.Name()), 0o755); err != nil { - return errors.Wrap(err, "mkdir all") - } - - files, err := static.ReadDir(path.Join(staticRoot, d.Name())) - if err != nil { - return errors.Wrap(err, "read files") - } - - for _, f := range files { - if f.IsDir() { - return errors.New("child static dirs not supported") - } - - b, err := static.ReadFile(path.Join(staticRoot, d.Name(), f.Name())) - if err != nil { - return errors.Wrap(err, "read file") - } - - var mode os.FileMode = 0o644 - if strings.HasSuffix(f.Name(), ".sh") { - mode = 0o755 - } - - if err := os.WriteFile(path.Join(dir, d.Name(), f.Name()), b, mode); err != nil { - return errors.Wrap(err, "write file") - } - } - } - - return nil -} - -// writePrometheusConfig writes prometheus scrape configs for the actual -// cluster size, replacing the static 4-node default copied from static/. -// Unlike charon's static config, this scrapes the relay (not a non-existent -// "bootnode") and covers all NumNodes so the `up == 0` alert works. -func writePrometheusConfig(dir string, conf Config) error { - var b strings.Builder - - b.WriteString(`global: - scrape_interval: 5s - evaluation_interval: 5s - -scrape_configs: - - job_name: 'relay' - static_configs: - - targets: [ 'relay:3620' ] -`) - - for i := range conf.NumNodes { - fmt.Fprintf(&b, ` - job_name: 'node%d' - static_configs: - - targets: ['node%d:3620'] -`, i, i) - } - - b.WriteString(` -rule_files: - - /etc/prometheus/rules.yml -`) - - if err := os.MkdirAll(path.Join(dir, "prometheus"), 0o755); err != nil { - return errors.Wrap(err, "mkdir prometheus") - } - - err := os.WriteFile(path.Join(dir, "prometheus", "prometheus.yml"), []byte(b.String()), 0o644) //nolint:gosec - if err != nil { - return errors.Wrap(err, "write prometheus.yml") - } - - return nil -} - -// Canonical alert rule names: generated by writeAlertRules, validated -// against Config.AlertDisableRules, and referenced by the alert collector's -// warmup allowlist. -const ( - plutoDownRule = "Pluto Down" - errorRateRule = "Error Log Rate" - warnRateRule = "Warn Log Rate" - vapiRateRule = "Validator API Error Rate" - proxyRateRule = "Proxy API Error Rate" - broadcastRule = "Broadcast Duty Rate" -) - -// alertRuleNames is the set of valid rule names for Config.AlertDisableRules. -var alertRuleNames = map[string]bool{ - plutoDownRule: true, - errorRateRule: true, - warnRateRule: true, - vapiRateRule: true, - proxyRateRule: true, - broadcastRule: true, -} - -// writeAlertRules writes the prometheus alert rules evaluated by the smoke -// tests. Rules are generated (not static) because the expressions depend on -// config: scenarios that deliberately degrade a node exempt its job via -// conf.AlertExcludeJobs, and cluster-wide degradations drop whole rules via -// conf.AlertDisableRules. -// -// Charon's "Outstanding Duty Rate" rule (core_bcast_broadcast_total - -// core_scheduler_duty_total > 50) is deliberately not ported: a node cannot -// broadcast a duty more often than it is scheduled, and the two counters -// only share a subset of duty label values (the rest drop out of the vector -// match), so the expression can never exceed zero — the rule is dead -// upstream too. -func writeAlertRules(dir string, conf Config) error { - // Exclusion matcher for per-node behavioral rules; empty when no node is - // exempted. "Pluto Down" (up == 0) is never exempted: a degraded node - // must still be scrapable. - var jobExcl string - if len(conf.AlertExcludeJobs) > 0 { - jobExcl = fmt.Sprintf(`job!~"%s"`, strings.Join(conf.AlertExcludeJobs, "|")) - } - - // sel renders a PromQL label-matcher block from the non-empty matchers. - sel := func(matchers ...string) string { - var parts []string - for _, m := range matchers { - if m != "" { - parts = append(parts, m) - } - } - - if len(parts) == 0 { - return "" - } - - return "{" + strings.Join(parts, ",") + "}" - } - - // Warn Log Rate always excludes charon v1.7.1 topics that warn - // structurally in any healthy simnet cluster (verified in the all-charon - // `dkg` scenario): - // - vmock: the in-process validatormock schedules DutyBuilderRegistration - // every epoch (~16 duties per epoch start) with no handler, so every - // VCMock node warns "Duty failed: unexpected duty" in bursts. - // - tracker: the beaconmock never includes broadcast duties on-chain, so - // every successful proposal epoch warns "Broadcasted block/attestation - // never included on-chain" (the better the cluster works, the more it - // warns). - // Both are mock artifacts, not node behavior; every other warn topic stays - // gated. - const warnTopics = "vmock|tracker" - - // The broadcast-liveness expression must fail when a node exposes NO - // core_bcast_broadcast_total series at all: the counter is created on - // first broadcast, so a node that never broadcasts has no series and a - // plain `increase(...) < 0.5` can never fire for it. Inject a 0 for - // every scraped node job (`0 * up`) so absent series alert too. Summed - // per job because the per-duty sync_message series legitimately pauses 6 - // of every 8 epochs (simnet sync-committee membership window). Scoped to - // node jobs: the relay never broadcasts duties. - bcastSel := sel(`job=~"node[0-9]+"`, jobExcl) - - errorSel := sel(jobExcl) - warnSel := sel(fmt.Sprintf(`topic!~"%s"`, warnTopics), jobExcl) - vapiSel := sel(`endpoint!="proxy"`, jobExcl) - proxySel := sel(`endpoint="proxy"`, jobExcl) - - // Blocks keyed by rule name so conf.AlertDisableRules can drop whole - // rules; the names double as the collector's warmup allowlist keys. - ruleBlocks := []struct { - name string - block string - }{ - {plutoDownRule, ` - alert: Pluto Down - expr: up == 0 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} is down" -`}, - // Windowed instead of charon's absolute app_log_error_total > 0: a - // fresh simnet cluster loses the first epoch-boundary proposer - // consensus (vmock 2-slot startup delay -> no randao yet), logging - // exactly one consensus timeout ERROR per node on charon and pluto - // alike. An absolute counter gate can never recover from that - // cold-start artifact; a 30s window plus the collector warmup - // (compose/alert.go) gates steady-state errors only. - {errorRateRule, fmt.Sprintf(` - alert: Error Log Rate - expr: increase(app_log_error_total%s[30s]) > 0 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} has a high error rate" -`, errorSel)}, - {warnRateRule, fmt.Sprintf(` - alert: Warn Log Rate - expr: increase(app_log_warn_total%s[30s]) > 2 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} has a high warning rate" -`, warnSel)}, - {vapiRateRule, fmt.Sprintf(` - alert: Validator API Error Rate - expr: increase(core_validatorapi_request_error_total%s[30s]) > 1 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} validator API a high error rate" -`, vapiSel)}, - {proxyRateRule, fmt.Sprintf(` - alert: Proxy API Error Rate - expr: increase(core_validatorapi_request_error_total%s[30s]) > 5 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} proxy API a high error rate" -`, proxySel)}, - {broadcastRule, fmt.Sprintf(` - alert: Broadcast Duty Rate - expr: (sum by (job) (increase(core_bcast_broadcast_total%[1]s[30s])) or on (job) max by (job) (0 * up%[1]s)) < 0.5 - for: 15s - annotations: - description: "Pluto {{ $labels.job }} is not broadcasting enough duties" -`, bcastSel)}, - } - - disabled := make(map[string]bool) - for _, rule := range conf.AlertDisableRules { - disabled[rule] = true - } - - var b strings.Builder - - b.WriteString("groups:\n- name: pluto\n rules:\n") - - for _, rule := range ruleBlocks { - if disabled[rule.name] { - continue - } - - b.WriteString(rule.block) - b.WriteString("\n") - } - - rules := strings.TrimSuffix(b.String(), "\n") - - if err := os.MkdirAll(path.Join(dir, "prometheus"), 0o755); err != nil { - return errors.Wrap(err, "mkdir prometheus") - } - - err := os.WriteFile(path.Join(dir, "prometheus", "rules.yml"), []byte(rules), 0o644) //nolint:gosec - if err != nil { - return errors.Wrap(err, "write rules.yml") - } - - return nil -} - -// keyGenFunc can be overridden in tests for deterministic p2pkeys. -var keyGenFunc = func() (*k1.PrivateKey, error) { - privkey, err := k1.GeneratePrivateKey() - if err != nil { - return nil, errors.Wrap(err, "new priv key") - } - - return privkey, nil -} - -// newP2PKeys returns a slice of newly generated secp256k1 private keys. -func newP2PKeys(n int) ([]*k1.PrivateKey, error) { - var resp []*k1.PrivateKey - - for range n { - key, err := keyGenFunc() - if err != nil { - return nil, errors.Wrap(err, "new key") - } - - resp = append(resp, key) - } - - return resp, nil -} - -// nodeFile returns the path to a file in a node folder. -func nodeFile(dir string, i int, file string) string { - return path.Join(dir, fmt.Sprintf("node%d", i), file) -} - -// WriteConfig writes the config as yaml to disk. -func WriteConfig(dir string, conf Config) error { - if err := conf.Validate(); err != nil { - return err - } - - b, err := json.MarshalIndent(conf, "", " ") - if err != nil { - return errors.Wrap(err, "marshal config") - } - - err = os.WriteFile(path.Join(dir, configFile), b, 0o755) //nolint:gosec - if err != nil { - return errors.Wrap(err, "write config") - } - - return nil -} diff --git a/test-infra/compose/docker-compose.template b/test-infra/compose/docker-compose.template deleted file mode 100644 index 58f0a25a..00000000 --- a/test-infra/compose/docker-compose.template +++ /dev/null @@ -1,129 +0,0 @@ -x-node-base: &node-base - image: obolnetwork/charon:{{.CharonImageTag}} - {{if .CharonEntrypoint }}entrypoint: {{.CharonEntrypoint}} - {{end -}} - command: {{.CharonCommand}} - networks: [compose] - volumes: [{{.ComposeDir}}:/compose] - {{if .Relay }}depends_on: [relay]{{end}} - -services: - {{- range $i, $node := .Nodes}} - node{{$i}}: - <<: *node-base - container_name: node{{$i}} - {{if .Image}}image: {{.Image}} - {{end -}} - {{if .Entrypoint}}entrypoint: {{.Entrypoint}} - {{end -}} - {{if .Command}}command: {{.Command}} - {{end -}} - {{- if .EnvVars}} - environment: - {{- range $node.EnvVars}} - CHARON_{{.EnvKey}}: {{.Value}} - {{- end}} - {{end -}} - {{if .Ports}} - ports: - {{- range $node.Ports}} - - "{{.External}}:{{.Internal}}" - {{end -}} - {{end -}} - {{end -}} - - {{- if .Relay }} - relay: - <<: *node-base - container_name: relay - command: relay - depends_on: [] - environment: - CHARON_HTTP_ADDRESS: 0.0.0.0:3640 - CHARON_MONITORING_ADDRESS: 0.0.0.0:3620 - CHARON_DATA_DIR: /compose/relay - CHARON_P2P_RELAYS: "" - CHARON_P2P_EXTERNAL_HOSTNAME: relay - CHARON_P2P_TCP_ADDRESS: 0.0.0.0:3610 - CHARON_P2P_UDP_ADDRESS: 0.0.0.0:3630 - CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true" - CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push - {{end -}} - - {{- range $i, $vc := .VCs}} - {{- if $vc.Label}} - vc{{$i}}-{{$vc.Label}}: - container_name: vc{{$i}}-{{$vc.Label}} - {{if $vc.Build}}build: {{$vc.Build}} - {{end -}} - {{if $vc.Image}}image: {{$vc.Image}} - {{end -}} - {{if $vc.Command}}command: {{$vc.Command}} - {{end -}} - networks: [compose] - depends_on: [node{{$i}}] - environment: - NODE: node{{$i}} - volumes: - - .:/compose - {{end -}} - {{end -}} - - {{if .Alerting}} - curl: - container_name: curl - # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert - image: curlimages/curl:latest - command: sleep 1d - networks: [compose] - - prometheus: - container_name: prometheus - image: prom/prometheus:${PROMETHEUS_VERSION:-v2.50.1} - {{if .MonitoringPorts}}ports: - - "9090:9090" - {{end -}} - networks: [compose] - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - ./prometheus/rules.yml:/etc/prometheus/rules.yml - {{end}} - - {{if .Monitoring}} - grafana: - container_name: grafana - image: grafana/grafana:${GRAFANA_VERSION:-10.4.2} - {{if .MonitoringPorts}}ports: - - "3000:3000" - {{end -}} - networks: [compose] - volumes: - - ./grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml - - ./grafana/dashboards.yml:/etc/grafana/provisioning/dashboards/datasource.yml - - ./grafana/notifiers.yml:/etc/grafana/provisioning/notifiers/notifiers.yml - - ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro - - ./grafana/dash_charon_overview.json:/etc/dashboards/dash_charon_overview.json - - ./grafana/dash_duty_details.json:/etc/dashboards/dash_duty_details.json - - ./grafana/dash_alerts.json:/etc/dashboards/dash_alerts.json - - tempo: - container_name: tempo - image: grafana/tempo:${TEMPO_VERSION:-2.7.1} - networks: [compose] - user: ":" - command: -config.file=/opt/tempo/tempo.yaml - volumes: - - ./tempo:/opt/tempo - - loki: - container_name: loki - image: grafana/loki:${LOKI_VERSION:-2.8.2} - networks: [compose] - user: ":" - command: -config.file=/opt/loki/loki.yml - volumes: - - ./loki:/opt/loki - {{end}} - -networks: - compose: diff --git a/test-infra/compose/go.mod b/test-infra/compose/go.mod deleted file mode 100644 index a10d76fc..00000000 --- a/test-infra/compose/go.mod +++ /dev/null @@ -1,184 +0,0 @@ -module github.com/NethermindEth/pluto/test-infra/compose - -go 1.25 - -require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 - github.com/obolnetwork/charon v1.7.1 - github.com/spf13/cobra v1.10.1 - github.com/spf13/pflag v1.0.10 - github.com/stretchr/testify v1.11.1 -) - -require ( - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/attestantio/go-eth2-client v0.27.1 // indirect - github.com/benbjohnson/clock v1.3.5 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.22.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/consensys/gnark-crypto v0.18.0 // indirect - github.com/containerd/cgroups v1.1.0 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect - github.com/deckarep/golang-set/v2 v2.8.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/elastic/gosigar v0.14.3 // indirect - github.com/emicklei/dot v1.8.0 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.3 // indirect - github.com/ethereum/go-ethereum v1.16.4 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect - github.com/ferranbt/fastssz v1.0.0 // indirect - github.com/flynn/noise v1.1.0 // indirect - github.com/francoispqt/gojay v1.2.13 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/goccy/go-yaml v1.17.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/snappy v1.0.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/gopacket v1.1.19 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect - github.com/herumi/bls-eth-go-binary v1.36.4 // indirect - github.com/holiman/uint256 v1.3.2 // indirect - github.com/huandu/go-clone v1.7.2 // indirect - github.com/huin/goupnp v1.3.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/ipfs/go-cid v0.5.0 // indirect - github.com/ipfs/go-log/v2 v2.8.1 // indirect - github.com/jackpal/go-nat-pmp v1.0.2 // indirect - github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/jsternberg/zap-logfmt v1.3.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/koron/go-ssdp v0.0.5 // indirect - github.com/libp2p/go-buffer-pool v0.1.0 // indirect - github.com/libp2p/go-flow-metrics v0.2.0 // indirect - github.com/libp2p/go-libp2p v0.41.1 // indirect - github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect - github.com/libp2p/go-msgio v0.3.0 // indirect - github.com/libp2p/go-netroute v0.2.2 // indirect - github.com/libp2p/go-reuseport v0.4.0 // indirect - github.com/libp2p/go-yamux/v5 v5.0.0 // indirect - github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/miekg/dns v1.1.64 // indirect - github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect - github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect - github.com/minio/sha256-simd v1.0.1 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect - github.com/multiformats/go-base32 v0.1.0 // indirect - github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multiaddr v0.16.1 // indirect - github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect - github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect - github.com/multiformats/go-multibase v0.2.0 // indirect - github.com/multiformats/go-multicodec v0.9.0 // indirect - github.com/multiformats/go-multihash v0.2.3 // indirect - github.com/multiformats/go-multistream v0.6.0 // indirect - github.com/multiformats/go-varint v0.0.7 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.23.3 // indirect - github.com/opencontainers/runtime-spec v1.2.1 // indirect - github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect - github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v2 v2.2.12 // indirect - github.com/pion/dtls/v3 v3.0.6 // indirect - github.com/pion/ice/v4 v4.0.9 // indirect - github.com/pion/interceptor v0.1.39 // indirect - github.com/pion/logging v0.2.3 // indirect - github.com/pion/mdns/v2 v2.0.7 // indirect - github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.18 // indirect - github.com/pion/sctp v1.8.37 // indirect - github.com/pion/sdp/v3 v3.0.11 // indirect - github.com/pion/srtp/v3 v3.0.4 // indirect - github.com/pion/stun v0.6.1 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect - github.com/pion/transport/v2 v2.2.10 // indirect - github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect - github.com/pion/webrtc/v4 v4.0.14 // indirect - github.com/pk910/dynamic-ssz v0.0.6 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/protolambda/eth2-shuffle v1.1.0 // indirect - github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.50.1 // indirect - github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect - github.com/r3labs/sse/v2 v2.10.0 // indirect - github.com/raulk/go-watchdog v1.3.0 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/shirou/gopsutil v3.21.11+incompatible // indirect - github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect - github.com/tklauser/go-sysconf v0.3.15 // indirect - github.com/tklauser/numcpus v0.10.0 // indirect - github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1 // indirect - github.com/wlynxg/anet v0.0.5 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/dig v1.18.1 // indirect - go.uber.org/fx v1.23.0 // indirect - go.uber.org/mock v0.5.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/time v0.13.0 // indirect - golang.org/x/tools v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/grpc v1.75.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect - gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.4.0 // indirect -) - -// Copied from charon v1.7.1 go.mod (Go does not propagate a dependency's replaces). -// Keep in sync when bumping the charon version. -replace github.com/coinbase/kryptology => github.com/ObolNetwork/kryptology v0.1.0 - -replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.27.1-obol.1 diff --git a/test-infra/compose/go.sum b/test-infra/compose/go.sum deleted file mode 100644 index 8f40199f..00000000 --- a/test-infra/compose/go.sum +++ /dev/null @@ -1,1022 +0,0 @@ -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1 h1:f6miF8tK6H+Ktad24WpnNfpHO75GRGk0rhJ1mxPXqgA= -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250121211742-6d880cc6cc8d.1/go.mod h1:rvbyamNtvJ4o3ExeCmaG5/6iHnu0vy0E+UQ+Ph0om8s= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250307204501-0409229c3780.1 h1:zgJPqo17m28+Lf5BW4xv3PvU20BnrmTcGYrog22lLIU= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250307204501-0409229c3780.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250116203702-1c024d64352b.1 h1:1SDs5tEGoWWv2vmKLx2B0Bp+yfhlxiU4DaZUII8+Pvs= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250116203702-1c024d64352b.1/go.mod h1:o2AgVM1j3MczvxnMqfZTpiqGwK1VD4JbEagseY0QcjE= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250116203702-1c024d64352b.1 h1:O1sbHpYA7yAIZpDWSEw0mNibv1gov2KH8mSzPruCNhk= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.6-20250116203702-1c024d64352b.1/go.mod h1:ee69ieBAzwc/oY/Vde0K4r6JWvrk093q4Z/FXexPMmA= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1 h1:trcsXBDm8exui7mvndZnvworCyBq1xuMnod2N0j79K8= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.6-20241007202033-cf42259fcbfc.1/go.mod h1:OUbhXurY+VHFGn9FBxcRy8UB7HXk9NvJ2qCgifOMypQ= -buf.build/go/bufplugin v0.8.0 h1:YgR1+CNGmzR69jt85oRWTa5FioZoX/tOrHV+JxfNnnk= -buf.build/go/bufplugin v0.8.0/go.mod h1:rcm0Esd3P/GM2rtYTvz3+9Gf8w9zdo7rG8dKSxYHHIE= -buf.build/go/protoyaml v0.3.1 h1:ucyzE7DRnjX+mQ6AH4JzN0Kg50ByHHu+yrSKbgQn2D4= -buf.build/go/protoyaml v0.3.1/go.mod h1:0TzNpFQDXhwbkXb/ajLvxIijqbve+vMQvWY/b3/Dzxg= -buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= -buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= -connectrpc.com/otelconnect v0.7.2 h1:WlnwFzaW64dN06JXU+hREPUGeEzpz3Acz2ACOmN8cMI= -connectrpc.com/otelconnect v0.7.2/go.mod h1:JS7XUKfuJs2adhCnXhNHPHLz6oAaZniCJdSF00OZSew= -dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= -dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= -dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= -dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= -git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= -github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/attestantio/go-builder-client v0.7.2 h1:bOrtysEIZd9bEM+mAeT6OtAo6LSAft/qylBLwFoFwZ0= -github.com/attestantio/go-builder-client v0.7.2/go.mod h1:+NADxbaknI5yxl+0mCkMa/VciVsesxRMGNP/poDfV08= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= -github.com/bufbuild/buf v1.51.0 h1:k2we7gmuSDeIqxkv16F/8s5Kk0l2ZfvMHpvC1n6o5Rk= -github.com/bufbuild/buf v1.51.0/go.mod h1:TbX4Df3BfE0Lugd3Y3sFr7QTxqmCfPkuiEexe29KZeE= -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= -github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= -github.com/bufbuild/protovalidate-go v0.9.3-0.20250317160558-38a17488914d h1:Y6Yp/LwSaRG8gw9GyyQD7jensL9NXqPlkbuulaAvCEE= -github.com/bufbuild/protovalidate-go v0.9.3-0.20250317160558-38a17488914d/go.mod h1:SZN6Qr3lPWuKMoQtIhKdhESkb+3m2vk0lqN9WMuZDDU= -github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= -github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A= -github.com/chigopher/pathlib v0.19.1/go.mod h1:tzC1dZLW8o33UQpWkNkhvPwL5n4yyFRFm/jL1YGWFvY= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= -github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= -github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= -github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= -github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A= -github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= -github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= -github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= -github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= -github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= -github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4314= -github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= -github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0= -github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= -github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= -github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= -github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= -github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= -github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= -github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= -github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= -github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= -github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= -github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= -github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= -github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= -github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= -github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A= -github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok= -github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= -github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo= -github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= -github.com/emicklei/dot v1.8.0 h1:HnD60yAKFAevNeT+TPYr9pb8VB9bqdeSo0nzwIW6IOI= -github.com/emicklei/dot v1.8.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/ethereum/c-kzg-4844/v2 v2.1.3 h1:DQ21UU0VSsuGy8+pcMJHDS0CV1bKmJmxsJYK8l3MiLU= -github.com/ethereum/c-kzg-4844/v2 v2.1.3/go.mod h1:fyNcYI/yAuLWJxf4uzVtS8VDKeoAaRM8G/+ADz/pRdA= -github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= -github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.16.4 h1:H6dU0r2p/amA7cYg6zyG9Nt2JrKKH6oX2utfcqrSpkQ= -github.com/ethereum/go-ethereum v1.16.4/go.mod h1:P7551slMFbjn2zOQaKrJShZVN/d8bGxp4/I6yZVlb5w= -github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= -github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= -github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= -github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8= -github.com/ferranbt/fastssz v1.0.0/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= -github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= -github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= -github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 h1:f6D9Hr8xV8uYKlyuj8XIruxlh9WjVjdh1gIicAS7ays= -github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= -github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= -github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= -github.com/goccy/go-yaml v1.17.0 h1:JJhayi67p5LeTLh9UJYFhayPIOGDZjAqQNoEzHhYvik= -github.com/goccy/go-yaml v1.17.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI= -github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= -github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= -github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= -github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= -github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/graph-gophers/graphql-go v1.6.0 h1:tHuViEiKFvs9TSjiisqeBQAxld1mscgF0D/czoHVV30= -github.com/graph-gophers/graphql-go v1.6.0/go.mod h1:mVu5xmLns4x/D4XH7R6bepK2bMF4I4J1BBTum2VDbWU= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/hashicorp/go-bexpr v0.1.14 h1:uKDeyuOhWhT1r5CiMTjdVY4Aoxdxs6EtwgTGnlosyp4= -github.com/hashicorp/go-bexpr v0.1.14/go.mod h1:gN7hRKB3s7yT+YvTdnhZVLTENejvhlkZ8UE4YVBS+Q8= -github.com/herumi/bls-eth-go-binary v1.36.4 h1:yff41RSbfyZwfE1NF/qddP5nXhgdU0c3RGOpYOoM7YM= -github.com/herumi/bls-eth-go-binary v1.36.4/go.mod h1:luAnRm3OsMQeokhGzpYmc0ZKwawY7o87PUEP11Z7r7U= -github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= -github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= -github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= -github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= -github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= -github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= -github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-clone v1.7.2 h1:3+Aq0Ed8XK+zKkLjE2dfHg0XrpIfcohBE1K+c8Usxoo= -github.com/huandu/go-clone v1.7.2/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= -github.com/huandu/go-clone/generic v1.6.0 h1:Wgmt/fUZ28r16F2Y3APotFD59sHk1p78K0XLdbUYN5U= -github.com/huandu/go-clone/generic v1.6.0/go.mod h1:xgd9ZebcMsBWWcBx5mVMCoqMX24gLWr5lQicr+nVXNs= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= -github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= -github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= -github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= -github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= -github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= -github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU= -github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= -github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= -github.com/ipfs/go-log/v2 v2.8.1 h1:Y/X36z7ASoLJaYIJAL4xITXgwf7RVeqb1+/25aq/Xk0= -github.com/ipfs/go-log/v2 v2.8.1/go.mod h1:NyhTBcZmh2Y55eWVjOeKf8M7e4pnJYM3yDZNxQBWEEY= -github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= -github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= -github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= -github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= -github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= -github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= -github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= -github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= -github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM= -github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= -github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= -github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= -github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= -github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= -github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= -github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= -github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= -github.com/libp2p/go-flow-metrics v0.2.0 h1:EIZzjmeOE6c8Dav0sNv35vhZxATIXWZg6j/C08XmmDw= -github.com/libp2p/go-flow-metrics v0.2.0/go.mod h1:st3qqfu8+pMfh+9Mzqb2GTiwrAGjIPszEjZmtksN8Jc= -github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= -github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= -github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= -github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= -github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= -github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= -github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= -github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= -github.com/libp2p/go-netroute v0.2.2 h1:Dejd8cQ47Qx2kRABg6lPwknU7+nBnFRpko45/fFPuZ8= -github.com/libp2p/go-netroute v0.2.2/go.mod h1:Rntq6jUAH0l9Gg17w5bFGhcC9a+vk4KNXs6s7IljKYE= -github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= -github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= -github.com/libp2p/go-yamux/v5 v5.0.0 h1:2djUh96d3Jiac/JpGkKs4TO49YhsfLopAoryfPmf+Po= -github.com/libp2p/go-yamux/v5 v5.0.0/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU= -github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= -github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ= -github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= -github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= -github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= -github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= -github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= -github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= -github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= -github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= -github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= -github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= -github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw= -github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= -github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/mount v0.3.4 h1:yn5jq4STPztkkzSKpZkLcmjue+bZJ0u2AuQY1iNI1Ww= -github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/moby/sys/reexec v0.1.0 h1:RrBi8e0EBTLEgfruBOFcxtElzRGTEUkeIFaVXgU7wok= -github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= -github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= -github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= -github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= -github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= -github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= -github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= -github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multiaddr-dns v0.4.1 h1:whi/uCLbDS3mSEUMb1MsoT4uzUeZB0N32yzufqS0i5M= -github.com/multiformats/go-multiaddr-dns v0.4.1/go.mod h1:7hfthtB4E4pQwirrz+J0CcDUfbWzTqEzVyYKKIKpgkc= -github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= -github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= -github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= -github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= -github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= -github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= -github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= -github.com/multiformats/go-multistream v0.6.0 h1:ZaHKbsL404720283o4c/IHQXiS6gb8qAN5EIJ4PN5EA= -github.com/multiformats/go-multistream v0.6.0/go.mod h1:MOyoG5otO24cHIg8kf9QW2/NozURlkP/rvi2FQJyCPg= -github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= -github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= -github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= -github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/obolnetwork/charon v1.7.1 h1:FK5VSSFWopuPQMzTn3JY3sshreXOtIp5A1SUKTU3lG4= -github.com/obolnetwork/charon v1.7.1/go.mod h1:vzMNosnmZiM6OheKPlx7ZwmqAYVbHJsmadKx2by9Nbo= -github.com/ObolNetwork/go-eth2-client v0.27.1-obol.1 h1:bnUqEOoHVnIDXpDp8YwOUWWZuqr2xB9FGtuzqF/+rSI= -github.com/ObolNetwork/go-eth2-client v0.27.1-obol.1/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= -github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI= -github.com/ObolNetwork/kryptology v0.1.0/go.mod h1:/Wl7Js2f676GyXZDTaojf/O+l0fxFPWudbyjdFhkpSA= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= -github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= -github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= -github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= -github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= -github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= -github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= -github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= -github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= -github.com/pion/ice/v4 v4.0.9 h1:VKgU4MwA2LUDVLq+WBkpEHTcAb8c5iCvFMECeuPOZNk= -github.com/pion/ice/v4 v4.0.9/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA= -github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= -github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU= -github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= -github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= -github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= -github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= -github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= -github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= -github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= -github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg= -github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk= -github.com/pk910/dynamic-ssz v0.0.6 h1:Tu97LSc2TtCyqRfoSbhG9XuR/FbA7CkKeAnlkgUydFY= -github.com/pk910/dynamic-ssz v0.0.6/go.mod h1:b6CrLaB2X7pYA+OSEEbkgXDEcRnjLOZIxZTsMuO/Y9c= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/protolambda/eth2-shuffle v1.1.0 h1:gixIBI84IeugTwwHXm8vej1bSSEhueBCSryA4lAKRLU= -github.com/protolambda/eth2-shuffle v1.1.0/go.mod h1:FhA2c0tN15LTC+4T9DNVm+55S7uXTTjQ8TQnBuXlkF8= -github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94= -github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k= -github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= -github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q= -github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E= -github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= -github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= -github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= -github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= -github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk= -github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= -github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/segmentio/encoding v0.4.1 h1:KLGaLSW0jrmhB58Nn4+98spfvPvmo4Ci1P/WIQ9wn7w= -github.com/segmentio/encoding v0.4.1/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= -github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/showwin/speedtest-go v1.7.10 h1:9o5zb7KsuzZKn+IE2//z5btLKJ870JwO6ETayUkqRFw= -github.com/showwin/speedtest-go v1.7.10/go.mod h1:Ei7OCTmNPdWofMadzcfgq1rUO7mvJy9Jycj//G7vyfA= -github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= -github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= -github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= -github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= -github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= -github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= -github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= -github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= -github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= -github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= -github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= -github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= -github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= -github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= -github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= -github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= -github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= -github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= -github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= -github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= -github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= -github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= -github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo= -github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= -github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= -github.com/vektra/mockery/v2 v2.53.3 h1:yBU8XrzntcZdcNRRv+At0anXgSaFtgkyVUNm3f4an3U= -github.com/vektra/mockery/v2 v2.53.3/go.mod h1:hIFFb3CvzPdDJJiU7J4zLRblUMv7OuezWsHPmswriwo= -github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= -github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= -github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= -github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= -github.com/wealdtech/go-eth2-types/v2 v2.8.2 h1:b5aXlNBLKgjAg/Fft9VvGlqAUCQMP5LzYhlHRrr4yPg= -github.com/wealdtech/go-eth2-types/v2 v2.8.2/go.mod h1:IAz9Lz1NVTaHabQa+4zjk2QDKMv8LVYo0n46M9o/TXw= -github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1 h1:9j7bpwjT9wmwBb54ZkBhTm1uNIlFFcCJXefd/YskZPw= -github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1/go.mod h1:+tI1VD76E1WINI+Nstg7RVGpUolL5ql10nu2YztMO/4= -github.com/wealdtech/go-eth2-wallet-types/v2 v2.11.0 h1:yX9+FfUXvPDvZ8Q5bhF+64AWrQwh4a3/HpfTx99DnZc= -github.com/wealdtech/go-eth2-wallet-types/v2 v2.11.0/go.mod h1:UVP9YFcnPiIzHqbmCMW3qrQ3TK5FOqr1fmKqNT9JGr8= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= -go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= -go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= -go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= -go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= -go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= -go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= -go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= -go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= -go.uber.org/dig v1.18.1 h1:rLww6NuajVjeQn+49u5NcezUJEGwd5uXmyoCKW2g5Es= -go.uber.org/dig v1.18.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= -go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= -go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= -go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= -golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= -golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8= -golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= -golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= -google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= -google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= -gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= -gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= -gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= -grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -lukechampine.com/blake3 v1.4.0 h1:xDbKOZCVbnZsfzM6mHSYcGRHZ3YrLDzqz8XnV4uaD5w= -lukechampine.com/blake3 v1.4.0/go.mod h1:MQJNQCTnR+kwOP/JEZSxj3MaQjp80FOFSNMMHXcSeX0= -pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= -pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= -sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= -sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/test-infra/compose/lock.go b/test-infra/compose/lock.go deleted file mode 100644 index 84c3911d..00000000 --- a/test-infra/compose/lock.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path" - "strconv" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" - "github.com/obolnetwork/charon/eth2util" -) - -// Lock creates a docker-compose.yml from a charon-compose.yml for generating keys and a cluster lock file. -func Lock(ctx context.Context, dir string, conf Config) (TmplData, error) { - if conf.Step != stepDefined { - return TmplData{}, errors.New("compose config not defined, so can't be locked", z.Any("step", conf.Step)) - } - - var data TmplData - - switch conf.KeyGen { - case KeyGenCreate: - splitKeysDir, err := getRelSplitKeysDir(dir, conf.SplitKeysDir) - if err != nil { - return TmplData{}, err - } else if splitKeysDir != "" { - splitKeysDir = path.Join("/compose", splitKeysDir) - } - - // Only single node to call charon create cluster generate keys - kvs := []kv{ - {"name", fmt.Sprintf("compose-%d-%d", conf.NumNodes, conf.NumValidators)}, - {"threshold", strconv.Itoa(conf.Threshold)}, - {"nodes", strconv.Itoa(conf.NumNodes)}, - {"cluster-dir", "/compose"}, - {"split-existing-keys", fmt.Sprintf(`"%v"`, conf.SplitKeysDir != "")}, - {"split-keys-dir", splitKeysDir}, - {"num-validators", strconv.Itoa(conf.NumValidators)}, - {"insecure-keys", fmt.Sprintf(`"%v"`, conf.InsecureKeys)}, - {"withdrawal-addresses", zeroAddress}, - {"fee-recipient-addresses", zeroAddress}, - {"network", eth2util.Goerli.Name}, - } - - n := TmplNode{Image: conf.ImageOverride(conf.KeygenImpl()), EnvVars: kvs} - - data = TmplData{ - ComposeDir: dir, - CharonImageTag: conf.ImageTag, - CharonCommand: cmdCreateCluster, - Nodes: []TmplNode{n}, - } - case KeyGenDKG: - var nodes []TmplNode - for i := range conf.NumNodes { - n := TmplNode{ - EnvVars: newNodeEnvs(i, conf, ""), - Image: conf.ImageOverride(conf.NodeImpl(i)), - Command: cmdDKG, - } - nodes = append(nodes, n) - } - - data = TmplData{ - ComposeDir: dir, - CharonImageTag: conf.ImageTag, - CharonCommand: "not used", - Relay: true, - Nodes: nodes, - } - default: - return TmplData{}, errors.New("unsupported keygen", z.Any("keygen", conf.KeyGen)) - } - - log.Info(ctx, "Creating docker-compose.yml") - log.Info(ctx, "Create keys and cluster lock with: docker compose up") - - conf.Step = stepLocked - if err := WriteConfig(dir, conf); err != nil { - return TmplData{}, err - } - - if err := WriteDockerCompose(dir, data); err != nil { - return TmplData{}, err - } - - return data, nil -} - -// newNodeEnvs returns the default node environment variable to run a charon docker container. -func newNodeEnvs(index int, conf Config, vcType VCType) []kv { - beaconMock := false - - beaconNode := conf.BeaconNodes - if beaconNode == "mock" { - beaconMock = true - beaconNode = "" - } - - lockFile := fmt.Sprintf("/compose/node%d/cluster-lock.json", index) - - // The path-less URL form (multiaddrs response) instead of charon-compose's - // /enr path: pluto's relay parsing roundtrips URLs through a multiaddr, - // which cannot represent a URL path. Charon supports both forms. - //nolint:revive // tls not required for testing. - p2pRelayAddr := "http://relay:3640" - if conf.ExternalRelay != "" { - p2pRelayAddr = conf.ExternalRelay - } - - // Common config - kvs := []kv{ - {"private-key-file", fmt.Sprintf("/compose/node%d/charon-enr-private-key", index)}, - {"monitoring-address", "0.0.0.0:3620"}, - {"p2p-external-hostname", fmt.Sprintf("node%d", index)}, - {"p2p-tcp-address", "0.0.0.0:3610"}, - {"p2p-relays", p2pRelayAddr}, - {"log-level", "debug"}, - {"log-color", "force"}, - {"feature-set", conf.FeatureSet}, - } - - if conf.Step == stepDefined { - // Define lock config - return append(kvs, - kv{"data-dir", fmt.Sprintf("/compose/node%d", index)}, - kv{"definition-file", "/compose/cluster-definition.json"}, - kv{"insecure-keys", fmt.Sprintf(`"%v"`, conf.InsecureKeys)}, - ) - } - - // Define run config - kvs = append(kvs, - kv{"lock-file", lockFile}, - kv{"validator-api-address", "0.0.0.0:3600"}, - kv{"beacon-node-endpoints", beaconNode}, - kv{"simnet-beacon_mock", fmt.Sprintf(`"%v"`, beaconMock)}, - kv{"simnet-validator-mock", fmt.Sprintf(`"%v"`, vcType == VCMock)}, - kv{"simnet-slot-duration", conf.SlotDuration.String()}, - kv{"simnet-validator-keys-dir", fmt.Sprintf("/compose/node%d/validator_keys", index)}, - kv{"simnet-beacon-mock-fuzz", fmt.Sprintf(`"%v"`, conf.BeaconFuzz)}, - kv{"synthetic-block-proposals", fmt.Sprintf(`"%v"`, conf.SyntheticBlockProposals)}, - kv{"builder-api", fmt.Sprintf(`"%v"`, conf.BuilderAPI)}, - ) - - // Unlike charon's compose, only point nodes at loki/tempo when the - // monitoring stack actually runs: failed pushes to absent services are - // logged as errors, tripping the Error Log Rate alert. - if conf.Monitoring { - //nolint:revive // tls not required for testing. - kvs = append(kvs, - kv{"otlp-address", "tempo:4317"}, - kv{"otlp-service-name", fmt.Sprintf("node%d", index)}, - kv{"loki-addresses", "http://loki:3100/loki/api/v1/push"}, - kv{"loki-service", fmt.Sprintf("node%d", index)}, - ) - } - - return kvs -} - -// LoadConfig returns the config loaded from disk. -func LoadConfig(dir string) (Config, error) { - b, err := os.ReadFile(path.Join(dir, configFile)) - if err != nil { - return Config{}, errors.Wrap(err, "load config") - } - - var resp Config - if err := json.Unmarshal(b, &resp); err != nil { - return Config{}, errors.Wrap(err, "unmarshal Config") - } - - if err := resp.Validate(); err != nil { - return Config{}, err - } - - return resp, nil -} diff --git a/test-infra/compose/new.go b/test-infra/compose/new.go deleted file mode 100644 index ac1e17a7..00000000 --- a/test-infra/compose/new.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "context" - "fmt" - - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" -) - -// New creates a new compose config file from flags. -func New(ctx context.Context, dir string, conf Config) error { - if err := Clean(ctx, dir); err != nil { - return err - } - - conf.Step = stepNew - - log.Info(ctx, "Writing config to compose dir", - z.Str("dir", dir), - z.Str("config", fmt.Sprintf("%#v", conf)), - ) - - return WriteConfig(dir, conf) -} diff --git a/test-infra/compose/new_test.go b/test-infra/compose/new_test.go deleted file mode 100644 index 992309b7..00000000 --- a/test-infra/compose/new_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose_test - -import ( - "context" - "os" - "path" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/NethermindEth/pluto/test-infra/compose" - "github.com/obolnetwork/charon/testutil" -) - -//go:generate go test . -update -clean - -func TestNewDefaultConfig(t *testing.T) { - dir := t.TempDir() - - err := compose.New(context.Background(), dir, compose.NewDefaultConfig()) - require.NoError(t, err) - - conf, err := os.ReadFile(path.Join(dir, "config.json")) - require.NoError(t, err) - - testutil.RequireGoldenBytes(t, conf) -} diff --git a/test-infra/compose/rules_internal_test.go b/test-infra/compose/rules_internal_test.go deleted file mode 100644 index 99ef19a8..00000000 --- a/test-infra/compose/rules_internal_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "os" - "path" - "strconv" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestWritePrometheusConfigScrapesAllNodes asserts the generated scrape -// config covers every configured node plus the relay, so the `up == 0` and -// injected-zero broadcast alerts can see all of them. -func TestWritePrometheusConfigScrapesAllNodes(t *testing.T) { - dir := t.TempDir() - - conf := NewDefaultConfig() - conf.NumNodes = 10 - - require.NoError(t, writePrometheusConfig(dir, conf)) - - b, err := os.ReadFile(path.Join(dir, "prometheus", "prometheus.yml")) - require.NoError(t, err) - - content := string(b) - require.Contains(t, content, "- targets: [ 'relay:3620' ]") - - for i := range conf.NumNodes { - require.Contains(t, content, "job_name: 'node"+strconv.Itoa(i)+"'") - require.Contains(t, content, "- targets: ['node"+strconv.Itoa(i)+":3620']") - } - - require.NotContains(t, content, "node10", "must not scrape beyond NumNodes") -} - -// TestWriteAlertRulesBroadcastCoversMissingSeries asserts the broadcast -// liveness expression injects a zero for scraped node jobs with no -// core_bcast_broadcast_total series, so a node that never broadcasts (the -// counter is only created on first broadcast) fails instead of silently -// passing. -func TestWriteAlertRulesBroadcastCoversMissingSeries(t *testing.T) { - content := writeRules(t, NewDefaultConfig()) - - require.Contains(t, content, - `expr: (sum by (job) (increase(core_bcast_broadcast_total{job=~"node[0-9]+"}[30s])) or on (job) max by (job) (0 * up{job=~"node[0-9]+"})) < 0.5`) -} - -// TestWriteAlertRulesExcludesDegradedJobs asserts AlertExcludeJobs exempts a -// node from every behavioral rule while "Pluto Down" keeps watching it. -func TestWriteAlertRulesExcludesDegradedJobs(t *testing.T) { - conf := NewDefaultConfig() - conf.AlertExcludeJobs = []string{"node0"} - - content := writeRules(t, conf) - - require.Contains(t, content, `increase(app_log_error_total{job!~"node0"}[30s]) > 0`) - require.Contains(t, content, `increase(app_log_warn_total{topic!~"vmock|tracker",job!~"node0"}[30s]) > 2`) - require.Contains(t, content, `increase(core_validatorapi_request_error_total{endpoint!="proxy",job!~"node0"}[30s]) > 1`) - require.Contains(t, content, `increase(core_validatorapi_request_error_total{endpoint="proxy",job!~"node0"}[30s]) > 5`) - require.Contains(t, content, - `(sum by (job) (increase(core_bcast_broadcast_total{job=~"node[0-9]+",job!~"node0"}[30s])) or on (job) max by (job) (0 * up{job=~"node[0-9]+",job!~"node0"})) < 0.5`) - - // The scrape-liveness rule must never carry exclusions. - require.Contains(t, content, "expr: up == 0") -} - -// TestWriteAlertRulesWarnTopics asserts the Warn Log Rate gate excludes exactly -// the two charon mock-noise topics. -func TestWriteAlertRulesWarnTopics(t *testing.T) { - content := writeRules(t, NewDefaultConfig()) - require.Contains(t, content, `increase(app_log_warn_total{topic!~"vmock|tracker"}[30s]) > 2`) -} - -// TestWriteAlertRulesDropsOutstandingDuty pins the removal of charon's dead -// "Outstanding Duty Rate" rule (broadcast counts can never exceed scheduled -// counts, so the expression could never fire). -func TestWriteAlertRulesDropsOutstandingDuty(t *testing.T) { - content := writeRules(t, NewDefaultConfig()) - require.NotContains(t, content, "Outstanding Duty") - require.NotContains(t, content, "core_scheduler_duty_total") -} - -// TestWriteAlertRulesDisableRules asserts AlertDisableRules drops exactly the -// named rules and validation rejects unknown names. -func TestWriteAlertRulesDisableRules(t *testing.T) { - conf := NewDefaultConfig() - conf.AlertDisableRules = []string{"Error Log Rate", "Validator API Error Rate"} - - content := writeRules(t, conf) - require.NotContains(t, content, "Error Log Rate") - require.NotContains(t, content, `endpoint!="proxy"`) - // The remaining gates stay. - require.Contains(t, content, "Pluto Down") - require.Contains(t, content, "Warn Log Rate") - require.Contains(t, content, "Proxy API Error Rate") - require.Contains(t, content, "Broadcast Duty Rate") - - conf = NewDefaultConfig() - conf.AlertDisableRules = []string{"No Such Rule"} - require.ErrorContains(t, WriteConfig(t.TempDir(), conf), "unknown alert rule name") -} - -// TestConfigValidateRejectsUnknownImpl asserts unknown implementation names -// fail on write and on load instead of silently running the charon image. -func TestConfigValidateRejectsUnknownImpl(t *testing.T) { - conf := NewDefaultConfig() - conf.NodeImpls = []NodeImpl{ImplCharon, "geth"} - require.ErrorContains(t, WriteConfig(t.TempDir(), conf), "unknown node implementation") - - conf = NewDefaultConfig() - conf.KeyGenImpl = "plutoo" - require.ErrorContains(t, WriteConfig(t.TempDir(), conf), "unknown keygen implementation") - - // Loading a hand-edited config with a bad impl fails too. - dir := t.TempDir() - badJSON := `{"version":"obol/charon/compose/1.0.0","node_impls":["geth"]}` - require.NoError(t, os.WriteFile(path.Join(dir, "config.json"), []byte(badJSON), 0o644)) - _, err := LoadConfig(dir) - require.ErrorContains(t, err, "unknown node implementation") - - // The happy path still validates. - conf = NewDefaultConfig() - conf.NodeImpls = []NodeImpl{ImplCharon, ImplPluto} - conf.KeyGenImpl = ImplPluto - dir = t.TempDir() - require.NoError(t, WriteConfig(dir, conf)) - _, err = LoadConfig(dir) - require.NoError(t, err) -} - -// writeRules writes alert rules for conf into a temp dir and returns them. -func writeRules(t *testing.T, conf Config) string { - t.Helper() - - dir := t.TempDir() - require.NoError(t, writeAlertRules(dir, conf)) - - b, err := os.ReadFile(path.Join(dir, "prometheus", "rules.yml")) - require.NoError(t, err) - - return string(b) -} diff --git a/test-infra/compose/run.go b/test-infra/compose/run.go deleted file mode 100644 index bf4c3375..00000000 --- a/test-infra/compose/run.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "bytes" - "context" - "fmt" - "text/template" - - "github.com/obolnetwork/charon/app/errors" - "github.com/obolnetwork/charon/app/log" - "github.com/obolnetwork/charon/app/z" -) - -// Run creates a docker-compose.yml from config.json to run the cluster. -func Run(ctx context.Context, dir string, conf Config) (TmplData, error) { - if conf.Step != stepLocked { - return TmplData{}, errors.New("compose config not locked, so can't be run", z.Any("step", conf.Step)) - } - - var ( - nodes []TmplNode - vcs []TmplVC - ) - - for i := range conf.NumNodes { - typ := conf.VCs[i%len(conf.VCs)] - - vc, err := getVC(typ, i, conf.NumValidators, conf.InsecureKeys, conf.BuilderAPI) - if err != nil { - return TmplData{}, err - } - - vcs = append(vcs, vc) - - n := TmplNode{EnvVars: newNodeEnvs(i, conf, typ), Image: conf.ImageOverride(conf.NodeImpl(i))} - if !conf.DisableMonitoringPorts { - for _, p := range charonPorts { - p.External += 10000 * i - n.Ports = append(n.Ports, p) - } - } - - nodes = append(nodes, n) - } - - charonCmd := cmdRun - - if conf.P2PFuzz { - nodes[0].EnvVars = append(nodes[0].EnvVars, kv{"p2p-fuzz", fmt.Sprintf(`"%v"`, conf.P2PFuzz)}) - charonCmd = cmdUnsafeRun - } - - data := TmplData{ - ComposeDir: dir, - CharonImageTag: conf.ImageTag, - CharonCommand: charonCmd, - Nodes: nodes, - Relay: true, - Monitoring: conf.Monitoring, - Alerting: true, - MonitoringPorts: !conf.DisableMonitoringPorts, - VCs: vcs, - } - - log.Info(ctx, "Created docker-compose.yml") - log.Info(ctx, "Run the cluster with: docker compose up") - - if err := WriteDockerCompose(dir, data); err != nil { - return TmplData{}, err - } - - return data, nil -} - -// getVC returns the validator client template data for the provided type and index. -func getVC(typ VCType, nodeIdx int, numVals int, insecure, builderAPI bool) (TmplVC, error) { - vcByType := map[VCType]TmplVC{ - VCVouch: { - Label: string(VCVouch), - Build: "vouch", - }, - VCLighthouse: { - Label: string(VCLighthouse), - Build: "lighthouse", - }, - VCLodestar: { - Label: string(VCLodestar), - Build: "lodestar", - }, - VCTeku: { - Label: string(VCTeku), - Image: "consensys/teku:latest", - Command: `| - validator-client - --network=auto - --beacon-node-api-endpoint="http://node{{.NodeIdx}}:3600" - {{range .TekuKeys}}--validator-keys="{{.}}" - {{end -}} - --validators-proposer-default-fee-recipient="0x0000000000000000000000000000000000000000" - --validators-proposer-blinded-blocks-enabled={{.BuilderAPI}}`, - }, - } - - resp := vcByType[typ] - if typ == VCTeku { - var keys []string - - for i := range numVals { - if insecure { - keys = append(keys, fmt.Sprintf("/compose/node%d/validator_keys/keystore-insecure-%d.json:/compose/node%d/validator_keys/keystore-insecure-%d.txt", nodeIdx, i, nodeIdx, i)) - } else { - keys = append(keys, fmt.Sprintf("/compose/node%d/validator_keys/keystore-%d.json:/compose/node%d/validator_keys/keystore-%d.txt", nodeIdx, i, nodeIdx, i)) - } - } - - data := struct { - TekuKeys []string - NodeIdx int - BuilderAPI bool - }{ - NodeIdx: nodeIdx, - TekuKeys: keys, - BuilderAPI: builderAPI, - } - - var buf bytes.Buffer - - err := template.Must(template.New("").Parse(resp.Command)).Execute(&buf, data) - if err != nil { - return TmplVC{}, errors.Wrap(err, "teku template") - } - - resp.Command = buf.String() - } - - return resp, nil -} diff --git a/test-infra/compose/smoke/smoke_test.go b/test-infra/compose/smoke/smoke_test.go deleted file mode 100644 index 84ee48cb..00000000 --- a/test-infra/compose/smoke/smoke_test.go +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package smoke_test - -import ( - "context" - "flag" - "os" - "path" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/obolnetwork/charon/testutil" - - "github.com/NethermindEth/pluto/test-infra/compose" -) - -//go:generate go test . -run=TestSmoke -integration -v - -var ( - integration = flag.Bool("integration", false, "Enable docker based integration test") - sudoPerms = flag.Bool("sudo-perms", false, "Enables changing all compose artefacts file permissions using sudo.") - logDir = flag.String("log-dir", "", "Specifies the directory to store test docker-compose logs. Empty defaults to stdout.") -) - -// charonImageTag pins the charon reference version pluto is ported from. -const charonImageTag = "v1.7.1" - -// defaultTimeout bounds one scenario's alert collection: Prometheus readiness -// (~10s) + the 60s cold-start warmup (compose/alert.go) + steady-state -// polling time beyond it. -const defaultTimeout = 2 * time.Minute - -// smokeBaseConfig returns the config every scenario starts from. -// -// All scenarios run the mock validator client: charon v1.7.1's beaconmock -// hardcodes `head_slot: "1"` in /eth/v1/node/syncing, so a real VC (e.g. -// lighthouse) permanently considers the beacon node unsynced and performs no -// duties — the cluster then never reaches the signing threshold and the -// broadcast/error alerts fire by design. Upstream charon runs lighthouse VCs -// in these scenarios but never noticed because its alert gate matches a -// state ("active") that Prometheus never reports. The real-VC compose -// service definitions remain in the harness (`static/`), but the tests -// always run the mock VC. -func smokeBaseConfig() compose.Config { - conf := compose.NewDefaultConfig() - conf.Monitoring = false - conf.DisableMonitoringPorts = true - conf.ImageTag = charonImageTag - conf.InsecureKeys = true - conf.VCs = []compose.VCType{compose.VCMock} - - // Route the cluster through an external relay (e.g. the public - // https://0.relay.obol.tech) instead of the local relay container. The - // local relay service still runs so its prometheus scrape target stays - // up; nothing dials it. - if url := os.Getenv("SMOKE_EXTERNAL_RELAY"); url != "" { - conf.ExternalRelay = url - } - - return conf -} - -// smokeScenario defines one smoke matrix entry. -type smokeScenario struct { - Name string - ConfigFunc func(*compose.Config) - RunTmplFunc func(*compose.TmplData) - DefineTmplFunc func(*compose.TmplData) - PrintYML bool - Timeout time.Duration - RequirePluto bool // Scenario needs the pluto docker image (PLUTO_REPO env var). -} - -// smokeScenarios returns the full scenario matrix. Every scenario runs when -// -integration is set; the only skip condition is a pluto scenario without -// the PLUTO_REPO env var. -func smokeScenarios() []smokeScenario { - return []smokeScenario{ - { - Name: "default_alpha", - PrintYML: true, - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenCreate - conf.FeatureSet = "alpha" - }, - }, - { - Name: "default_beta", - ConfigFunc: func(conf *compose.Config) { - conf.NumNodes = 3 - conf.Threshold = 2 - conf.KeyGen = compose.KeyGenCreate - conf.FeatureSet = "beta" - }, - }, - { - Name: "default_stable", - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenCreate - conf.FeatureSet = "stable" - }, - }, - { - Name: "dkg", - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenDKG - }, - }, - { - Name: "very_large", - ConfigFunc: func(conf *compose.Config) { - conf.NumNodes = 10 - conf.Threshold = 7 - conf.NumValidators = 100 - conf.KeyGen = compose.KeyGenCreate - conf.SlotDuration = time.Second * 6 - conf.SyntheticBlockProposals = false - }, - Timeout: time.Minute * 3, - }, - { - // node0 keeps default p2p flags (public relays) so it runs but - // cannot reach the cluster: expected to log errors and stop - // broadcasting, hence exempted from the per-node behavioral - // alerts (not from "Pluto Down"). - // - // This scenario gates liveness only — that losing a node does not - // take the rest of the cluster down. It deliberately does NOT - // gate duty outcomes, because at simnet settings the surviving - // three cannot reliably complete duties and no configuration - // fixes that: - // - // - Charon derives duty deadlines from slot duration (a - // proposer duty must finish within slotDuration/3), leaving - // ~0.33s at the 1s default. QBFT quorum for n=4 is 3, so with - // node0 down every duty needs all three survivors inside that - // window with no slack. Measured: ~40% of runs failed (2/5), - // the survivors logging consensus timeouts, `propose_block_v3` - // validator-API errors, and broadcast gaps — three symptoms of - // one cause, so silencing them individually just moves it. - // - Slowing slots to 3s fixes the deadlines but stretches epochs - // to 48s, and with one validator the duties no longer land in - // every 30s alert window. Measured: 3/4 runs failed on - // `Broadcast Duty Rate`. - // - // So the duty-outcome rules are dropped and the remainder is kept - // honest: every node stays scrapable (`Pluto Down`, never - // excluded) and nothing floods the warn log — which is what - // "survives 1 of 4 down" can actually assert here. node0 is also - // exempted from the per-node behavioral rules via - // AlertExcludeJobs, since it is expected to error and go silent. - Name: "1_of_4_down", - ConfigFunc: func(conf *compose.Config) { - conf.AlertExcludeJobs = []string{"node0"} - conf.AlertDisableRules = []string{ - "Error Log Rate", - "Validator API Error Rate", - "Broadcast Duty Rate", - } - }, - RunTmplFunc: func(data *compose.TmplData) { - node0 := data.Nodes[0] - for i := range len(node0.EnvVars) { - if strings.HasPrefix(node0.EnvVars[i].Key, "p2p") { - data.Nodes[0].EnvVars[i].Key = node0.EnvVars[i].Key + "-unset" // Zero p2p flags to it cannot communicate - } - } - }, - }, - { - // Same collateral-error problem as 1_of_4_down (see there), but - // worse: with 3 nodes even the epoch-aligned proposer duties - // rotate their round-1 leader (16 % 3 == 1), so every third one - // is led by the downed node0 and cannot recover — charon - // v1.7.1's linear round timer uses nanosecond timeouts after - // round 1 (upstream bug #4537) and the 1s-slot proposer deadline - // (~0.4s) expires regardless. The HEALTHY nodes therefore log - // both consensus timeouts and failing vmock proposal requests, - // so the validator-API error gate is dropped too. Broadcast - // liveness, warn rates, and scrape health stay gated. - Name: "1_of_3_down", - ConfigFunc: func(conf *compose.Config) { - conf.NumNodes = 3 - conf.Threshold = 2 - conf.AlertExcludeJobs = []string{"node0"} - conf.AlertDisableRules = []string{"Error Log Rate", "Validator API Error Rate"} - }, - RunTmplFunc: func(data *compose.TmplData) { - node0 := data.Nodes[0] - for i := range len(node0.EnvVars) { - if strings.HasPrefix(node0.EnvVars[i].Key, "p2p") { - data.Nodes[0].EnvVars[i].Key = node0.EnvVars[i].Key + "-unset" // Zero p2p flags to it cannot communicate - } - } - }, - }, - { - Name: "blinded_blocks_vmock", - ConfigFunc: func(conf *compose.Config) { - conf.BuilderAPI = true - }, - }, - { - // Pluto generates the keys and cluster lock, charon nodes run them. - // Validates pluto `create cluster` artifacts against the charon runtime. - Name: "pluto_keygen_create", - RequirePluto: true, - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenCreate - conf.KeyGenImpl = compose.ImplPluto - }, - }, - { - Name: "all_pluto", - RequirePluto: true, - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenCreate - conf.NodeImpls = []compose.NodeImpl{compose.ImplPluto} - // `pluto run` fails fast on --synthetic-block-proposals. - conf.SyntheticBlockProposals = false - }, - }, - { - // Threshold 3 of 4 forces both implementations to participate in every duty. - Name: "mixed_2_charon_2_pluto", - RequirePluto: true, - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenCreate - conf.NodeImpls = []compose.NodeImpl{ - compose.ImplCharon, compose.ImplCharon, - compose.ImplPluto, compose.ImplPluto, - } - // `pluto run` fails fast on --synthetic-block-proposals. - conf.SyntheticBlockProposals = false - }, - }, - { - Name: "pluto_dkg", - RequirePluto: true, - ConfigFunc: func(conf *compose.Config) { - conf.KeyGen = compose.KeyGenDKG - conf.NodeImpls = []compose.NodeImpl{compose.ImplPluto} - // `pluto run` fails fast on --synthetic-block-proposals. - conf.SyntheticBlockProposals = false - }, - }, - } -} - -func TestSmoke(t *testing.T) { - if !*integration { - t.Skip("Skipping smoke integration test") - } - - for _, test := range smokeScenarios() { - t.Run(test.Name, func(t *testing.T) { - if test.RequirePluto && os.Getenv("PLUTO_REPO") == "" { - t.Skip("Skipping pluto scenario since PLUTO_REPO env var is not set") - } - - dir := t.TempDir() - - conf := smokeBaseConfig() - if test.ConfigFunc != nil { - test.ConfigFunc(&conf) - } - - require.NoError(t, compose.WriteConfig(dir, conf)) - - os.Args = []string{"cobra.test"} - - if test.Timeout == 0 { - test.Timeout = defaultTimeout - } - - autoConfig := compose.AutoConfig{ - Dir: dir, - AlertTimeout: test.Timeout, - SudoPerms: *sudoPerms, - PrintYML: test.PrintYML, - RunTmplFunc: test.RunTmplFunc, - DefineTmplFunc: test.DefineTmplFunc, - } - - if *logDir != "" { - autoConfig.LogFile = path.Join(*logDir, test.Name+".log") - } - - err := compose.Auto(context.Background(), autoConfig) - testutil.RequireNoError(t, err) - }) - } -} - -// TestScenarioMatrix guards the scenario table invariants without docker: -// unique names, valid configs, and the RequirePluto gate matching the impls a -// scenario actually uses — a pluto scenario without the gate would fail on -// missing PLUTO_REPO (or silently run a stale pluto:local image), and a -// charon-only scenario with the gate would skip for no reason. -func TestScenarioMatrix(t *testing.T) { - seen := make(map[string]bool) - - for _, test := range smokeScenarios() { - require.NotEmpty(t, test.Name) - require.False(t, seen[test.Name], "duplicate scenario name: %s", test.Name) - seen[test.Name] = true - - conf := smokeBaseConfig() - if test.ConfigFunc != nil { - test.ConfigFunc(&conf) - } - - require.Equal(t, test.RequirePluto, conf.UsesPluto(), - "RequirePluto must match the implementations scenario %q uses", test.Name) - - // Every scenario config must survive the write/load validation boundary. - dir := t.TempDir() - require.NoError(t, compose.WriteConfig(dir, conf)) - _, err := compose.LoadConfig(dir) - require.NoError(t, err) - } -} diff --git a/test-infra/compose/template.go b/test-infra/compose/template.go deleted file mode 100644 index f5d84cd9..00000000 --- a/test-infra/compose/template.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 - -package compose - -import ( - "bytes" - "embed" - "os" - "path" - "strings" - "text/template" - - "github.com/obolnetwork/charon/app/errors" -) - -//go:embed docker-compose.template -var tmpl []byte - -//go:embed static -var static embed.FS - -// TmplData is the docker-compose.yml template data. -type TmplData struct { - ComposeDir string - - CharonImageTag string - CharonEntrypoint string - CharonCommand string - - Nodes []TmplNode - VCs []TmplVC - - Relay bool - Monitoring bool - Alerting bool - MonitoringPorts bool -} - -// TmplVC represents a validator client service in a docker-compose.yml. -type TmplVC struct { - Label string - Image string - Build string - Command string - Ports []port -} - -// TmplNode represents a charon or pluto TmplNode service in a docker-compose.yml. -type TmplNode struct { - Image string // Image is a full image reference, empty by default, resulting in obolnetwork/charon:{CharonImageTag} being used. - Entrypoint string // Entrypoint is empty by default, resulting in CharonEntrypoint being used. - Command string // Command is empty by default, resulting in CharonCommand being used. - EnvVars []kv - Ports []port -} - -// kv is a key value pair. -type kv struct { - Key string - Value string -} - -// EnvKey returns the key formatted as env var: "data-dir" -> "DATA_DIR". -func (kv kv) EnvKey() string { - return strings.ReplaceAll(strings.ToUpper(kv.Key), "-", "_") -} - -// port is a port mapping in a docker-compose.yml. -type port struct { - External int - Internal int -} - -// WriteDockerCompose generates the docker-compose.yml template and writes it to disk. -func WriteDockerCompose(dir string, data TmplData) error { - tpl, err := template.New("").Parse(string(tmpl)) - if err != nil { - return errors.Wrap(err, "new template") - } - - var buf bytes.Buffer - if err := tpl.Execute(&buf, data); err != nil { - return errors.Wrap(err, "exec template") - } - - err = os.WriteFile(path.Join(dir, "docker-compose.yml"), buf.Bytes(), 0o755) //nolint:gosec - if err != nil { - return errors.Wrap(err, "write docker-compose.yml") - } - - return nil -}