From df9e0001ca9cae404b2c8e6e8f65202df6ecc18a Mon Sep 17 00:00:00 2001 From: Quang Le Date: Mon, 7 Sep 2026 17:46:15 +0700 Subject: [PATCH 1/4] test(smoke): smoke test in rust --- .github/workflows/smoke-tests.yml | 98 +- .gitignore | 3 - AGENTS.md | 1 + Cargo.lock | 20 + Cargo.toml | 3 + crates/cli/src/commands/create_cluster.rs | 2 +- crates/test-compose/Cargo.toml | 30 + .../compose => crates/test-compose}/README.md | 84 +- .../test-compose}/docker-compose.template | 0 crates/test-compose/src/alert.rs | 732 ++++++++++++ crates/test-compose/src/auto.rs | 341 ++++++ crates/test-compose/src/config.rs | 643 +++++++++++ crates/test-compose/src/define.rs | 898 +++++++++++++++ crates/test-compose/src/duration.rs | 104 ++ crates/test-compose/src/error.rs | 282 +++++ crates/test-compose/src/fsutil.rs | 173 +++ crates/test-compose/src/golden_tests.rs | 165 +++ crates/test-compose/src/gotmpl.rs | 621 ++++++++++ crates/test-compose/src/lib.rs | 63 + crates/test-compose/src/lock.rs | 291 +++++ crates/test-compose/src/new.rs | 43 + crates/test-compose/src/process.rs | 344 ++++++ crates/test-compose/src/run.rs | 273 +++++ crates/test-compose/src/smoke.rs | 357 ++++++ crates/test-compose/src/static_files.rs | 105 ++ crates/test-compose/src/template.rs | 221 ++++ .../static/grafana/dash_alerts.json | 0 .../static/grafana/dash_charon_overview.json | 0 .../static/grafana/dash_duty_details.json | 0 .../static/grafana/dashboards.yml | 0 .../static/grafana/datasource.yml | 0 .../test-compose}/static/grafana/grafana.ini | 0 .../static/grafana/notifiers.yml | 0 .../static/lighthouse/Dockerfile | 0 .../test-compose}/static/lighthouse/run.sh | 0 .../test-compose}/static/lodestar/Dockerfile | 0 .../test-compose}/static/lodestar/run.sh | 0 .../test-compose}/static/loki/loki.yml | 0 .../test-compose}/static/tempo/tempo.yaml | 0 .../test-compose}/static/vouch/Dockerfile | 0 .../test-compose}/static/vouch/run.sh | 0 .../test-compose}/static/vouch/vouch.yml | 0 ...ockerCompose_define_create_template.golden | 0 ...TestDockerCompose_define_create_yml.golden | 0 ...stDockerCompose_define_dkg_template.golden | 0 .../TestDockerCompose_define_dkg_yml.golden | 0 ...e_lock_create_pluto_keygen_template.golden | 0 ...ompose_lock_create_pluto_keygen_yml.golden | 0 ...tDockerCompose_lock_create_template.golden | 0 .../TestDockerCompose_lock_create_yml.golden | 0 ...mpose_lock_dkg_mixed_impls_template.golden | 0 ...kerCompose_lock_dkg_mixed_impls_yml.golden | 0 ...TestDockerCompose_lock_dkg_template.golden | 0 .../TestDockerCompose_lock_dkg_yml.golden | 0 ...kerCompose_run_mixed_impls_template.golden | 0 ...stDockerCompose_run_mixed_impls_yml.golden | 0 .../TestDockerCompose_run_template.golden | 0 .../testdata/TestDockerCompose_run_yml.golden | 0 .../testdata/TestNewDefaultConfig.golden | 0 .../testdata/smoke/1_of_3_down.transcript | 20 + .../testdata/smoke/1_of_4_down.transcript | 20 + .../testdata/smoke/all_pluto.transcript | 22 + .../smoke/blinded_blocks_vmock.transcript | 20 + .../testdata/smoke/default_alpha.transcript | 23 + .../testdata/smoke/default_beta.transcript | 20 + .../testdata/smoke/default_stable.transcript | 20 + .../testdata/smoke/dkg.transcript | 20 + .../smoke/mixed_2_charon_2_pluto.transcript | 22 + .../testdata/smoke/pluto_dkg.transcript | 22 + .../smoke/pluto_keygen_create.transcript | 22 + crates/test-compose/testdata/smoke/shim.sh | 38 + .../testdata/smoke/very_large.transcript | 20 + crates/test-compose/tests/smoke.rs | 100 ++ crates/test-compose/tests/transcript.rs | 222 ++++ test-infra/compose/alert.go | 227 ---- test-infra/compose/alert_internal_test.go | 65 -- test-infra/compose/auto.go | 332 ------ test-infra/compose/compose_internal_test.go | 135 --- test-infra/compose/config.go | 294 ----- test-infra/compose/define.go | 629 ---------- test-infra/compose/go.mod | 184 --- test-infra/compose/go.sum | 1022 ----------------- test-infra/compose/lock.go | 185 --- test-infra/compose/new.go | 27 - test-infra/compose/new_test.go | 29 - test-infra/compose/rules_internal_test.go | 145 --- test-infra/compose/run.go | 139 --- test-infra/compose/smoke/smoke_test.go | 326 ------ test-infra/compose/template.go | 92 -- 89 files changed, 6438 insertions(+), 3901 deletions(-) create mode 100644 crates/test-compose/Cargo.toml rename {test-infra/compose => crates/test-compose}/README.md (59%) rename {test-infra/compose => crates/test-compose}/docker-compose.template (100%) create mode 100644 crates/test-compose/src/alert.rs create mode 100644 crates/test-compose/src/auto.rs create mode 100644 crates/test-compose/src/config.rs create mode 100644 crates/test-compose/src/define.rs create mode 100644 crates/test-compose/src/duration.rs create mode 100644 crates/test-compose/src/error.rs create mode 100644 crates/test-compose/src/fsutil.rs create mode 100644 crates/test-compose/src/golden_tests.rs create mode 100644 crates/test-compose/src/gotmpl.rs create mode 100644 crates/test-compose/src/lib.rs create mode 100644 crates/test-compose/src/lock.rs create mode 100644 crates/test-compose/src/new.rs create mode 100644 crates/test-compose/src/process.rs create mode 100644 crates/test-compose/src/run.rs create mode 100644 crates/test-compose/src/smoke.rs create mode 100644 crates/test-compose/src/static_files.rs create mode 100644 crates/test-compose/src/template.rs rename {test-infra/compose => crates/test-compose}/static/grafana/dash_alerts.json (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/dash_charon_overview.json (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/dash_duty_details.json (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/dashboards.yml (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/datasource.yml (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/grafana.ini (100%) rename {test-infra/compose => crates/test-compose}/static/grafana/notifiers.yml (100%) rename {test-infra/compose => crates/test-compose}/static/lighthouse/Dockerfile (100%) rename {test-infra/compose => crates/test-compose}/static/lighthouse/run.sh (100%) rename {test-infra/compose => crates/test-compose}/static/lodestar/Dockerfile (100%) rename {test-infra/compose => crates/test-compose}/static/lodestar/run.sh (100%) rename {test-infra/compose => crates/test-compose}/static/loki/loki.yml (100%) rename {test-infra/compose => crates/test-compose}/static/tempo/tempo.yaml (100%) rename {test-infra/compose => crates/test-compose}/static/vouch/Dockerfile (100%) rename {test-infra/compose => crates/test-compose}/static/vouch/run.sh (100%) rename {test-infra/compose => crates/test-compose}/static/vouch/vouch.yml (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_define_create_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_define_create_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_define_dkg_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_define_dkg_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_create_pluto_keygen_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_create_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_create_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_dkg_mixed_impls_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_dkg_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_lock_dkg_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_run_mixed_impls_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_run_mixed_impls_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_run_template.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestDockerCompose_run_yml.golden (100%) rename {test-infra/compose => crates/test-compose}/testdata/TestNewDefaultConfig.golden (100%) create mode 100644 crates/test-compose/testdata/smoke/1_of_3_down.transcript create mode 100644 crates/test-compose/testdata/smoke/1_of_4_down.transcript create mode 100644 crates/test-compose/testdata/smoke/all_pluto.transcript create mode 100644 crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript create mode 100644 crates/test-compose/testdata/smoke/default_alpha.transcript create mode 100644 crates/test-compose/testdata/smoke/default_beta.transcript create mode 100644 crates/test-compose/testdata/smoke/default_stable.transcript create mode 100644 crates/test-compose/testdata/smoke/dkg.transcript create mode 100644 crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript create mode 100644 crates/test-compose/testdata/smoke/pluto_dkg.transcript create mode 100644 crates/test-compose/testdata/smoke/pluto_keygen_create.transcript create mode 100644 crates/test-compose/testdata/smoke/shim.sh create mode 100644 crates/test-compose/testdata/smoke/very_large.transcript create mode 100644 crates/test-compose/tests/smoke.rs create mode 100644 crates/test-compose/tests/transcript.rs delete mode 100644 test-infra/compose/alert.go delete mode 100644 test-infra/compose/alert_internal_test.go delete mode 100644 test-infra/compose/auto.go delete mode 100644 test-infra/compose/compose_internal_test.go delete mode 100644 test-infra/compose/config.go delete mode 100644 test-infra/compose/define.go delete mode 100644 test-infra/compose/go.mod delete mode 100644 test-infra/compose/go.sum delete mode 100644 test-infra/compose/lock.go delete mode 100644 test-infra/compose/new.go delete mode 100644 test-infra/compose/new_test.go delete mode 100644 test-infra/compose/rules_internal_test.go delete mode 100644 test-infra/compose/run.go delete mode 100644 test-infra/compose/smoke/smoke_test.go delete mode 100644 test-infra/compose/template.go diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 375d126b..f9416551 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -8,13 +8,13 @@ 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,30 +24,47 @@ 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 + # uncached on a fresh runner), the harness build and the scenario matrix. + timeout-minutes: 100 steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v5 + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Update apt package list + run: sudo apt-get update + + # The smoke test binary links pluto-testutil, which pulls in the eth2api + # and protobuf code generation, so it needs the same build tools as + # test.yml. + - name: Install `protobuf` + uses: awalsh128/cache-apt-pkgs-action@v1.6.0 with: - go-version-file: test-infra/compose/go.mod - cache-dependency-path: test-infra/compose/go.sum + packages: protobuf-compiler=3.21.12* + version: 3.21.12 + + - 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. + # Built here rather than letting the harness do it inside the smoke + # run, so the release compile does not consume the smoke 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 @@ -57,8 +74,15 @@ jobs: docker build -t pluto:local \ --build-arg "GIT_COMMIT_HASH_SHORT=$(git rev-parse --short=7 HEAD)" . + - name: Build smoke harness + # Same reasoning: compiled in its own step so the smoke timeout bounds + # observation, and a build break fails here. + run: cargo test --locked -p pluto-test-compose --test smoke --no-run + - name: Run smoke tests - working-directory: test-infra/compose + # fromJSON: the input arrives as a number, but coercing through JSON + # keeps this valid should it ever arrive as a string. + timeout-minutes: ${{ fromJSON(inputs.smoke_timeout) }} # 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. @@ -66,26 +90,34 @@ jobs: # 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, so the artefacts they leave in the compose + # dir are root-owned; without this the runner cannot clean them 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 requires more CPU than a GitHub-hosted runner provides + # reliably. + args=(--ignored --nocapture --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 runs nothing, and passes, for a name that selects no test, + # so a typo would give a green run. Every name must select a test. + 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 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..17f20f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5693,6 +5693,26 @@ dependencies = [ "tree_hash", ] +[[package]] +name = "pluto-test-compose" +version = "1.7.1" +dependencies = [ + "k256", + "nix", + "pluto-eth2util", + "pluto-k1util", + "pluto-testutil", + "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..bdee6a5e --- /dev/null +++ b/crates/test-compose/Cargo.toml @@ -0,0 +1,30 @@ +[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] +pluto-testutil.workspace = true +tempfile.workspace = true +test-case.workspace = true +tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/test-infra/compose/README.md b/crates/test-compose/README.md similarity index 59% rename from test-infra/compose/README.md rename to crates/test-compose/README.md index c70750a3..9d2bdc81 100644 --- a/test-infra/compose/README.md +++ b/crates/test-compose/README.md @@ -9,19 +9,21 @@ 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/`: +in `tests/smoke.rs` — there is no standalone CLI. Cluster generation happens in +three stages, exposed as crate 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. +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`. +`auto` (see `src/auto.rs`) chains define → lock → run and runs `docker compose up`; it is +what the tests call after writing a config with `write_config`. + +This crate is test infrastructure: nothing in it ships in the `pluto` binary. ## Node implementations -Each node runs either charon or pluto, assigned round-robin from a scenario's `NodeImpls` +Each node runs either charon or pluto, assigned round-robin from a scenario's `node_impls` config (empty defaults to all charon): - Charon nodes run `obolnetwork/charon:{tag}` (smoke pins `v1.7.1`; the default config @@ -29,7 +31,7 @@ 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 +- `key_gen_impl` 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. @@ -46,9 +48,9 @@ other than `charon` or `pluto` is rejected. ## Smoke tests -`smoke/smoke_test.go` mirrors charon's compose smoke tests: each scenario generates +`tests/smoke.rs` 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 +evaluates the generated alert rules (see `write_alert_rules` in `src/define.rs`). A scenario fails if any alert fires. Alert semantics: collection starts once Prometheus answers its rules API. For the @@ -59,30 +61,48 @@ node at the first epoch boundary, before the validator mock submits duties), 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. +Prerequisites: a running Docker daemon and the workspace build prerequisites (see +`CONTRIBUTING.md`; the test binary links `pluto-testutil`, which needs `protoc` and +`oas3-gen` like the rest of the workspace). 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 needed. -``` -cd test-infra/compose +The scenarios are `#[ignore]`d tests named `scenario_`, so they only run when +asked for: +``` # 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)$' +PLUTO_REPO=$(git rev-parse --show-toplevel) cargo test -p pluto-test-compose --test smoke -- \ + --ignored --nocapture --exact \ + scenario_pluto_keygen_create scenario_all_pluto scenario_mixed_2_charon_2_pluto scenario_pluto_dkg # Full matrix (pluto + charon-only scenarios): -PLUTO_REPO=$(git rev-parse --show-toplevel) go test ./smoke -v -integration -timeout=35m +PLUTO_REPO=$(git rev-parse --show-toplevel) cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture -# Keep docker-compose logs per scenario: -go test ./smoke -v -integration -timeout=35m -log-dir=. +# The CI matrix: everything but the resource-heavy very_large scenario: +PLUTO_REPO=$(git rev-parse --show-toplevel) cargo test -p pluto-test-compose --test smoke -- \ + --ignored --nocapture --skip scenario_very_large + +# Keep docker-compose logs per scenario (/.log): +SMOKE_LOG_DIR=. cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture ``` -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. +Without `--exact`, a name is a substring filter (`dkg` selects `pluto_dkg` too). +Environment variables read by the suite: + +| Variable | Effect | +|----------|--------| +| `PLUTO_REPO` | pluto checkout to build `pluto:local` from; scenarios that run pluto (`pluto_keygen_create`, `all_pluto`, `mixed_2_charon_2_pluto`, `pluto_dkg`) skip when it is unset | +| `SMOKE_SUDO_PERMS=1` | fix root-owned artefacts with `sudo chown`/`chmod` after each step (containers run as root); needed where the caller must clean the compose dir up afterwards, e.g. CI | +| `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 instead of the compose one | + +There is no global timeout to set: each scenario is bounded by its own 2–3 minute alert +window plus image builds, and libtest has no overall deadline. Scenarios run one at a +time whatever `--test-threads` says, because clusters competing for CPU and memory +produce duty timeouts a sequential run never sees. `.github/workflows/smoke-tests.yml` +runs the CI matrix on manual dispatch and bounds the run with its step timeout. 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 @@ -115,13 +135,11 @@ Scenarios that intentionally degrade the cluster tune the gate via config, not t | 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) | +| `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 -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 +Charon is pinned to the pluto parity reference (`v1.7.1`) through the docker image tag +the smoke tests use: `CHARON_IMAGE_TAG` in `src/smoke.rs`. Bump it deliberately alongside +the parity target, not to track charon main. diff --git a/test-infra/compose/docker-compose.template b/crates/test-compose/docker-compose.template similarity index 100% rename from test-infra/compose/docker-compose.template rename to crates/test-compose/docker-compose.template diff --git a/crates/test-compose/src/alert.rs b/crates/test-compose/src/alert.rs new file mode 100644 index 00000000..e06104f1 --- /dev/null +++ b/crates/test-compose/src/alert.rs @@ -0,0 +1,732 @@ +//! 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, combined_output}, + duration::go_duration_string, + error::{CommandError, ComposeError, Result}, +}; + +/// Sentinel sent on the alert channel when polling was still healthy at the +/// end of the observation window. +pub const ALERTS_POLLED: &str = "alerts_polled"; + +/// 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()) +} + +/// Cadence of the alert collector. The defaults are what the harness runs +/// with; the knob exists so docker-free tests can finish quickly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AlertTiming { + /// Time after Prometheus first answers during which startup transients + /// are ignored. + pub warmup: Duration, + /// Time between two polls. + pub poll_interval: Duration, +} + +impl Default for AlertTiming { + fn default() -> Self { + Self { + warmup: ALERT_WARMUP, + poll_interval: ALERT_POLL_INTERVAL, + } + } +} + +/// 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: PromAnnotations, +} + +/// The annotations of an alert. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PromAlertAnnotations { + /// The rendered description. + #[serde(default)] + pub description: String, +} + +/// Annotations of an alert instance. +pub type PromAnnotations = PromAlertAnnotations; + +/// 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 + .map_err(|err| ComposeError::ExecCurlAlerts { + source: CommandError::Io(err), + out: String::new(), + })?; + + let out = combined_output(&output); + if !output.status.success() { + return Err(ComposeError::ExecCurlAlerts { + source: CommandError::Exit(output.status), + out, + }); + } + + 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 [`ALERTS_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, + timing: AlertTiming, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(100); + tokio::spawn(collect(token, poller, timing, tx)); + rx +} + +async fn collect( + token: CancellationToken, + poller: impl AlertPoller, + timing: AlertTiming, + tx: mpsc::Sender, +) { + let Some(ready_at) = await_prometheus_ready(&token, &poller, timing.poll_interval).await else { + return; + }; + + info!( + warmup = %go_duration_string(timing.warmup), + "Prometheus ready, collecting alerts" + ); + + // `None` only on Instant overflow, in which case warmup never ends. + let warmup_end = ready_at.checked_add(timing.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(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(active.description).await; + } + } + } + + sleep_or_cancel(&token, timing.poll_interval).await; + } + + if post_warmup_poll_ok && last_poll_ok { + let _ = tx.send(ALERTS_POLLED.to_string()).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, + poll_interval: Duration, +) -> 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, 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 = tokio::select! { + result = poller.query() => result, + () = token.cancelled() => return None, + }; + + (!token.is_cancelled()).then_some(result) +} + +async fn sleep_or_cancel(token: &CancellationToken, duration: Duration) { + tokio::select! { + () = time::sleep(duration) => {} + () = token.cancelled() => {} + } +} + +/// 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 test_case::test_case; + + 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); + } + + #[test] + fn prom_alerts_tolerates_missing_and_unknown_fields() { + let alerts: PromAlerts = serde_json::from_str( + r#"{"status":"success","extra":1,"data":{"groups":[{"rules":[{"alerts":[{}]}]}]}}"#, + ) + .expect("parse payload"); + + assert_eq!(alerts.status, "success"); + assert_eq!(alerts.data.groups.len(), 1); + assert!(get_active_alerts(&alerts).is_empty()); + } + + #[test] + fn alert_timing_default_matches_harness() { + assert_eq!( + AlertTiming::default(), + AlertTiming { + warmup: Duration::from_secs(60), + poll_interval: Duration::from_secs(2), + } + ); + } + + /// 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 with_status(status: &str) -> Result { + Ok(PromAlerts { + status: status.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::ExecCurlAlerts { + source: CommandError::Io(io::Error::other("no such container")), + out: String::new(), + }) + } + + /// 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, AlertTiming::default()); + + time::sleep(window).await; + token.cancel(); + + let mut messages = Vec::new(); + while let Some(message) = rx.recv().await { + messages.push(message); + } + + messages + } + + 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 messages = run_collector(WINDOW, |_| healthy()).await; + assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + } + + #[tokio::test(start_paused = true)] + async fn poller_dying_after_warmup_withholds_polled() { + let messages = + run_collector(WINDOW, |t| if t < secs(70) { healthy() } else { failing() }).await; + assert!(messages.is_empty(), "{messages:?}"); + } + + #[tokio::test(start_paused = true)] + async fn poller_recovering_before_deadline_reports_polled() { + let messages = run_collector(WINDOW, |t| { + if (secs(70)..secs(90)).contains(&t) { + failing() + } else { + healthy() + } + }) + .await; + assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + } + + #[tokio::test(start_paused = true)] + async fn never_ready_reports_nothing() { + let messages = run_collector(WINDOW, |_| failing()).await; + assert!(messages.is_empty(), "{messages:?}"); + } + + #[tokio::test(start_paused = true)] + async fn readiness_waits_for_success_status() { + let messages = run_collector(WINDOW, |t| { + if t < secs(5) { + with_status("error") + } else { + healthy() + } + }) + .await; + assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + } + + #[tokio::test(start_paused = true)] + async fn non_success_status_is_reported_every_poll_and_withholds_polled() { + let messages = run_collector(WINDOW, |t| { + if t < secs(10) { + healthy() + } else { + with_status("error") + } + }) + .await; + assert!(!messages.is_empty()); + assert!( + messages + .iter() + .all(|m| m == "non success status from prometheus alerts: error"), + "{messages:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn non_transient_alert_during_warmup_is_reported() { + let messages = run_collector(WINDOW, |t| { + if (secs(10)..secs(14)).contains(&t) { + firing(PLUTO_DOWN_RULE, "node0 is down") + } else { + healthy() + } + }) + .await; + assert_eq!( + messages, + vec!["node0 is down".to_string(), ALERTS_POLLED.to_string()] + ); + } + + #[tokio::test(start_paused = true)] + async fn transient_alert_only_during_warmup_is_ignored() { + let messages = run_collector(WINDOW, |t| { + if t < secs(30) { + firing(ERROR_RATE_RULE, "node0 has a high error rate") + } else { + healthy() + } + }) + .await; + assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + } + + #[tokio::test(start_paused = true)] + async fn transient_alert_outliving_warmup_is_reported_once() { + let messages = run_collector(WINDOW, |t| { + if t < secs(80) { + firing(ERROR_RATE_RULE, "node0 has a high error rate") + } else { + healthy() + } + }) + .await; + assert_eq!( + messages, + vec![ + "node0 has a high error rate".to_string(), + ALERTS_POLLED.to_string() + ] + ); + } + + #[tokio::test(start_paused = true)] + async fn persistent_alert_is_reported_once() { + let messages = 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!( + messages, + vec![ + "node1 has a high validator api error rate".to_string(), + ALERTS_POLLED.to_string() + ] + ); + } + + #[test_case(Duration::from_secs(60), "1m0s" ; "harness_default")] + #[test_case(Duration::from_secs(1), "1s" ; "one_second")] + fn warmup_is_logged_in_go_format(warmup: Duration, expected: &str) { + assert_eq!(go_duration_string(warmup), expected); + } + + /// 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, AlertTiming::default()); + + time::sleep(window).await; + token.cancel(); + + let drain = async { + let mut messages = Vec::new(); + while let Some(message) = rx.recv().await { + messages.push(message); + } + + messages + }; + + time::timeout(secs(10), drain) + .await + .expect("collector did not stop after cancel") + } + + #[tokio::test(start_paused = true)] + async fn stalled_readiness_query_stops_on_cancel() { + let messages = run_stalling(WINDOW, Duration::ZERO).await; + assert!(messages.is_empty(), "{messages:?}"); + } + + #[tokio::test(start_paused = true)] + async fn stalled_poll_during_warmup_stops_on_cancel_without_verdict() { + let messages = run_stalling(WINDOW, secs(1)).await; + assert!(messages.is_empty(), "{messages:?}"); + } + + #[tokio::test(start_paused = true)] + async fn stalled_poll_at_deadline_does_not_count_against_verdict() { + let messages = run_stalling(WINDOW, secs(100)).await; + assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + } +} diff --git a/crates/test-compose/src/auto.rs b/crates/test-compose/src/auto.rs new file mode 100644 index 00000000..660bd943 --- /dev/null +++ b/crates/test-compose/src/auto.rs @@ -0,0 +1,341 @@ +//! 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::{ + fmt, io, + path::{Path, PathBuf}, + time::Duration, +}; + +use tokio::{sync::mpsc, task}; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::{ + alert::{ALERTS_POLLED, AlertTiming, 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 = Box; + +/// Configuration of [`auto`]. +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, + /// Adjusts the define step template data. + pub define_tmpl_fn: Option, + /// Append the `docker compose up` output to this file instead of stdout. + pub log_file: Option, + /// Options of the define step. + pub define_options: DefineOptions, + /// Alert collector cadence. + pub timing: AlertTiming, +} + +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, + define_tmpl_fn: None, + log_file: None, + define_options: DefineOptions::default(), + timing: AlertTiming::default(), + } + } +} + +impl fmt::Debug for AutoConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AutoConfig") + .field("dir", &self.dir) + .field("alert_timeout", &self.alert_timeout) + .field("sudo_perms", &self.sudo_perms) + .field("print_yml", &self.print_yml) + .field("run_tmpl_fn", &self.run_tmpl_fn.is_some()) + .field("define_tmpl_fn", &self.define_tmpl_fn.is_some()) + .field("log_file", &self.log_file) + .field("define_options", &self.define_options) + .field("timing", &self.timing) + .finish() + } +} + +/// 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, + define_tmpl_fn, + log_file, + define_options, + timing, + } = 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", define_tmpl_fn, move |dir: &Path, conf| { + define(dir, conf, &define_options) + }) + .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. + let _ = down(&dir, sudo_perms).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), timing); + + 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, + // taking the whole cluster down with it. Nothing was observed for the + // full window, so this is a failure rather than "no alerts detected". + 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(alert) = alerts.recv().await { + if alert == ALERTS_POLLED { + polled = true; + } else { + detected.push(alert); + } + } + + 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, false, 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`, runs the generator step `run_fn` on it and, +/// when `up_after` is set, brings the resulting cluster up on stdout. `topic` +/// names the step in the log. +pub async fn run_step( + topic: &'static str, + dir: impl AsRef, + up_after: bool, + run_fn: F, +) -> Result +where + F: FnOnce(&Path, Config) -> Result + Send + 'static, +{ + let dir = dir.as_ref().to_path_buf(); + + let conf = match load_config(&dir) { + Err(ComposeError::LoadConfig(err)) if err.kind() == io::ErrorKind::NotFound => { + return Err(ComposeError::ConfigNotFound { + dir: dir.display().to_string(), + }); + } + other => other?, + }; + + info!(command = topic, "Running compose command"); + + let step_dir = dir.clone(); + let tmpl = 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) + } + })??; + + if up_after { + up(&dir, &LogSink::Stdout, &CancellationToken::new()).await?; + } + + Ok(tmpl) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Step, write_config}; + + #[tokio::test] + async fn run_step_without_config_reports_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + + let err = run_step("lock", dir.path(), false, |dir: &Path, conf| { + lock(dir, conf) + }) + .await + .expect_err("missing config must fail"); + + assert_eq!( + err.to_string(), + format!( + "compose config.json not found; write one with WriteConfig or New first: dir={}", + dir.path().display() + ) + ); + } + + #[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(), false, |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:?}" + ); + } + + #[tokio::test] + async fn run_step_surfaces_other_load_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("config.json"), "{").expect("write broken config"); + + let err = run_step("lock", dir.path(), false, |dir: &Path, conf| { + lock(dir, conf) + }) + .await + .expect_err("broken config must fail"); + + assert!(matches!(err, ComposeError::UnmarshalConfig(_)), "{err:?}"); + } + + #[test] + fn auto_config_defaults() { + let conf = AutoConfig::new("/tmp/compose"); + + assert_eq!(conf.dir, PathBuf::from("/tmp/compose")); + assert_eq!(conf.alert_timeout, Duration::ZERO); + assert!(!conf.sudo_perms); + assert!(!conf.print_yml); + assert!(conf.run_tmpl_fn.is_none()); + assert!(conf.define_tmpl_fn.is_none()); + assert!(conf.log_file.is_none()); + assert!(conf.define_options.pull_images); + assert_eq!(conf.timing, AlertTiming::default()); + assert!(format!("{conf:?}").contains("run_tmpl_fn: false")); + } +} diff --git a/crates/test-compose/src/config.rs b/crates/test-compose/src/config.rs new file mode 100644 index 00000000..a4be9821 --- /dev/null +++ b/crates/test-compose/src/config.rs @@ -0,0 +1,643 @@ +//! Compose cluster configuration (`config.json`). + +use std::{fmt, fs, path::Path, time::Duration}; + +use serde::{Deserialize, Deserializer, Serialize, de}; + +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_KEY_GEN: KeyGen = KeyGen::Create; +const DEFAULT_NUM_VALS: usize = 1; +const DEFAULT_NUM_NODES: usize = 4; +const DEFAULT_THRESHOLD: usize = 3; +const DEFAULT_FEATURE_SET: &str = "alpha"; + +const CHARON_IMAGE: &str = "obolnetwork/charon"; +const PLUTO_IMAGE: &str = "pluto"; + +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, 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. + 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)] +#[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", + } + } + + fn parse(s: &str) -> Option { + match s { + "charon" => Some(NodeImpl::Charon), + "pluto" => Some(NodeImpl::Pluto), + _ => None, + } + } +} + +impl fmt::Display for NodeImpl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for NodeImpl { + fn deserialize>(deserializer: D) -> std::result::Result { + let name = String::deserialize(deserializer)?; + NodeImpl::parse(&name).ok_or_else(|| { + de::Error::custom(format!( + "unknown node implementation; must be charon or pluto: impl={name}" + )) + }) + } +} + +/// Compose workflow step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Step { + /// Config written, nothing generated yet. + 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, and unknown names are rejected with the keygen-specific message. +mod keygen_impl { + use serde::{Deserialize, Deserializer, Serializer, de}; + + 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::parse(&name).map(Some).ok_or_else(|| { + de::Error::custom(format!( + "unknown keygen implementation; must be charon or pluto: impl={name}" + )) + }) + } +} + +/// 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, 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 Default for Config { + fn default() -> Self { + Self { + version: String::new(), + step: Step::New, + num_nodes: 0, + threshold: 0, + num_validators: 0, + image_tag: String::new(), + build_local: false, + node_impls: Vec::new(), + key_gen_impl: None, + pluto_image_tag: String::new(), + key_gen: KeyGen::Create, + split_keys_dir: String::new(), + beacon_nodes: String::new(), + external_relay: String::new(), + vcs: Vec::new(), + feature_set: String::new(), + disable_monitoring_ports: false, + insecure_keys: false, + slot_duration: Duration::ZERO, + beacon_fuzz: false, + p2p_fuzz: false, + synthetic_block_proposals: false, + monitoring: false, + builder_api: false, + alert_exclude_jobs: Vec::new(), + alert_disable_rules: Vec::new(), + } + } +} + +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: DEFAULT_KEY_GEN, + 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 { + index + .checked_rem(self.node_impls.len()) + .and_then(|i| self.node_impls.get(i)) + .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 full docker image reference for an implementation. + pub fn impl_image(&self, node_impl: NodeImpl) -> String { + match node_impl { + NodeImpl::Pluto => { + let tag = &self.pluto_image_tag; + format!("{PLUTO_IMAGE}:{tag}") + } + NodeImpl::Charon => { + let tag = &self.image_tag; + format!("{CHARON_IMAGE}:{tag}") + } + } + } + + /// 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 => self.impl_image(node_impl), + 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::WriteConfig) +} + +/// 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::LoadConfig)?; + + 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(&[], 0, NodeImpl::Charon ; "empty_defaults_to_charon")] + #[test_case(&[NodeImpl::Pluto], 3, NodeImpl::Pluto ; "single_cycles")] + #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 0, NodeImpl::Charon ; "mixed_first")] + #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 1, NodeImpl::Pluto ; "mixed_second")] + #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 2, NodeImpl::Charon ; "mixed_wraps")] + 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 keygen_impl_falls_back_to_node0() { + let mut conf = Config::new_default(); + conf.node_impls = vec![NodeImpl::Pluto, NodeImpl::Charon]; + assert_eq!(conf.keygen_impl(), NodeImpl::Pluto); + + conf.key_gen_impl = Some(NodeImpl::Charon); + assert_eq!(conf.keygen_impl(), NodeImpl::Charon); + } + + #[test] + fn images() { + let conf = Config { + image_tag: "v1".to_string(), + pluto_image_tag: "dev".to_string(), + ..Config::new_default() + }; + assert_eq!(conf.impl_image(NodeImpl::Charon), "obolnetwork/charon:v1"); + assert_eq!(conf.impl_image(NodeImpl::Pluto), "pluto:dev"); + assert_eq!(conf.image_override(NodeImpl::Charon), ""); + assert_eq!(conf.image_override(NodeImpl::Pluto), "pluto:dev"); + } + + #[test] + fn uses_pluto_checks_nodes_and_keygen() { + let mut conf = Config::new_default(); + assert!(!conf.uses_pluto()); + + conf.key_gen_impl = Some(NodeImpl::Pluto); + assert!(conf.uses_pluto()); + + conf.key_gen_impl = None; + conf.node_impls = vec![ + NodeImpl::Charon, + NodeImpl::Charon, + NodeImpl::Charon, + NodeImpl::Pluto, + ]; + assert!(conf.uses_pluto()); + + // A pluto entry beyond num_nodes is never reached. + conf.num_nodes = 3; + assert!(!conf.uses_pluto()); + } + + #[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 missing_fields_take_zero_values() { + let conf: Config = + serde_json::from_str(r#"{"version":"obol/charon/compose/1.0.0"}"#).expect("unmarshal"); + assert_eq!(conf.version, VERSION); + assert_eq!(conf.num_nodes, 0); + assert!(conf.node_impls.is_empty()); + assert_eq!(conf.key_gen_impl, None); + assert_eq!(conf.slot_duration, Duration::ZERO); + } + + #[test] + fn null_lists_load_as_empty() { + let conf: Config = serde_json::from_str(r#"{"node_impls":null,"validator_clients":null}"#) + .expect("unmarshal"); + assert!(conf.node_impls.is_empty()); + assert!(conf.vcs.is_empty()); + } + + #[test] + fn empty_lists_serialize_as_null() { + let conf = Config { + node_impls: Vec::new(), + vcs: Vec::new(), + ..Config::new_default() + }; + let json: serde_json::Value = + serde_json::from_slice(&marshal_indent(&conf).expect("marshal")).expect("parse"); + assert_eq!(json["node_impls"], serde_json::Value::Null); + assert_eq!(json["validator_clients"], serde_json::Value::Null); + assert_eq!( + json["keygen_impl"], + serde_json::Value::String(String::new()) + ); + assert!(json.get("alert_exclude_jobs").is_none()); + assert!(json.get("alert_disable_rules").is_none()); + } + + #[test] + fn config_validate_rejects_unknown_impl() { + // Enum-typed implementations cannot hold unknown names in memory, so + // the write-side assertions have no Rust counterpart; loading a + // hand-edited config with a bad impl still fails. + 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 node implementation"), + "{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 keygen implementation"), + "{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); + } + + #[test] + fn load_config_missing_file_keeps_not_found_kind() { + let dir = tempfile::tempdir().expect("tempdir"); + match load_config(dir.path()) { + Err(ComposeError::LoadConfig(err)) => { + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + other => panic!("unexpected result: {other:?}"), + } + } +} diff --git a/crates/test-compose/src/define.rs b/crates/test-compose/src/define.rs new file mode 100644 index 00000000..55da3974 --- /dev/null +++ b/crates/test-compose/src/define.rs @@ -0,0 +1,898 @@ +//! Cluster definition step, compose directory cleaning and image builds. + +use std::{ + env, fmt, fs, io, + path::Path, + process::{Command, Output}, +}; + +use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; +use pluto_eth2util::{enr::Record, network::GOERLI}; +use tracing::info; + +use crate::{ + Result, + config::{CMD_CREATE_DKG, CONFIG_FILE, Config, KeyGen, Step, write_config}, + error::{CommandError, ComposeError}, + fsutil::{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 `write_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, +]; + +/// Error a key generator may return. +pub type KeyGenError = Box; + +/// Generator for node p2p private keys. +pub type KeyGenFn = Box std::result::Result + Send + Sync>; + +/// Knobs for [`define`] that are process-wide toggles in the Go harness. +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: Box::new(|| Ok(SecretKey::random(&mut OsRng))), + } + } +} + +impl fmt::Debug for DefineOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DefineOptions") + .field("pull_images", &self.pull_images) + .field("key_gen", &"") + .finish() + } +} + +/// Deletes all compose artefacts in `dir`. +/// +/// The directory is only cleaned when its listing contains a `config.json` +/// entry whose full path is exactly `config.json`, i.e. when `dir` is the +/// working directory; anything else is reported as "config.json not found" +/// and left alone. Entries with `key` in their path are never deleted so a +/// long-lived split-keys folder survives. +pub fn clean(dir: impl AsRef) -> Result<()> { + let dir = dir.as_ref().to_string_lossy(); + let files = glob_all(&dir); + + // Make sure we ONLY delete compose artifacts. + let mut config_found = false; + let mut go_found = false; + + for file in &files { + if file == CONFIG_FILE { + config_found = true; + } else if file.ends_with(".go") || file.starts_with("go.") { + go_found = true; + } + } + + if !config_found { + info!("Not cleaning since config.json not found"); + return Ok(()); + } else if go_found { + return Err(ComposeError::GoFilesFound { + dir: dir.into_owned(), + }); + } + + info!(files = files.len(), "Cleaning compose dir"); + + for file in &files { + if file.contains("key") { + // Do not delete root folder with key in the name, since it might be + // long-lived split keys folder. + info!(path = %file, "Not deleting *key* folder"); + continue; + } + + remove_all(file).map_err(ComposeError::RemoveFile)?; + } + + Ok(()) +} + +/// Lists `dir/*` the way Go's `filepath.Glob(path.Join(dir, "*"))` does: +/// sorted, dotfiles included, each entry joined onto the cleaned directory, +/// and an unreadable or missing directory yielding no entries. +fn glob_all(dir: &str) -> Vec { + let pattern = go_path_join(dir, "*"); + let dir_part = match pattern.rfind('/') { + Some(i) => &pattern[..=i], + None => "", + }; + let dir_part = match dir_part { + "" => ".", + "/" => "/", + d => d.strip_suffix('/').unwrap_or(d), + }; + + let Ok(entries) = fs::read_dir(dir_part) else { + return Vec::new(); + }; + + let mut names: Vec = entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort_unstable(); + + names + .iter() + .map(|name| go_path_join(dir_part, name)) + .collect() +} + +/// Removes a file or directory tree; a missing path is not an error. +fn remove_all(path: &str) -> io::Result<()> { + let result = match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => fs::remove_dir_all(path), + Ok(_) => fs::remove_file(path), + Err(err) => Err(err), + }; + + match result { + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + other => other, + } +} + +/// 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()?; + } + + 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_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 p2pkeys = new_p2p_keys(conf.num_nodes, &opts.key_gen)?; + + let mut enrs = Vec::with_capacity(p2pkeys.len()); + + for (i, key) in p2pkeys.iter().enumerate() { + // 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)) + .map_err(ComposeError::SaveEnrPrivateKey)?; + + let record = Record::from_key(key)?; + enrs.push(record.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)?; + write_prometheus_config(dir, &conf)?; + write_alert_rules(dir, &conf)?; + + 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::AbsDir)?; + let target = go_abs(split_keys_dir).map_err(ComposeError::AbsDir)?; + + 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", "obolnetwork/charon:latest"]) + .status() + .map_err(|err| ComposeError::RunDockerPull(CommandError::Io(err)))?; + + if !status.success() { + return Err(ComposeError::RunDockerPull(CommandError::Exit(status))); + } + + Ok(()) +} + +/// Builds the `obolnetwork/charon:local` docker image from the checkout the +/// `CHARON_REPO` environment variable points at. +pub fn build_local() -> Result<()> { + let repo = repo_from_env("CHARON_REPO").ok_or(ComposeError::CharonRepoNotSet)?; + + info!(repo = %repo, "Building `obolnetwork/charon:local` docker container"); + + docker_build(&repo, &["build", "-t", "obolnetwork/charon:local", "."]) +} + +/// Builds the `pluto:local` docker image from the checkout the `PLUTO_REPO` +/// environment variable points at. +/// +/// 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_pluto() -> Result<()> { + let repo = repo_from_env("PLUTO_REPO").ok_or(ComposeError::PlutoRepoNotSet)?; + + info!(repo = %repo, "Building `pluto:local` docker container"); + + let mut args = vec![ + "build".to_string(), + "-t".to_string(), + "pluto:local".to_string(), + ]; + + if let Ok(hash) = git_commit_hash_short(&repo) { + args.push("--build-arg".to_string()); + args.push(format!("GIT_COMMIT_HASH_SHORT={hash}")); + } + + args.push(".".to_string()); + + docker_build(&repo, &args) +} + +/// Reads a repo path from the environment; unset, empty or non-UTF-8 values +/// count as not set. +fn repo_from_env(var: &str) -> Option { + env::var(var).ok().filter(|repo| !repo.is_empty()) +} + +/// Runs `docker ` in `repo`, reporting the combined output on failure. +fn docker_build>(repo: &str, args: &[S]) -> Result<()> { + let output = Command::new("docker") + .args(args) + .current_dir(repo) + .output() + .map_err(|err| ComposeError::ExecDockerBuild { + source: CommandError::Io(err), + output: String::new(), + })?; + + if !output.status.success() { + return Err(ComposeError::ExecDockerBuild { + source: CommandError::Exit(output.status), + output: combined_output(&output), + }); + } + + Ok(()) +} + +/// 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 +} + +/// 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() + .map_err(|err| ComposeError::GitRevParse(CommandError::Io(err)))?; + + if !output.status.success() { + return Err(ComposeError::GitRevParse(CommandError::Exit(output.status))); + } + + 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<()> { + for file in STATIC_FILES { + let sub_dir = dir.join(file.dir); + mkdir_all(&sub_dir, 0o755).map_err(ComposeError::MkdirAll)?; + + let mode = if file.name.ends_with(".sh") { + 0o755 + } else { + 0o644 + }; + + write_file(sub_dir.join(file.name), file.bytes, mode).map_err(ComposeError::WriteFile)?; + } + + Ok(()) +} + +/// Writes 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 write_prometheus_config(dir: &Path, conf: &Config) -> Result<()> { + 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 +", + ); + + let prom_dir = dir.join("prometheus"); + mkdir_all(&prom_dir, 0o755).map_err(ComposeError::MkdirPrometheus)?; + + write_file(prom_dir.join("prometheus.yml"), b, 0o644).map_err(ComposeError::WritePrometheusYml) +} + +/// Writes 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 write_alert_rules(dir: &Path, conf: &Config) -> Result<()> { + // 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(",")) + } + }; + + // Warn topics that are mock artefacts, not node behaviour: the validator + // mock warns about pending duties before the first epoch, and the tracker + // warns about every broadcast the mock beacon node never includes on-chain. + let warn_topics = "vmock|tracker"; + + // Inject a zero for every scraped node job (`0 * up`) so a node with no + // core_bcast_broadcast_total series at all (the counter is only created + // on first broadcast) alerts too. Summed per job because the per-duty + // sync_message series legitimately pauses most epochs. Scoped to node + // jobs: the relay never broadcasts duties. + 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 instead of charon's absolute app_log_error_total > 0: a + // fresh simnet cluster logs exactly one consensus timeout error per + // node at the first epoch boundary, which an absolute counter gate + // could never recover from. + ( + 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 mut b = String::from("groups:\n- name: pluto\n rules:\n"); + + for (name, block) in &rule_blocks { + if conf.alert_disable_rules.iter().any(|rule| rule == name) { + continue; + } + + b.push_str(block); + b.push('\n'); + } + + let rules = b.strip_suffix('\n').unwrap_or(&b); + + let prom_dir = dir.join("prometheus"); + mkdir_all(&prom_dir, 0o755).map_err(ComposeError::MkdirPrometheus)?; + + write_file(prom_dir.join("rules.yml"), rules, 0o644).map_err(ComposeError::WriteRulesYml) +} + +/// 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}\" +" + ) +} + +/// Generates `n` node p2p private keys with `key_gen`. +fn new_p2p_keys(n: usize, key_gen: &KeyGenFn) -> Result> { + (0..n) + .map(|_| key_gen().map_err(ComposeError::NewKey)) + .collect() +} + +/// 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 std::fs; + + use super::*; + + /// Writes alert rules for `conf` into a temp dir and returns them. + fn write_rules(conf: &Config) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + write_alert_rules(dir.path(), conf).expect("write alert rules"); + + fs::read_to_string(dir.path().join("prometheus").join("rules.yml")).expect("read rules.yml") + } + + /// 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 write_prometheus_config_scrapes_all_nodes() { + let dir = tempfile::tempdir().expect("tempdir"); + + let mut conf = Config::new_default(); + conf.num_nodes = 10; + + write_prometheus_config(dir.path(), &conf).expect("write prometheus config"); + + let content = fs::read_to_string(dir.path().join("prometheus").join("prometheus.yml")) + .expect("read prometheus.yml"); + 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 write_alert_rules_broadcast_covers_missing_series() { + let content = write_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 write_alert_rules_excludes_degraded_jobs() { + let mut conf = Config::new_default(); + conf.alert_exclude_jobs = vec!["node0".to_string()]; + + let content = write_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 write_alert_rules_warn_topics() { + let content = write_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 write_alert_rules_drops_outstanding_duty() { + let content = write_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 write_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 = write_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 write_alert_rules_has_no_trailing_newline_and_all_rules() { + let content = write_rules(&Config::new_default()); + assert!(content.starts_with("groups:\n- name: pluto\n rules:\n")); + // The blank-line separator after the last block is trimmed; the block's + // own newline stays. + assert!(content.ends_with("\"\n"), "{content:?}"); + assert!(!content.ends_with("\n\n"), "{content:?}"); + for name in ALERT_RULE_NAMES { + assert!( + content.contains(&format!(" - alert: {name}\n")), + "{content}" + ); + } + } + + #[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" + ); + } + + #[test] + fn define_rejects_split_keys_dir_outside_compose_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("tempdir"); + + let mut conf = Config::new_default(); + conf.split_keys_dir = outside.path().to_string_lossy().into_owned(); + + let opts = DefineOptions { + pull_images: false, + ..DefineOptions::default() + }; + let err = define(dir.path(), conf, &opts).expect_err("must fail"); + assert!( + err.to_string() + .starts_with("split-keys-dir must be a child of compose dir: relative=.."), + "{err}" + ); + } + + #[test] + fn rel_split_keys_dir_variants() { + assert_eq!(rel_split_keys_dir("/a/b", "").expect("empty"), ""); + assert_eq!( + rel_split_keys_dir("/a/b", "/a/b/keys").expect("child"), + "keys" + ); + assert_eq!( + rel_split_keys_dir("/a/b", "/a/keys").expect("sibling"), + "../keys" + ); + } + + #[test] + fn clean_leaves_dir_when_config_path_is_not_bare() { + // Entries are compared by full path, so a config.json below a real + // directory is never recognised and nothing is deleted. + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join(CONFIG_FILE), "{}").expect("write config"); + fs::write(dir.path().join("docker-compose.yml"), "x").expect("write yml"); + + clean(dir.path()).expect("clean"); + + assert!(dir.path().join(CONFIG_FILE).exists()); + assert!(dir.path().join("docker-compose.yml").exists()); + } + + #[test] + fn glob_all_lists_sorted_joined_entries_and_tolerates_missing_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("b"), "").expect("write"); + fs::write(dir.path().join("a"), "").expect("write"); + fs::write(dir.path().join(".hidden"), "").expect("write"); + + let dir_str = dir.path().to_string_lossy().into_owned(); + let got = glob_all(&format!("{dir_str}/")); + assert_eq!( + got, + vec![ + format!("{dir_str}/.hidden"), + format!("{dir_str}/a"), + format!("{dir_str}/b"), + ] + ); + + assert!(glob_all(&format!("{dir_str}/does-not-exist")).is_empty()); + } + + #[test] + fn node_file_paths() { + assert_eq!(node_file("/c", 0, ""), "/c/node0"); + assert_eq!( + node_file("/c/", 2, "charon-enr-private-key"), + "/c/node2/charon-enr-private-key" + ); + assert_eq!(node_file("", 1, ""), "node1"); + } + + #[test] + fn copy_static_folders_writes_all_files_with_modes() { + let dir = tempfile::tempdir().expect("tempdir"); + copy_static_folders(dir.path()).expect("copy"); + + for file in STATIC_FILES { + let path = dir.path().join(file.dir).join(file.name); + assert_eq!(fs::read(&path).expect("read"), file.bytes, "{path:?}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::metadata(&path).expect("meta").permissions().mode() & 0o777; + let want = if file.name.ends_with(".sh") { + 0o755 + } else { + 0o644 + }; + assert_eq!(mode, want, "{path:?}"); + } + } + } +} diff --git a/crates/test-compose/src/duration.rs b/crates/test-compose/src/duration.rs new file mode 100644 index 00000000..1723504a --- /dev/null +++ b/crates/test-compose/src/duration.rs @@ -0,0 +1,104 @@ +//! 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(1, "1ns")] + #[test_case(999, "999ns")] + #[test_case(1000, "1µs" ; "one_microsecond")] + #[test_case(1500, "1.5µs" ; "one_and_half_microseconds")] + #[test_case(999_999, "999.999µs" ; "just_under_one_millisecond")] + #[test_case(1_000_000, "1ms")] + #[test_case(12_000_000, "12ms")] + #[test_case(500_000_000, "500ms")] + #[test_case(999_999_999, "999.999999ms")] + #[test_case(1_000_000_000, "1s")] + #[test_case(1_500_000_000, "1.5s")] + #[test_case(59_000_000_000, "59s")] + #[test_case(60_000_000_000, "1m0s")] + #[test_case(61_000_000_000, "1m1s")] + #[test_case(120_000_000_000, "2m0s")] + #[test_case(5_400_000_000_000, "1h30m0s")] + #[test_case(3_600_000_000_000, "1h0m0s")] + #[test_case(3_661_500_000_000, "1h1m1.5s")] + #[test_case(360_000_000_000_000, "100h0m0s")] + #[test_case(1_234_567_890_123, "20m34.567890123s")] + #[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..5914db89 --- /dev/null +++ b/crates/test-compose/src/error.rs @@ -0,0 +1,282 @@ +use std::{io, process::ExitStatus}; + +use pluto_eth2util::enr::RecordError; +use pluto_k1util::K1UtilError; + +use crate::{config::Step, gotmpl}; + +/// Failure of a child process such as `docker` or `git`. +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + /// The process could not be spawned or waited on. + #[error(transparent)] + Io(#[from] io::Error), + + /// The process ran but exited unsuccessfully. + #[error("{0}")] + Exit(ExitStatus), +} + +/// Errors returned by the compose generator. +#[derive(Debug, thiserror::Error)] +pub enum ComposeError { + /// The directory holds Go sources, so it is not a compose directory. + #[error("go files found, compose dir incorrect: dir={dir}")] + GoFilesFound { + /// The directory that was about to be cleaned. + dir: String, + }, + + /// Deleting a compose artefact failed. + #[error("remove file: {0}")] + RemoveFile(#[source] io::Error), + + /// `define` requires a config at the `new` step. + #[error("compose config not new, so can't be defined: step={step}")] + NotNew { + /// The step the config is actually at. + step: Step, + }, + + /// `lock` requires a config at the `defined` step. + #[error("compose config not defined, so can't be locked: step={step}")] + NotDefined { + /// The step the config is actually at. + step: Step, + }, + + /// `run` requires a config at the `locked` step. + #[error("compose config not locked, so can't be run: step={step}")] + NotLocked { + /// The step the config is actually at. + step: Step, + }, + + /// The configured key generator failed to produce a p2p key. + #[error("new key: {0}")] + NewKey(#[source] Box), + + /// Writing a node's ENR private key failed. + #[error("save charon-enr-private-key: {0}")] + SaveEnrPrivateKey(#[source] K1UtilError), + + /// Building a node's ENR failed. + #[error(transparent)] + Enr(#[from] RecordError), + + /// The split keys directory is outside the compose directory. + #[error("split-keys-dir must be a child of compose dir: relative={relative}")] + SplitKeysDirNotChild { + /// The split keys directory relative to the compose directory. + relative: String, + }, + + /// Resolving a directory to an absolute path failed. + #[error("abs dir: {0}")] + AbsDir(#[source] io::Error), + + /// The split keys directory cannot be expressed relative to the compose + /// directory. + #[error("relative split keys dir: Rel: can't make {target} relative to {base}")] + RelativeSplitKeysDir { + /// The absolute compose directory. + base: String, + /// The absolute split keys directory. + target: String, + }, + + /// `docker pull` failed. + #[error("run docker pull: {0}")] + RunDockerPull(#[source] CommandError), + + /// A local charon build was requested without `CHARON_REPO`. + #[error( + "cannot build local charon binary; CHARON_REPO env var, the path to the charon repo, is not set" + )] + CharonRepoNotSet, + + /// A local pluto build was requested without `PLUTO_REPO`. + #[error( + "cannot build local pluto binary; PLUTO_REPO env var, the path to the pluto repo, is not set" + )] + PlutoRepoNotSet, + + /// `docker build` failed. + #[error("exec docker build: {source}: output={output}")] + ExecDockerBuild { + /// The process failure. + #[source] + source: CommandError, + /// Combined stdout and stderr of the build. + output: String, + }, + + /// `git rev-parse` failed. + #[error("git rev-parse: {0}")] + GitRevParse(#[source] CommandError), + + /// Creating a static config directory failed. + #[error("mkdir all: {0}")] + MkdirAll(#[source] io::Error), + + /// Writing a static config file failed. + #[error("write file: {0}")] + WriteFile(#[source] io::Error), + + /// Creating the prometheus directory failed. + #[error("mkdir prometheus: {0}")] + MkdirPrometheus(#[source] io::Error), + + /// Writing the prometheus scrape config failed. + #[error("write prometheus.yml: {0}")] + WritePrometheusYml(#[source] io::Error), + + /// Writing the prometheus alert rules failed. + #[error("write rules.yml: {0}")] + WriteRulesYml(#[source] io::Error), + + /// `alert_disable_rules` names a rule that does not exist. + #[error("unknown alert rule name in alert_disable_rules: rule={rule}")] + UnknownAlertRule { + /// The unknown rule name. + rule: String, + }, + + /// Serialising the config failed. + #[error("marshal config: {0}")] + MarshalConfig(#[source] serde_json::Error), + + /// Writing `config.json` failed. + #[error("write config: {0}")] + WriteConfig(#[source] io::Error), + + /// Reading `config.json` failed. + #[error("load config: {0}")] + LoadConfig(#[source] io::Error), + + /// Parsing `config.json` failed. + #[error("unmarshal Config: {0}")] + UnmarshalConfig(#[source] serde_json::Error), + + /// `run` needs at least one validator client type to cycle through. + #[error("no validator clients configured")] + NoValidatorClients, + + /// A node's external port offset does not fit the port type. + #[error("external port overflow: node={index}")] + PortOverflow { + /// The node index whose ports overflowed. + index: usize, + }, + + /// Rendering the teku command template failed. + #[error("teku template: {0}")] + TekuTemplate(#[source] gotmpl::Error), + + /// Parsing the docker-compose template failed. + #[error("new template: {0}")] + NewTemplate(#[source] gotmpl::Error), + + /// Rendering the docker-compose template failed. + #[error("exec template: {0}")] + ExecTemplate(#[source] gotmpl::Error), + + /// Writing `docker-compose.yml` failed. + #[error("write docker-compose.yml: {0}")] + WriteDockerCompose(#[source] io::Error), + + /// Opening the `docker compose up` log file failed. + #[error("open log file: {0}")] + OpenLogFile(#[source] io::Error), + + /// Printing `docker-compose.yml` with `cat` failed. + #[error("exec cat docker-compose.yml: {0}")] + ExecCatDockerCompose(#[source] CommandError), + + /// A `sudo` command fixing artefact permissions failed. + #[error("exec sudo {program}: {source}")] + ExecSudo { + /// The program run under sudo (`chown` or `chmod`). + program: String, + /// The process failure. + #[source] + source: CommandError, + }, + + /// `docker compose down` failed. + #[error("run down: {0}")] + RunDown(#[source] CommandError), + + /// `docker compose build` failed. + #[error("exec docker compose build: {source}: output={output}")] + ExecComposeBuild { + /// The process failure. + #[source] + source: CommandError, + /// Combined stdout and stderr of the build. + output: String, + }, + + /// `docker compose up` failed. + #[error("exec docker compose up: {0}")] + ExecComposeUp(#[source] CommandError), + + /// `docker compose up --no-start --build` failed. + #[error("exec docker compose up --no-start --build: {source}: output={output}")] + ExecComposeCreate { + /// The process failure. + #[source] + source: CommandError, + /// Combined stdout and stderr of the command. + output: String, + }, + + /// The cluster exited before the alert observation window elapsed. + #[error("cluster stopped before the observation window elapsed")] + ClusterStopped, + + /// Prometheus polling was not still healthy when the window closed. + #[error("prometheus was not polled successfully through the end of the observation window")] + PrometheusNotPolled, + + /// Alerts fired while the cluster was observed. + #[error("alerts detected: alerts=[{}]", .alerts.join(" "))] + AlertsDetected { + /// Descriptions of the firing alerts, in the order they were detected. + alerts: Vec, + }, + + /// The compose directory holds no `config.json`. + #[error("compose config.json not found; write one with WriteConfig or New first: dir={dir}")] + ConfigNotFound { + /// The compose directory. + dir: String, + }, + + /// Querying the Prometheus rules API through the `curl` container failed. + #[error("exec curl alerts: {source}: out={out}")] + ExecCurlAlerts { + /// The process failure. + #[source] + source: CommandError, + /// Combined stdout and stderr of the query. + out: String, + }, + + /// Parsing the Prometheus rules API response failed. + #[error("unmarshal alerts: {source}: out={out}")] + UnmarshalAlerts { + /// The parse failure. + #[source] + source: serde_json::Error, + /// The response that failed to parse. + out: String, + }, + + /// The blocking generator step was cancelled before it completed. + #[error("run step: {0}")] + StepCancelled(#[source] tokio::task::JoinError), +} + +/// 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..5b4956ce --- /dev/null +++ b/crates/test-compose/src/fsutil.rs @@ -0,0 +1,173 @@ +//! File and path helpers with Go `os`/`path` semantics where the generated +//! output depends on them. + +use std::{fs, io, path::Path}; + +/// 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 test_case::test_case; + + use super::*; + + #[test_case("", "." ; "empty")] + #[test_case(".", "." ; "dot")] + #[test_case("a/b/c", "a/b/c" ; "already_clean")] + #[test_case("a//b/./c/", "a/b/c" ; "collapse")] + #[test_case("a/b/../c", "a/c" ; "dotdot")] + #[test_case("a/../..", ".." ; "dotdot_escapes")] + #[test_case("/..", "/" ; "rooted_dotdot")] + #[test_case("/tmp/x/", "/tmp/x" ; "trailing_slash")] + #[test_case("./config.json", "config.json" ; "leading_dot")] + fn path_clean(input: &str, want: &str) { + assert_eq!(go_path_clean(input), want); + } + + #[test_case("", "*", "*" ; "empty_dir")] + #[test_case(".", "*", "*" ; "dot_dir")] + #[test_case("/compose", "keys", "/compose/keys" ; "abs")] + #[test_case("/compose", "./keys/", "/compose/keys" ; "cleans")] + #[test_case("dir/", "node0", "dir/node0" ; "trailing_slash")] + fn path_join(a: &str, b: &str, want: &str) { + assert_eq!(go_path_join(a, b), want); + } + + #[test_case("/a/b", "/a/b", Some(".") ; "same")] + #[test_case("/a/b", "/a/b/c", Some("c") ; "child")] + #[test_case("/a/b", "/a/b/c/d", Some("c/d") ; "grandchild")] + #[test_case("/a/b", "/a", Some("..") ; "parent")] + #[test_case("/a/b", "/c", Some("../../c") ; "sibling_tree")] + #[test_case("/", "/a", Some("a") ; "from_root")] + fn rel(base: &str, target: &str, want: Option<&str>) { + assert_eq!(go_rel(base, target), want.map(str::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..6387346c --- /dev/null +++ b/crates/test-compose/src/golden_tests.rs @@ -0,0 +1,165 @@ +//! 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 pluto_testutil::random::generate_insecure_k1_key; +use test_case::test_case; + +use crate::{ + Config, DefineOptions, KeyGen, NodeImpl, Result, Step, TmplData, config::marshal_indent, + define, lock, new, run, +}; + +const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata"); + +/// Deterministic define options matching the Go test: seed-0 insecure keys, +/// no image pulls or builds. +fn test_define_options() -> DefineOptions { + DefineOptions { + pull_images: false, + key_gen: Box::new(|| Ok(generate_insecure_k1_key(0))), + } +} + +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"); + + new(dir.path(), Config::new_default()).expect("new"); + + 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/gotmpl.rs b/crates/test-compose/src/gotmpl.rs new file mode 100644 index 00000000..fa39db84 --- /dev/null +++ b/crates/test-compose/src/gotmpl.rs @@ -0,0 +1,621 @@ +//! Interpreter for the subset of Go `text/template` syntax used by the +//! compose templates. +//! +//! Supported: literal text, `{{.}}`, `{{.Field.Chain}}`, `{{$var.Field}}`, +//! `{{if pipeline}}…{{end}}`, `{{range pipeline}}…{{end}}`, +//! `{{range $elem := pipeline}}`, `{{range $idx, $elem := pipeline}}` and the +//! `{{- ` / ` -}}` whitespace trim markers, all with Go's semantics. +//! +//! Everything else (`else`, `with`, `define`, functions, pipes, literals, +//! comparisons) is rejected while parsing so that a template edit relying on +//! an unimplemented construct fails loudly instead of rendering wrongly. + +use std::{iter::Peekable, vec::IntoIter}; + +/// The whitespace characters stripped by the trim markers. +const SPACE_CHARS: &[char] = &[' ', '\t', '\r', '\n']; + +/// Template parse or execution error. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + /// An action opened with `{{` was never closed. + #[error("unclosed action at byte {offset}")] + UnclosedAction { + /// Byte offset of the `{{`. + offset: usize, + }, + + /// The action uses a construct this interpreter does not implement. + #[error("unsupported template action at byte {offset}: {{{{{action}}}}}")] + Unsupported { + /// Byte offset of the action. + offset: usize, + /// The trimmed action body. + action: String, + }, + + /// `{{end}}` without a matching `if` or `range`. + #[error("unexpected {{{{end}}}} at byte {offset}")] + UnexpectedEnd { + /// Byte offset of the action. + offset: usize, + }, + + /// An `if` or `range` was never closed. + #[error("missing {{{{end}}}} for {kind} at byte {offset}")] + MissingEnd { + /// Which construct is open. + kind: &'static str, + /// Byte offset of the opening action. + offset: usize, + }, + + /// A token is not a field chain or variable reference. + #[error("bad operand: {token}")] + BadOperand { + /// The offending token. + token: String, + }, + + /// Field access on a value without that field. + #[error("can't evaluate field {field} in {kind}")] + NoField { + /// The requested field. + field: String, + /// The kind of value it was requested on. + kind: &'static str, + }, + + /// A `$var` that is not in scope. + #[error("undefined variable: ${name}")] + UndefinedVariable { + /// The variable name without the `$`. + name: String, + }, + + /// `range` over a value that is not a list. + #[error("range can't iterate over {kind}")] + NotIterable { + /// The kind of value ranged over. + kind: &'static str, + }, + + /// Printing a value that has no textual form. + #[error("can't print {kind}")] + NotPrintable { + /// The kind of value printed. + kind: &'static str, + }, + + /// A range index does not fit the template integer type. + #[error("range index overflow")] + IndexOverflow, +} + +/// A value the template can evaluate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + /// A string, printed verbatim. + Str(String), + /// A signed integer. + Int(i64), + /// A boolean, printed as `true` / `false`. + Bool(bool), + /// A list, iterable with `range`. + List(Vec), + /// A struct-like value with named fields. + Object(Vec<(String, Value)>), +} + +impl Value { + /// Builds an object from `(field, value)` pairs. + pub fn object>(fields: impl IntoIterator) -> Self { + Value::Object(fields.into_iter().map(|(k, v)| (k.into(), v)).collect()) + } + + /// Builds a string value. + pub fn str(value: impl Into) -> Self { + Value::Str(value.into()) + } + + /// Builds a list of string values. + pub fn str_list>(items: impl IntoIterator) -> Self { + Value::List(items.into_iter().map(Value::str).collect()) + } + + fn kind(&self) -> &'static str { + match self { + Value::Str(_) => "string", + Value::Int(_) => "int", + Value::Bool(_) => "bool", + Value::List(_) => "list", + Value::Object(_) => "object", + } + } + + fn field(&self, name: &str) -> Result<&Value, Error> { + if let Value::Object(fields) = self + && let Some((_, value)) = fields.iter().find(|(k, _)| k == name) + { + return Ok(value); + } + + Err(Error::NoField { + field: name.to_string(), + kind: self.kind(), + }) + } + + /// Go truthiness: false, zero, empty string and empty list are false. + fn truthy(&self) -> bool { + match self { + Value::Str(s) => !s.is_empty(), + Value::Int(i) => *i != 0, + Value::Bool(b) => *b, + Value::List(l) => !l.is_empty(), + Value::Object(_) => true, + } + } + + fn print(&self, out: &mut String) -> Result<(), Error> { + match self { + Value::Str(s) => out.push_str(s), + Value::Int(i) => out.push_str(&i.to_string()), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::List(_) | Value::Object(_) => { + return Err(Error::NotPrintable { kind: self.kind() }); + } + } + + Ok(()) + } +} + +/// What an operand starts from: the current dot or a `$variable`. +#[derive(Debug)] +enum Base { + Dot, + Var(String), +} + +/// A field chain rooted at dot or a variable, e.g. `.Nodes`, `$vc.Label`, `.`. +#[derive(Debug)] +struct Operand { + base: Base, + path: Vec, +} + +#[derive(Debug)] +enum Node { + Text(String), + Print(Operand), + If { + cond: Operand, + body: Vec, + }, + Range { + index_var: Option, + elem_var: Option, + over: Operand, + body: Vec, + }, +} + +#[derive(Debug)] +enum Token { + Text(String), + Action { body: String, offset: usize }, +} + +/// A parsed template. +#[derive(Debug)] +pub struct Template { + nodes: Vec, +} + +impl Template { + /// Parses template source, rejecting unsupported constructs. + pub fn parse(src: &str) -> Result { + let tokens = lex(src)?; + let mut tokens = tokens.into_iter().peekable(); + let nodes = parse_nodes(&mut tokens, None)?; + + Ok(Self { nodes }) + } + + /// Renders the template with `dot` as the root value. + pub fn execute(&self, dot: &Value) -> Result { + let mut out = String::new(); + let mut vars = Vec::new(); + exec(&self.nodes, dot, &mut vars, &mut out)?; + + Ok(out) + } +} + +fn is_space(c: char) -> bool { + SPACE_CHARS.contains(&c) +} + +fn lex(src: &str) -> Result, Error> { + let mut tokens = Vec::new(); + let mut rest = src; + let mut pos = 0usize; + let mut trim_next = false; + + loop { + let Some(open) = rest.find("{{") else { + push_text(&mut tokens, rest, trim_next, false); + break; + }; + + let text = rest.get(..open).unwrap_or_default(); + let after = rest.get(open.saturating_add(2)..).unwrap_or_default(); + + // `{{- ` (dash followed by a space) trims the preceding text. + let trim_left = + after.starts_with('-') && after.get(1..).is_some_and(|s| s.starts_with(is_space)); + push_text(&mut tokens, text, trim_next, trim_left); + + let body_skip = if trim_left { 2 } else { 0 }; + let body_rest = after.get(body_skip..).unwrap_or_default(); + let action_offset = pos.saturating_add(open); + + let Some(close) = body_rest.find("}}") else { + return Err(Error::UnclosedAction { + offset: action_offset, + }); + }; + + let mut body = body_rest.get(..close).unwrap_or_default(); + + // ` -}}` (space followed by dash) trims the following text. + let trim_right = body.ends_with('-') + && body + .get(..body.len().saturating_sub(1)) + .is_some_and(|s| s.ends_with(is_space)); + if trim_right { + body = body.get(..body.len().saturating_sub(2)).unwrap_or_default(); + } + + tokens.push(Token::Action { + body: body.trim_matches(is_space).to_string(), + offset: action_offset, + }); + trim_next = trim_right; + + let consumed = open + .saturating_add(2) + .saturating_add(body_skip) + .saturating_add(close) + .saturating_add(2); + pos = pos.saturating_add(consumed); + rest = rest.get(consumed..).unwrap_or_default(); + } + + Ok(tokens) +} + +fn push_text(tokens: &mut Vec, text: &str, trim_start: bool, trim_end: bool) { + let mut text = text; + if trim_start { + text = text.trim_start_matches(is_space); + } + if trim_end { + text = text.trim_end_matches(is_space); + } + if !text.is_empty() { + tokens.push(Token::Text(text.to_string())); + } +} + +fn parse_nodes( + tokens: &mut Peekable>, + open: Option<(&'static str, usize)>, +) -> Result, Error> { + let mut nodes = Vec::new(); + + while let Some(token) = tokens.next() { + let (body, offset) = match token { + Token::Text(text) => { + nodes.push(Node::Text(text)); + continue; + } + Token::Action { body, offset } => (body, offset), + }; + + // Commas are their own tokens in Go's lexer (`$i, $node`). + let spaced = body.replace(',', " , "); + let words: Vec<&str> = spaced.split_whitespace().collect(); + let unsupported = || Error::Unsupported { + offset, + action: body.clone(), + }; + + match words.as_slice() { + ["end"] => { + return match open { + Some(_) => Ok(nodes), + None => Err(Error::UnexpectedEnd { offset }), + }; + } + ["if", cond] => { + let cond = parse_operand(cond)?; + let body = parse_nodes(tokens, Some(("if", offset)))?; + nodes.push(Node::If { cond, body }); + } + ["range", over] => { + let over = parse_operand(over)?; + let body = parse_nodes(tokens, Some(("range", offset)))?; + nodes.push(Node::Range { + index_var: None, + elem_var: None, + over, + body, + }); + } + ["range", elem, ":=", over] => { + let elem_var = Some(parse_var_decl(elem)?); + let over = parse_operand(over)?; + let body = parse_nodes(tokens, Some(("range", offset)))?; + nodes.push(Node::Range { + index_var: None, + elem_var, + over, + body, + }); + } + ["range", index, ",", elem, ":=", over] => { + let index_var = Some(parse_var_decl(index)?); + let elem_var = Some(parse_var_decl(elem)?); + let over = parse_operand(over)?; + let body = parse_nodes(tokens, Some(("range", offset)))?; + nodes.push(Node::Range { + index_var, + elem_var, + over, + body, + }); + } + [ + "if" | "range" | "end" | "else" | "with" | "define" | "template" | "block" + | "break" | "continue" | "nil", + .., + ] => return Err(unsupported()), + [single] => nodes.push(Node::Print(parse_operand(single)?)), + _ => return Err(unsupported()), + } + } + + match open { + Some((kind, offset)) => Err(Error::MissingEnd { kind, offset }), + None => Ok(nodes), + } +} + +fn is_ident(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Parses `$name` in a range declaration. +fn parse_var_decl(token: &str) -> Result { + match token.strip_prefix('$') { + Some(name) if is_ident(name) => Ok(name.to_string()), + _ => Err(Error::BadOperand { + token: token.to_string(), + }), + } +} + +fn parse_operand(token: &str) -> Result { + let bad = || Error::BadOperand { + token: token.to_string(), + }; + + if token == "." { + return Ok(Operand { + base: Base::Dot, + path: Vec::new(), + }); + } + + if let Some(chain) = token.strip_prefix('.') { + let path: Vec = chain.split('.').map(str::to_string).collect(); + if !path.iter().all(|p| is_ident(p)) { + return Err(bad()); + } + + return Ok(Operand { + base: Base::Dot, + path, + }); + } + + if let Some(var) = token.strip_prefix('$') { + let mut parts = var.split('.'); + let name = parts.next().unwrap_or_default(); + if !is_ident(name) { + return Err(bad()); + } + + let path: Vec = parts.map(str::to_string).collect(); + if !path.iter().all(|p| is_ident(p)) { + return Err(bad()); + } + + return Ok(Operand { + base: Base::Var(name.to_string()), + path, + }); + } + + Err(bad()) +} + +fn eval<'v>( + operand: &Operand, + dot: &'v Value, + vars: &'v [(String, Value)], +) -> Result<&'v Value, Error> { + let mut value = match &operand.base { + Base::Dot => dot, + Base::Var(name) => { + let Some((_, value)) = vars.iter().rev().find(|(k, _)| k == name) else { + return Err(Error::UndefinedVariable { name: name.clone() }); + }; + value + } + }; + + for field in &operand.path { + value = value.field(field)?; + } + + Ok(value) +} + +fn exec( + nodes: &[Node], + dot: &Value, + vars: &mut Vec<(String, Value)>, + out: &mut String, +) -> Result<(), Error> { + for node in nodes { + match node { + Node::Text(text) => out.push_str(text), + Node::Print(operand) => eval(operand, dot, vars)?.print(out)?, + Node::If { cond, body } => { + if eval(cond, dot, vars)?.truthy() { + exec(body, dot, vars, out)?; + } + } + Node::Range { + index_var, + elem_var, + over, + body, + } => { + let items = match eval(over, dot, vars)? { + Value::List(items) => items.clone(), + other => return Err(Error::NotIterable { kind: other.kind() }), + }; + + for (i, item) in items.iter().enumerate() { + let depth = vars.len(); + if let Some(name) = index_var { + let index = i64::try_from(i).map_err(|_| Error::IndexOverflow)?; + vars.push((name.clone(), Value::Int(index))); + } + if let Some(name) = elem_var { + vars.push((name.clone(), item.clone())); + } + + exec(body, item, vars, out)?; + vars.truncate(depth); + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + use super::*; + + fn render(src: &str, dot: &Value) -> Result { + Template::parse(src)?.execute(dot) + } + + fn sample() -> Value { + Value::object([ + ("Name", Value::str("compose")), + ("Empty", Value::str("")), + ("Yes", Value::Bool(true)), + ("No", Value::Bool(false)), + ("Zero", Value::Int(0)), + ("Count", Value::Int(3)), + ("None", Value::List(vec![])), + ("Items", Value::str_list(["a", "b"])), + ( + "Nodes", + Value::List(vec![ + Value::object([ + ("Label", Value::str("x")), + ("Ports", Value::str_list(["1", "2"])), + ]), + Value::object([("Label", Value::str("")), ("Ports", Value::List(vec![]))]), + ]), + ), + ]) + } + + #[test_case("plain text", "plain text" ; "text_only")] + #[test_case("a {{.Name}} b", "a compose b" ; "field")] + #[test_case("{{ .Name }}", "compose" ; "inner_padding")] + #[test_case("{{.Count}}/{{.Yes}}/{{.No}}", "3/true/false" ; "int_and_bool")] + #[test_case("[{{if .Name}}y{{end}}]", "[y]" ; "if_string_true")] + #[test_case("[{{if .Empty}}y{{end}}]", "[]" ; "if_string_false")] + #[test_case("[{{if .Yes}}y{{end}}][{{if .No}}n{{end}}]", "[y][]" ; "if_bool")] + #[test_case("[{{if .Zero}}y{{end}}][{{if .Count}}c{{end}}]", "[][c]" ; "if_int")] + #[test_case("[{{if .None}}y{{end}}][{{if .Items}}i{{end}}]", "[][i]" ; "if_list")] + #[test_case("{{range .Items}}<{{.}}>{{end}}", "" ; "range_dot")] + #[test_case("{{range $v := .Items}}<{{$v}}>{{end}}", "" ; "range_elem_var")] + #[test_case("{{range $i, $v := .Items}}{{$i}}={{$v}};{{end}}", "0=a;1=b;" ; "range_index_var")] + #[test_case("{{range .None}}x{{end}}-", "-" ; "range_empty")] + #[test_case( + "{{range $i, $n := .Nodes}}{{$i}}:{{$n.Label}}{{if $n.Ports}}[{{range $n.Ports}}{{.}}{{end}}]{{end}};{{end}}", + "0:x[12];1:;" ; "nested_range_and_var_fields" + )] + #[test_case("a \n {{- .Name}}", "acompose" ; "trim_left")] + #[test_case("{{.Name -}} \n\n b", "composeb" ; "trim_right")] + #[test_case("x\n {{- if .Yes}}\n y\n {{end -}}\n z", "x\n y\n z" ; "trim_both_sides_of_block")] + #[test_case("{{if .Yes -}}\n a\n{{- end}}", "a" ; "trim_inside_block")] + fn renders(src: &str, want: &str) { + assert_eq!(render(src, &sample()), Ok(want.to_string())); + } + + #[test] + fn dash_without_space_keeps_text() { + // `{{-` without a following space is not a trim marker: the dash + // belongs to the action and makes it an unsupported token. + let err = render("a {{-x}}", &sample()).expect_err("must fail"); + assert!(matches!(err, Error::BadOperand { .. }), "{err:?}"); + } + + #[test_case("{{.Name", Error::UnclosedAction { offset: 0 } ; "unclosed")] + #[test_case("a{{end}}", Error::UnexpectedEnd { offset: 1 } ; "stray_end")] + #[test_case("{{if .Yes}}x", Error::MissingEnd { kind: "if", offset: 0 } ; "missing_end_if")] + #[test_case("{{range .Items}}x", Error::MissingEnd { kind: "range", offset: 0 } ; "missing_end_range")] + #[test_case("{{if .Yes}}a{{else}}b{{end}}", Error::Unsupported { offset: 12, action: "else".to_string() } ; "else_action")] + #[test_case("{{with .Name}}{{end}}", Error::Unsupported { offset: 0, action: "with .Name".to_string() } ; "with")] + #[test_case("{{.Name | printf}}", Error::Unsupported { offset: 0, action: ".Name | printf".to_string() } ; "pipe")] + #[test_case("{{printf \"x\"}}", Error::Unsupported { offset: 0, action: "printf \"x\"".to_string() } ; "function")] + #[test_case("{{if eq .Name \"x\"}}{{end}}", Error::Unsupported { offset: 0, action: "if eq .Name \"x\"".to_string() } ; "comparison")] + #[test_case("{{range $i := }}{{end}}", Error::Unsupported { offset: 0, action: "range $i :=".to_string() } ; "bad_range_decl")] + #[test_case("{{\"lit\"}}", Error::BadOperand { token: "\"lit\"".to_string() } ; "literal")] + #[test_case("{{$}}", Error::BadOperand { token: "$".to_string() } ; "root_var")] + #[test_case("{{.Missing}}", Error::NoField { field: "Missing".to_string(), kind: "object" } ; "missing_field")] + #[test_case("{{.Name.Inner}}", Error::NoField { field: "Inner".to_string(), kind: "string" } ; "field_on_string")] + #[test_case("{{$v}}", Error::UndefinedVariable { name: "v".to_string() } ; "undefined_var")] + #[test_case("{{range .Name}}{{end}}", Error::NotIterable { kind: "string" } ; "range_string")] + #[test_case("{{.Items}}", Error::NotPrintable { kind: "list" } ; "print_list")] + #[test_case("{{.}}", Error::NotPrintable { kind: "object" } ; "print_object")] + fn rejects(src: &str, want: Error) { + assert_eq!(render(src, &sample()), Err(want)); + } + + #[test] + fn variables_are_scoped_to_their_range() { + let err = render("{{range $v := .Items}}{{end}}{{$v}}", &sample()).expect_err("must fail"); + assert_eq!( + err, + Error::UndefinedVariable { + name: "v".to_string() + } + ); + } +} diff --git a/crates/test-compose/src/lib.rs b/crates/test-compose/src/lib.rs new file mode 100644 index 00000000..6736470a --- /dev/null +++ b/crates/test-compose/src/lib.rs @@ -0,0 +1,63 @@ +//! Docker-compose cluster generator for local and CI smoke testing of pluto +//! and charon nodes. +//! +//! A cluster is produced in steps. Each step reads `config.json` from the +//! compose directory, advances its `step` field and regenerates +//! `docker-compose.yml` from the bundled template: +//! +//! 1. [`new`] cleans the directory and writes a fresh config. +//! 2. [`define`] renders the cluster-definition step (`charon create dkg`, or a +//! no-op container for `create` key generation), copies the static +//! monitoring configs and writes the Prometheus scrape and alert-rule files. +//! 3. [`lock`] renders the cluster-lock step (`charon create cluster` or a full +//! `charon dkg` run). +//! 4. [`run`] renders the compose file that runs the nodes, validator clients, +//! relay and monitoring stack. +//! +//! [`auto`] chains the three steps against a docker daemon, brings the +//! cluster up and watches Prometheus for alerts while it runs; [`smoke`] +//! holds the scenario matrix the smoke tests feed into it. + +mod alert; +mod auto; +mod config; +mod define; +mod duration; +mod error; +mod fsutil; +mod gotmpl; +mod lock; +mod new; +mod process; +mod run; +pub mod smoke; +mod static_files; +mod template; + +#[cfg(test)] +mod golden_tests; + +pub use alert::{ + ALERT_POLL_INTERVAL, ALERT_WARMUP, ALERTS_POLLED, ActiveAlert, AlertPoller, AlertTiming, + DockerCurlPoller, PromAlert, PromAlertAnnotations, PromAlerts, PromAnnotations, PromData, + PromGroup, PromRule, STARTUP_TRANSIENT_RULES, get_active_alerts, is_startup_transient, + start_collector, +}; +pub use auto::{AutoConfig, TmplFn, auto, run_step}; +pub use config::{ + CHARON_PORTS, Config, KeyGen, NodeImpl, Step, VERSION, VcType, load_config, write_config, +}; +pub use define::{ + ALERT_RULE_NAMES, BROADCAST_RULE, DefineOptions, ERROR_RATE_RULE, KeyGenError, KeyGenFn, + PLUTO_DOWN_RULE, PROXY_RATE_RULE, VAPI_RATE_RULE, WARN_RATE_RULE, build_local, + build_local_pluto, clean, define, +}; +pub use duration::go_duration_string; +pub use error::{CommandError, ComposeError, Result}; +pub use lock::lock; +pub use new::new; +pub use process::{ + LogSink, UpOutcome, build_and_create, down, fix_perms, print_docker_compose, up, +}; +pub use run::run; +pub use template::{Kv, Port, TmplData, TmplNode, TmplVc, write_docker_compose}; diff --git a/crates/test-compose/src/lock.rs b/crates/test-compose/src/lock.rs new file mode 100644 index 00000000..fd1b7a1e --- /dev/null +++ b/crates/test-compose/src/lock.rs @@ -0,0 +1,291 @@ +//! 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}, +}; + +/// 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, None), + 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 (config step `defined`) or the +/// run flags, plus the loki/tempo flags when monitoring is on. +pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option) -> Vec { + let mut beacon_mock = false; + + let mut beacon_node = conf.beacon_nodes.as_str(); + if beacon_node == "mock" { + beacon_mock = true; + beacon_node = ""; + } + + // 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. + 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()), + ]; + + if conf.step == Step::Defined { + // Define lock config + 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; + } + + // Define run config + 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 == Some(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)), + ]); + + // 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 { + 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 defined_step_uses_dkg_flags() { + let mut conf = Config::new_default(); + conf.step = Step::Defined; + + let kvs = new_node_envs(2, &conf, None); + assert_eq!( + keys(&kvs), + [ + "private-key-file", + "monitoring-address", + "p2p-external-hostname", + "p2p-tcp-address", + "p2p-relays", + "log-level", + "log-color", + "feature-set", + "data-dir", + "definition-file", + "insecure-keys", + ] + ); + assert_eq!(value(&kvs, "data-dir"), "/compose/node2"); + assert_eq!(value(&kvs, "p2p-relays"), "http://relay:3640"); + assert_eq!(value(&kvs, "insecure-keys"), "\"false\""); + } + + #[test] + fn run_step_reflects_config_toggles() { + let mut conf = Config::new_default(); + conf.step = Step::Locked; + 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, Some(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, Some(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"); + } + + #[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" + ); + } + + #[test] + fn lock_create_maps_split_keys_dir_into_container() { + let dir = tempfile::tempdir().expect("tempdir"); + let keys_dir = dir.path().join("split-keys"); + std::fs::create_dir(&keys_dir).expect("mkdir"); + + let mut conf = Config::new_default(); + conf.step = Step::Defined; + conf.split_keys_dir = keys_dir.to_string_lossy().into_owned(); + + let data = lock(dir.path(), conf).expect("lock"); + let kvs = &data.nodes[0].env_vars; + assert_eq!(value(kvs, "split-existing-keys"), "\"true\""); + assert_eq!(value(kvs, "split-keys-dir"), "/compose/split-keys"); + } +} diff --git a/crates/test-compose/src/new.rs b/crates/test-compose/src/new.rs new file mode 100644 index 00000000..81124258 --- /dev/null +++ b/crates/test-compose/src/new.rs @@ -0,0 +1,43 @@ +//! Creation of a fresh compose config. + +use std::path::Path; + +use tracing::info; + +use crate::{ + Result, + config::{Config, Step, write_config}, + define::clean, +}; + +/// Cleans `dir` and writes `conf` as a new (`step: new`) `config.json`. +pub fn new(dir: impl AsRef, mut conf: Config) -> Result<()> { + let dir = dir.as_ref(); + + clean(dir)?; + + conf.step = Step::New; + + info!(dir = %dir.display(), config = ?conf, "Writing config to compose dir"); + + write_config(dir, &conf) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load_config; + + #[test] + fn new_resets_step_and_writes_config() { + let dir = tempfile::tempdir().expect("tempdir"); + + let mut conf = Config::new_default(); + conf.step = Step::Locked; + + new(dir.path(), conf.clone()).expect("new"); + + conf.step = Step::New; + assert_eq!(load_config(dir.path()).expect("load"), conf); + } +} diff --git a/crates/test-compose/src/process.rs b/crates/test-compose/src/process.rs new file mode 100644 index 00000000..889a46ed --- /dev/null +++ b/crates/test-compose/src/process.rs @@ -0,0 +1,344 @@ +//! 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, +//! so the command sequence can be observed with stand-in programs (see the +//! transcript tests) as well as against a real docker daemon. + +use std::{ + fs::{File, OpenOptions}, + io::{self, Write}, + os::unix::fs::OpenOptionsExt, + path::Path, + process::{ExitStatus, Stdio}, +}; + +use tokio::process::Command; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::{ + define::combined_output, + 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::OpenLogFile), + } + } + + /// 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, +} + +fn exit_ok(status: ExitStatus) -> std::result::Result<(), CommandError> { + if status.success() { + Ok(()) + } else { + Err(CommandError::Exit(status)) + } +} + +/// 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 + .map_err(|err| ComposeError::ExecCatDockerCompose(CommandError::Io(err)))?; + + exit_ok(status).map_err(ComposeError::ExecCatDockerCompose) +} + +/// 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 + .map_err(|err| ComposeError::ExecSudo { + program: program.to_string(), + source: CommandError::Io(err), + })?; + + exit_ok(status).map_err(|source| ComposeError::ExecSudo { + program: program.to_string(), + source, + })?; + } + + 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 + .map_err(|err| ComposeError::RunDown(CommandError::Io(err)))?; + + exit_ok(status).map_err(ComposeError::RunDown) +} + +/// 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 = tokio::select! { + output = build.output() => output.map_err(|err| ComposeError::ExecComposeBuild { + source: CommandError::Io(err), + output: String::new(), + })?, + () = token.cancelled() => { + return Err(ComposeError::ExecComposeBuild { + source: CommandError::Io(io::Error::other("signal: killed")), + output: String::new(), + }); + } + }; + if !output.status.success() { + return Err(ComposeError::ExecComposeBuild { + source: CommandError::Exit(output.status), + output: combined_output(&output), + }); + } + + info!("Executing docker compose up"); + + let stdout = sink + .stdio() + .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; + let stderr = sink + .stdio() + .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; + let mut child = Command::new("docker") + .args([ + "compose", + "up", + "--remove-orphans", + "--abort-on-container-exit", + "--quiet-pull", + ]) + .current_dir(dir) + .stdout(stdout) + .stderr(stderr) + .kill_on_drop(true) + .spawn() + .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; + + let status = tokio::select! { + status = child.wait() => { + status.map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))? + } + () = token.cancelled() => { + let _ = child.kill().await; + return Ok(UpOutcome::Cancelled); + } + }; + + if status.success() { + Ok(UpOutcome::Exited) + } else if token.is_cancelled() { + Ok(UpOutcome::Cancelled) + } else { + Err(ComposeError::ExecComposeUp(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 + .map_err(|err| ComposeError::ExecComposeCreate { + source: CommandError::Io(err), + output: String::new(), + })?; + + if output.status.success() { + Ok(()) + } else { + Err(ComposeError::ExecComposeCreate { + source: CommandError::Exit(output.status), + output: combined_output(&output), + }) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::PermissionsExt}; + + 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" + ); + } + + #[test] + fn log_sink_creates_file_with_0644() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("new.log"); + + let sink = LogSink::open(Some(&path)).expect("open sink"); + drop(sink); + + let mode = fs::metadata(&path).expect("metadata").permissions().mode(); + assert_eq!(mode & 0o777, 0o644); + } + + #[test] + fn log_sink_open_missing_parent_fails() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("missing").join("compose.log"); + + let err = LogSink::open(Some(&path)).expect_err("open must fail"); + assert!(matches!(err, ComposeError::OpenLogFile(_)), "{err:?}"); + assert!(err.to_string().starts_with("open log file: "), "{err}"); + } + + #[test] + fn log_sink_stdout_never_fails() { + let mut sink = LogSink::open(None).expect("stdout sink"); + assert!(matches!(sink, LogSink::Stdout)); + sink.banner(""); + } + + #[tokio::test] + async fn print_docker_compose_runs_cat_in_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("docker-compose.yml"), "services: {}\n").expect("write yml"); + + print_docker_compose(dir.path()) + .await + .expect("cat of an existing file succeeds"); + } + + #[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..51c26e8f --- /dev/null +++ b/crates/test-compose/src/run.rs @@ -0,0 +1,273 @@ +//! 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, + gotmpl::{Template, Value}, + lock::{new_node_envs, quoted_bool}, + template::{Kv, TmplData, TmplNode, TmplVc, write_docker_compose}, +}; + +/// Command template for the teku validator client; rendered per node with +/// its index, keystore pairs and the builder API toggle. +const TEKU_COMMAND: &str = r#"| + 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}}"#; + +/// 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 in 0..conf.num_nodes { + let typ = i + .checked_rem(conf.vcs.len()) + .and_then(|idx| conf.vcs.get(idx)) + .copied() + .ok_or(ComposeError::NoValidatorClients)?; + + 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, Some(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, +) -> Result { + let mut resp = match typ { + VcType::Mock => TmplVc::default(), + VcType::Vouch | VcType::Lighthouse | VcType::Lodestar => TmplVc { + label: typ.as_str().to_string(), + build: typ.as_str().to_string(), + ..TmplVc::default() + }, + VcType::Teku => TmplVc { + label: typ.as_str().to_string(), + image: "consensys/teku:latest".to_string(), + command: TEKU_COMMAND.to_string(), + ..TmplVc::default() + }, + }; + + if typ == VcType::Teku { + let keys: Vec = (0..num_vals) + .map(|i| { + if insecure { + format!( + "/compose/node{node_idx}/validator_keys/keystore-insecure-{i}.json:/compose/node{node_idx}/validator_keys/keystore-insecure-{i}.txt" + ) + } else { + format!( + "/compose/node{node_idx}/validator_keys/keystore-{i}.json:/compose/node{node_idx}/validator_keys/keystore-{i}.txt" + ) + } + }) + .collect(); + + let node_idx = + i64::try_from(node_idx).map_err(|_| ComposeError::PortOverflow { index: node_idx })?; + + let data = Value::object([ + ("TekuKeys", Value::str_list(keys)), + ("NodeIdx", Value::Int(node_idx)), + ("BuilderAPI", Value::Bool(builder_api)), + ]); + + resp.command = Template::parse(&resp.command) + .and_then(|tmpl| tmpl.execute(&data)) + .map_err(ComposeError::TekuTemplate)?; + } + + Ok(resp) +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + use super::*; + + #[test] + fn teku_template_renders() { + let vc = get_vc(VcType::Teku, 0, 1, false, true).expect("teku vc"); + 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 teku_template_insecure_keys_and_no_builder() { + let vc = get_vc(VcType::Teku, 2, 2, true, false).expect("teku vc"); + assert!(vc.command.contains( + "--validator-keys=\"/compose/node2/validator_keys/keystore-insecure-0.json:/compose/node2/validator_keys/keystore-insecure-0.txt\"\n --validator-keys=\"/compose/node2/validator_keys/keystore-insecure-1.json:/compose/node2/validator_keys/keystore-insecure-1.txt\"\n --validators-proposer-default" + ), "{}", vc.command); + assert!( + vc.command + .ends_with("--validators-proposer-blinded-blocks-enabled=false") + ); + } + + #[test_case(VcType::Vouch, "vouch" ; "vouch")] + #[test_case(VcType::Lighthouse, "lighthouse" ; "lighthouse")] + #[test_case(VcType::Lodestar, "lodestar" ; "lodestar")] + fn built_vcs(typ: VcType, name: &str) { + let vc = get_vc(typ, 1, 1, false, false).expect("vc"); + assert_eq!(vc.label, name); + assert_eq!(vc.build, name); + assert!(vc.image.is_empty()); + assert!(vc.command.is_empty()); + } + + #[test] + fn mock_vc_is_empty() { + assert_eq!( + get_vc(VcType::Mock, 0, 1, false, false).expect("vc"), + TmplVc::default() + ); + } + + #[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" + ); + } + + #[test] + fn run_requires_validator_clients() { + let mut conf = Config::new_default(); + conf.step = Step::Locked; + conf.vcs.clear(); + let dir = tempfile::tempdir().expect("tempdir"); + let err = run(dir.path(), conf).expect_err("must fail"); + assert_eq!(err.to_string(), "no validator clients configured"); + } + + #[test] + fn run_p2p_fuzz_and_disabled_ports() { + let mut conf = Config::new_default(); + conf.step = Step::Locked; + conf.p2p_fuzz = true; + conf.disable_monitoring_ports = true; + + let dir = tempfile::tempdir().expect("tempdir"); + let data = run(dir.path(), conf).expect("run"); + + assert_eq!(data.charon_command, "[unsafe,run]"); + assert!(!data.monitoring_ports); + assert!(data.nodes.iter().all(|n| n.ports.is_empty())); + + let fuzz = data.nodes[0].env_vars.last().expect("env"); + assert_eq!( + (fuzz.key.as_str(), fuzz.value.as_str()), + ("p2p-fuzz", "\"true\"") + ); + assert!(data.nodes[1].env_vars.iter().all(|kv| kv.key != "p2p-fuzz")); + } +} diff --git a/crates/test-compose/src/smoke.rs b/crates/test-compose/src/smoke.rs new file mode 100644 index 00000000..92d52278 --- /dev/null +++ b/crates/test-compose/src/smoke.rs @@ -0,0 +1,357 @@ +//! 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 docker-based tests, the docker-free +//! transcript tests and the CI workflow all run the same scenarios. + +use std::{env, path::PathBuf, time::Duration}; + +use crate::{ + auto::{AutoConfig, TmplFn}, + config::{Config, KeyGen, NodeImpl, VcType}, + define::{BROADCAST_RULE, ERROR_RATE_RULE, VAPI_RATE_RULE}, + 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"; + +/// Environment variable pointing at the pluto checkout the `pluto:local` +/// image is built from. Scenarios that need it are skipped when it is unset. +pub const PLUTO_REPO_ENV: &str = "PLUTO_REPO"; + +/// 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 Ok(relay) = env::var(EXTERNAL_RELAY_ENV) + && !relay.is_empty() + { + 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: Option, + /// Adjusts the run step template data. + pub run_tmpl_fn: Option, + /// Adjusts the define step template data. + pub define_tmpl_fn: Option, + /// Print `docker-compose.yml` after each step. + pub print_yml: bool, + /// Alert observation window; zero means [`DEFAULT_TIMEOUT`]. + pub timeout: Duration, + /// The scenario builds and runs pluto, so it needs [`PLUTO_REPO_ENV`]. + pub require_pluto: bool, +} + +impl Scenario { + const fn new(name: &'static str) -> Self { + Self { + name, + config_fn: None, + run_tmpl_fn: None, + define_tmpl_fn: None, + print_yml: false, + timeout: Duration::ZERO, + require_pluto: false, + } + } + + /// The scenario's cluster config. + pub fn config(&self) -> Config { + let mut conf = base_config(); + if let Some(config_fn) = self.config_fn { + config_fn(&mut conf); + } + + conf + } + + /// The alert observation window. + pub fn timeout(&self) -> Duration { + if self.timeout.is_zero() { + DEFAULT_TIMEOUT + } else { + self.timeout + } + } + + /// 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.map(|f| Box::new(f) as TmplFn); + conf.define_tmpl_fn = self.define_tmpl_fn.map(|f| Box::new(f) as TmplFn); + + 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 fn scenarios() -> Vec { + vec![ + Scenario { + print_yml: true, + config_fn: Some(|conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "alpha".to_string(); + }), + ..Scenario::new("default_alpha") + }, + Scenario { + config_fn: Some(|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: Some(|conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "stable".to_string(); + }), + ..Scenario::new("default_stable") + }, + Scenario { + config_fn: Some(|conf| { + conf.key_gen = KeyGen::Dkg; + }), + ..Scenario::new("dkg") + }, + Scenario { + config_fn: Some(|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: Some(|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: Some(|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: Some(|conf| { + conf.builder_api = true; + }), + ..Scenario::new("blinded_blocks_vmock") + }, + Scenario { + require_pluto: true, + config_fn: Some(|conf| { + conf.key_gen = KeyGen::Create; + conf.key_gen_impl = Some(NodeImpl::Pluto); + }), + ..Scenario::new("pluto_keygen_create") + }, + Scenario { + require_pluto: true, + config_fn: Some(|conf| { + conf.key_gen = KeyGen::Create; + conf.node_impls = vec![NodeImpl::Pluto]; + conf.synthetic_block_proposals = false; + }), + ..Scenario::new("all_pluto") + }, + Scenario { + require_pluto: true, + config_fn: Some(|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 { + require_pluto: true, + config_fn: Some(|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() + .into_iter() + .find(|scenario| scenario.name == name) +} + +#[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 scenarios = scenarios(); + 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(); + assert_eq!( + scenario.require_pluto, + conf.uses_pluto(), + "{}: require_pluto must match the config", + scenario.name + ); + + 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); + } + + #[test] + fn timeouts() { + assert_eq!( + scenario("default_alpha").expect("scenario").timeout(), + Duration::from_secs(120) + ); + assert_eq!( + scenario("very_large").expect("scenario").timeout(), + Duration::from_secs(180) + ); + assert!(scenario("missing").is_none()); + } + + #[test] + fn auto_config_carries_scenario_knobs() { + let conf = scenario("1_of_4_down") + .expect("scenario") + .auto_config("/tmp/compose"); + + assert_eq!(conf.alert_timeout, DEFAULT_TIMEOUT); + assert!(!conf.print_yml); + assert!(conf.run_tmpl_fn.is_some()); + assert!(conf.define_tmpl_fn.is_none()); + assert!( + scenario("default_alpha") + .expect("scenario") + .auto_config("/tmp/compose") + .print_yml + ); + } + + #[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"]); + } + + #[test] + fn unset_node0_p2p_tolerates_no_nodes() { + let mut data = TmplData::default(); + unset_node0_p2p(&mut data); + assert!(data.nodes.is_empty()); + } +} diff --git a/crates/test-compose/src/static_files.rs b/crates/test-compose/src/static_files.rs new file mode 100644 index 00000000..87e17c7d --- /dev/null +++ b/crates/test-compose/src/static_files.rs @@ -0,0 +1,105 @@ +//! 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()); + } + + #[test] + fn table_is_sorted() { + let keys: Vec<(&str, &str)> = STATIC_FILES.iter().map(|f| (f.dir, f.name)).collect(); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + assert_eq!(keys, sorted); + } +} diff --git a/crates/test-compose/src/template.rs b/crates/test-compose/src/template.rs new file mode 100644 index 00000000..60862ae9 --- /dev/null +++ b/crates/test-compose/src/template.rs @@ -0,0 +1,221 @@ +//! Template data for `docker-compose.yml` and its renderer. + +use std::path::Path; + +use serde::Serialize; + +use crate::{ + Result, + config::nullable_vec, + error::ComposeError, + fsutil::write_file, + gotmpl::{Template, Value}, +}; + +/// The bundled docker-compose template. +const COMPOSE_TEMPLATE: &str = include_str!("../docker-compose.template"); + +/// Root data of the docker-compose template. +#[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, +} + +impl From<&Port> for Value { + fn from(port: &Port) -> Self { + Value::object([ + ("External", Value::Int(i64::from(port.external))), + ("Internal", Value::Int(i64::from(port.internal))), + ]) + } +} + +impl From<&Kv> for Value { + fn from(kv: &Kv) -> Self { + Value::object([ + ("Key", Value::str(&kv.key)), + ("Value", Value::str(&kv.value)), + ("EnvKey", Value::str(kv.env_key())), + ]) + } +} + +impl From<&TmplNode> for Value { + fn from(node: &TmplNode) -> Self { + Value::object([ + ("Image", Value::str(&node.image)), + ("Entrypoint", Value::str(&node.entrypoint)), + ("Command", Value::str(&node.command)), + ( + "EnvVars", + Value::List(node.env_vars.iter().map(Value::from).collect()), + ), + ( + "Ports", + Value::List(node.ports.iter().map(Value::from).collect()), + ), + ]) + } +} + +impl From<&TmplVc> for Value { + fn from(vc: &TmplVc) -> Self { + Value::object([ + ("Label", Value::str(&vc.label)), + ("Image", Value::str(&vc.image)), + ("Build", Value::str(&vc.build)), + ("Command", Value::str(&vc.command)), + ( + "Ports", + Value::List(vc.ports.iter().map(Value::from).collect()), + ), + ]) + } +} + +impl From<&TmplData> for Value { + fn from(data: &TmplData) -> Self { + Value::object([ + ("ComposeDir", Value::str(&data.compose_dir)), + ("CharonImageTag", Value::str(&data.charon_image_tag)), + ("CharonEntrypoint", Value::str(&data.charon_entrypoint)), + ("CharonCommand", Value::str(&data.charon_command)), + ( + "Nodes", + Value::List(data.nodes.iter().map(Value::from).collect()), + ), + ( + "VCs", + Value::List(data.vcs.iter().map(Value::from).collect()), + ), + ("Relay", Value::Bool(data.relay)), + ("Monitoring", Value::Bool(data.monitoring)), + ("Alerting", Value::Bool(data.alerting)), + ("MonitoringPorts", Value::Bool(data.monitoring_ports)), + ]) + } +} + +/// Renders the bundled template with `data` and writes `docker-compose.yml` +/// into `dir`. +pub fn write_docker_compose(dir: impl AsRef, data: &TmplData) -> Result<()> { + let template = Template::parse(COMPOSE_TEMPLATE).map_err(ComposeError::NewTemplate)?; + let rendered = template + .execute(&Value::from(data)) + .map_err(ComposeError::ExecTemplate)?; + + write_file(dir.as_ref().join("docker-compose.yml"), rendered, 0o755) + .map_err(ComposeError::WriteDockerCompose) +} + +#[cfg(test)] +mod tests { + use test_case::test_case; + + use super::*; + + #[test_case("p2p-tcp-address", "P2P_TCP_ADDRESS" ; "dashes")] + #[test_case("simnet-beacon_mock", "SIMNET_BEACON_MOCK" ; "mixed_separators")] + #[test_case("name", "NAME" ; "plain")] + fn env_key(key: &str, want: &str) { + assert_eq!(Kv::new(key, "").env_key(), want); + } + + #[test] + fn bundled_template_parses() { + Template::parse(COMPOSE_TEMPLATE).expect("template must parse"); + } +} 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 100% rename from test-infra/compose/testdata/TestDockerCompose_define_create_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden 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 100% rename from test-infra/compose/testdata/TestDockerCompose_define_dkg_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden 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 100% 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 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 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_create_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden 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 100% 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 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 100% rename from test-infra/compose/testdata/TestDockerCompose_lock_dkg_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden 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 100% rename from test-infra/compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden 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 100% rename from test-infra/compose/testdata/TestDockerCompose_run_yml.golden rename to crates/test-compose/testdata/TestDockerCompose_run_yml.golden 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/testdata/smoke/1_of_3_down.transcript b/crates/test-compose/testdata/smoke/1_of_3_down.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/1_of_3_down.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/1_of_4_down.transcript b/crates/test-compose/testdata/smoke/1_of_4_down.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/1_of_4_down.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/all_pluto.transcript b/crates/test-compose/testdata/smoke/all_pluto.transcript new file mode 100644 index 00000000..35b159c2 --- /dev/null +++ b/crates/test-compose/testdata/smoke/all_pluto.transcript @@ -0,0 +1,22 @@ +git rev-parse --short=7 HEAD +docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript b/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/default_alpha.transcript b/crates/test-compose/testdata/smoke/default_alpha.transcript new file mode 100644 index 00000000..81f15c29 --- /dev/null +++ b/crates/test-compose/testdata/smoke/default_alpha.transcript @@ -0,0 +1,23 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +cat docker-compose.yml +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +cat docker-compose.yml +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +cat docker-compose.yml +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/default_beta.transcript b/crates/test-compose/testdata/smoke/default_beta.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/default_beta.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/default_stable.transcript b/crates/test-compose/testdata/smoke/default_stable.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/default_stable.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/dkg.transcript b/crates/test-compose/testdata/smoke/dkg.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/dkg.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript b/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript new file mode 100644 index 00000000..35b159c2 --- /dev/null +++ b/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript @@ -0,0 +1,22 @@ +git rev-parse --short=7 HEAD +docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/pluto_dkg.transcript b/crates/test-compose/testdata/smoke/pluto_dkg.transcript new file mode 100644 index 00000000..35b159c2 --- /dev/null +++ b/crates/test-compose/testdata/smoke/pluto_dkg.transcript @@ -0,0 +1,22 @@ +git rev-parse --short=7 HEAD +docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript b/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript new file mode 100644 index 00000000..35b159c2 --- /dev/null +++ b/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript @@ -0,0 +1,22 @@ +git rev-parse --short=7 HEAD +docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/testdata/smoke/shim.sh b/crates/test-compose/testdata/smoke/shim.sh new file mode 100644 index 00000000..44b96dcb --- /dev/null +++ b/crates/test-compose/testdata/smoke/shim.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Stand-in for the external programs the compose harness spawns (docker, sudo, +# git, cat). Installed under all four names in a temporary bin directory that +# is put first on PATH. Every invocation is appended to ../transcript.log as +# " \t" so the command sequence of a harness run can be +# compared without a docker daemon. +# +# Canned behaviour, enough to drive the harness through a full run: +# - `git rev-parse --short=7 HEAD` prints a fixed hash; +# - the prometheus rules query answers a successful, empty rule set; +# - `docker compose up --no-start --build` drops a marker, after which the +# final `docker compose up ...` blocks like a live cluster until killed; +# - everything else exits 0 silently. +root=$(dirname "$0")/.. +program=$(basename "$0") +line=$program +for arg in "$@"; do + line="$line $arg" +done +printf '%s\t%s\n' "$line" "$PWD" >> "$root/transcript.log" + +case "$program $*" in + "git rev-parse --short=7 HEAD") + printf 'abcdef0\n' + ;; + "docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert") + printf '{"status":"success","data":{"groups":[]}}\n' + ;; + "docker compose up --no-start --build") + : > "$root/created" + ;; + "docker compose up --remove-orphans --abort-on-container-exit --quiet-pull") + if [ -e "$root/created" ]; then + exec sleep 3600 + fi + ;; +esac +exit 0 diff --git a/crates/test-compose/testdata/smoke/very_large.transcript b/crates/test-compose/testdata/smoke/very_large.transcript new file mode 100644 index 00000000..5530a9b7 --- /dev/null +++ b/crates/test-compose/testdata/smoke/very_large.transcript @@ -0,0 +1,20 @@ +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +docker compose up --no-start --build +docker compose build --parallel +docker compose up --remove-orphans --abort-on-container-exit --quiet-pull +sudo chown -R : . +sudo chmod -R a+wrX . +docker compose down --remove-orphans --timeout=2 +polls=yes diff --git a/crates/test-compose/tests/smoke.rs b/crates/test-compose/tests/smoke.rs new file mode 100644 index 00000000..2c970906 --- /dev/null +++ b/crates/test-compose/tests/smoke.rs @@ -0,0 +1,100 @@ +//! 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::{env, path::PathBuf}; + +use pluto_test_compose::{auto, 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(()); + +fn env_flag(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty() && value != "0") +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +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.require_pluto && env_path(smoke::PLUTO_REPO_ENV).is_none() { + eprintln!("skipping {name}: {} not set", smoke::PLUTO_REPO_ENV); + 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_flag("SMOKE_SUDO_PERMS"); + conf.log_file = env_path("SMOKE_LOG_DIR").map(|log_dir| 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/crates/test-compose/tests/transcript.rs b/crates/test-compose/tests/transcript.rs new file mode 100644 index 00000000..c8af256e --- /dev/null +++ b/crates/test-compose/tests/transcript.rs @@ -0,0 +1,222 @@ +//! Command-transcript parity with the Go harness, without docker. +//! +//! Each test re-executes this test binary to run one smoke scenario through +//! [`pluto_test_compose::auto`] with stand-in `docker`, `sudo`, `git` and `cat` +//! programs first on `PATH`. The stand-ins log every invocation; the log is +//! normalised and compared with `testdata/smoke/.transcript`, which +//! was captured from `go test ./smoke -integration -sudo-perms` running the +//! same scenario through the same stand-ins. +//! +//! The re-exec exists because `PATH` is per process and the tests run in +//! parallel threads. Set `PLUTO_COMPOSE_TRANSCRIPT_OUT=` to also dump the +//! normalised transcripts for inspection. + +use std::{ + env, fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +use pluto_test_compose::{AlertTiming, auto, smoke, write_config}; + +const SHIM: &str = include_str!("../testdata/smoke/shim.sh"); +const SHIM_PROGRAMS: [&str; 4] = ["docker", "sudo", "git", "cat"]; +const GOLDEN_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/smoke"); +const SCENARIO_ENV: &str = "TRANSCRIPT_SCENARIO"; +const DIR_ENV: &str = "TRANSCRIPT_DIR"; +const POLL_PREFIX: &str = + "docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert"; + +fn install_shims(bin: &Path) { + for program in SHIM_PROGRAMS { + let path = bin.join(program); + fs::write(&path, SHIM).expect("write shim"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); + } +} + +/// Replaces the numeric owner of `sudo chown -R : .` with a +/// placeholder, so the transcript does not depend on who runs the test. +fn normalize_owner(cmd: &str) -> String { + let Some(rest) = cmd.strip_prefix("sudo chown -R ") else { + return cmd.to_string(); + }; + let Some((owner, tail)) = rest.split_once(' ') else { + return cmd.to_string(); + }; + let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()); + match owner.split_once(':') { + Some((uid, gid)) if numeric(uid) && numeric(gid) => { + format!("sudo chown -R : {tail}") + } + _ => cmd.to_string(), + } +} + +/// Normalises a raw shim log: working directories become `` or ``, +/// the chown owner becomes a placeholder, and the alert polls (whose count +/// depends on timing) collapse into a trailing `polls=yes|no` line. +fn normalize(raw: &str, repo: &Path) -> String { + let mut repo_paths = vec![repo.to_string_lossy().into_owned()]; + if let Ok(canonical) = fs::canonicalize(repo) { + repo_paths.push(canonical.to_string_lossy().into_owned()); + } + + let mut out = String::new(); + let mut polled = false; + for line in raw.lines() { + let (cmd, cwd) = line.split_once('\t').unwrap_or((line, "")); + if cmd.starts_with(POLL_PREFIX) { + polled = true; + continue; + } + + let cwd = if repo_paths.iter().any(|p| p == cwd) { + "" + } else { + "" + }; + out.push_str(&normalize_owner(cmd)); + out.push('\t'); + out.push_str(cwd); + out.push('\n'); + } + + out.push_str(if polled { "polls=yes\n" } else { "polls=no\n" }); + + out +} + +fn assert_transcript(name: &str) { + let root = tempfile::Builder::new() + .prefix("transcript-") + .tempdir() + .expect("tempdir"); + let bin = root.path().join("bin"); + let repo = root.path().join("repo"); + let compose = root.path().join("compose"); + for dir in [&bin, &repo, &compose] { + fs::create_dir(dir).expect("create dir"); + } + install_shims(&bin); + + let path = env::join_paths( + std::iter::once(bin.clone()) + .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), + ) + .expect("join PATH"); + let output = Command::new(env::current_exe().expect("current exe")) + .args(["transcript_child", "--exact", "--ignored", "--nocapture"]) + .env("PATH", path) + .env(SCENARIO_ENV, name) + .env(DIR_ENV, &compose) + .env(smoke::PLUTO_REPO_ENV, &repo) + .env_remove(smoke::EXTERNAL_RELAY_ENV) + .output() + .expect("run transcript child"); + assert!( + output.status.success(), + "{name}: transcript child failed: {}\n--- stdout ---\n{}\n--- stderr ---\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let raw = fs::read_to_string(root.path().join("transcript.log")).expect("read transcript"); + let actual = normalize(&raw, &repo); + + if let Some(out_dir) = env::var_os("PLUTO_COMPOSE_TRANSCRIPT_OUT") { + let out_dir = PathBuf::from(out_dir); + fs::create_dir_all(&out_dir).expect("create transcript out dir"); + fs::write(out_dir.join(format!("{name}.transcript")), &actual).expect("dump transcript"); + } + + let golden = Path::new(GOLDEN_DIR).join(format!("{name}.transcript")); + let expected = fs::read_to_string(&golden) + .unwrap_or_else(|err| panic!("read golden {}: {err}", golden.display())); + assert_eq!( + actual, expected, + "{name}: command transcript differs from the Go harness" + ); +} + +/// The re-executed half: runs one scenario with a short alert window against +/// the shims. A no-op unless the parent set the scenario, so a plain +/// `cargo test -- --ignored` does not trip over it. +#[tokio::test] +#[ignore = "helper re-executed by the transcript tests"] +async fn transcript_child() { + let Some(name) = env::var_os(SCENARIO_ENV) else { + return; + }; + let name = name.to_string_lossy().into_owned(); + let dir = PathBuf::from(env::var_os(DIR_ENV).expect("TRANSCRIPT_DIR is set")); + let scenario = smoke::scenario(&name).unwrap_or_else(|| panic!("unknown scenario {name}")); + + write_config(&dir, &scenario.config()).expect("write config"); + + let mut conf = scenario.auto_config(&dir); + conf.alert_timeout = Duration::from_secs(3); + conf.sudo_perms = true; + conf.timing = AlertTiming { + warmup: Duration::from_secs(1), + poll_interval: Duration::from_millis(100), + }; + + auto(conf).await.expect("auto run against the shims"); +} + +macro_rules! transcript_tests { + ($($test:ident => $name:literal),* $(,)?) => { + const SCENARIO_NAMES: &[&str] = &[$($name),*]; + + $( + #[test] + fn $test() { + assert_transcript($name); + } + )* + }; +} + +transcript_tests! { + transcript_default_alpha => "default_alpha", + transcript_default_beta => "default_beta", + transcript_default_stable => "default_stable", + transcript_dkg => "dkg", + transcript_very_large => "very_large", + transcript_1_of_4_down => "1_of_4_down", + transcript_1_of_3_down => "1_of_3_down", + transcript_blinded_blocks_vmock => "blinded_blocks_vmock", + transcript_pluto_keygen_create => "pluto_keygen_create", + transcript_all_pluto => "all_pluto", + transcript_mixed_2_charon_2_pluto => "mixed_2_charon_2_pluto", + transcript_pluto_dkg => "pluto_dkg", +} + +#[test] +fn every_scenario_has_a_transcript_test() { + let names: Vec<&str> = smoke::scenarios().iter().map(|s| s.name).collect(); + assert_eq!(names, SCENARIO_NAMES); +} + +#[test] +fn normalize_collapses_polls_and_placeholders() { + let repo = Path::new("/work/repo"); + let raw = "git rev-parse --short=7 HEAD\t/work/repo\n\ + docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert\t/tmp/c\n\ + sudo chown -R 501:20 .\t/tmp/c\n\ + sudo chmod -R a+wrX .\t/tmp/c\n"; + + assert_eq!( + normalize(raw, repo), + "git rev-parse --short=7 HEAD\t\nsudo chown -R : .\t\nsudo chmod -R a+wrX .\t\npolls=yes\n" + ); + assert_eq!(normalize("", repo), "polls=no\n"); + assert_eq!( + normalize_owner("sudo chown -R root:wheel ."), + "sudo chown -R root:wheel ." + ); +} 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/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 -} From b3edc0410a74a9a4ec4e564ef41f87fe59c0345a Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 8 Sep 2026 08:25:31 +0700 Subject: [PATCH 2/4] refactor(smoke): simplify code and test --- .github/workflows/smoke-tests.yml | 53 +- Cargo.lock | 1 - crates/test-compose/Cargo.toml | 1 - crates/test-compose/README.md | 138 +--- crates/test-compose/docker-compose.template | 129 ---- crates/test-compose/src/alert.rs | 298 ++------- crates/test-compose/src/auto.rs | 155 +---- crates/test-compose/src/config.rs | 235 ++----- crates/test-compose/src/define.rs | 477 +++----------- crates/test-compose/src/duration.rs | 18 +- crates/test-compose/src/error.rs | 290 +++----- crates/test-compose/src/fsutil.rs | 47 +- crates/test-compose/src/golden_tests.rs | 18 +- crates/test-compose/src/gotmpl.rs | 621 ------------------ crates/test-compose/src/lib.rs | 59 +- crates/test-compose/src/lock.rs | 102 +-- crates/test-compose/src/new.rs | 43 -- crates/test-compose/src/process.rs | 155 +---- crates/test-compose/src/run.rs | 162 ++--- crates/test-compose/src/smoke.rs | 308 ++++----- crates/test-compose/src/static_files.rs | 8 - crates/test-compose/src/template.rs | 260 +++++--- ...TestDockerCompose_define_create_yml.golden | 4 - .../TestDockerCompose_define_dkg_yml.golden | 5 - ...ompose_lock_create_pluto_keygen_yml.golden | 5 - .../TestDockerCompose_lock_create_yml.golden | 5 - ...kerCompose_lock_dkg_mixed_impls_yml.golden | 15 +- .../TestDockerCompose_lock_dkg_yml.golden | 15 +- ...stDockerCompose_run_mixed_impls_yml.golden | 39 +- .../testdata/TestDockerCompose_run_yml.golden | 39 +- .../testdata/smoke/1_of_3_down.transcript | 20 - .../testdata/smoke/1_of_4_down.transcript | 20 - .../testdata/smoke/all_pluto.transcript | 22 - .../smoke/blinded_blocks_vmock.transcript | 20 - .../testdata/smoke/default_alpha.transcript | 23 - .../testdata/smoke/default_beta.transcript | 20 - .../testdata/smoke/default_stable.transcript | 20 - .../testdata/smoke/dkg.transcript | 20 - .../smoke/mixed_2_charon_2_pluto.transcript | 22 - .../testdata/smoke/pluto_dkg.transcript | 22 - .../smoke/pluto_keygen_create.transcript | 22 - crates/test-compose/testdata/smoke/shim.sh | 38 -- .../testdata/smoke/very_large.transcript | 20 - crates/test-compose/tests/smoke.rs | 25 +- crates/test-compose/tests/transcript.rs | 222 ------- 45 files changed, 819 insertions(+), 3422 deletions(-) delete mode 100644 crates/test-compose/docker-compose.template delete mode 100644 crates/test-compose/src/gotmpl.rs delete mode 100644 crates/test-compose/src/new.rs delete mode 100644 crates/test-compose/testdata/smoke/1_of_3_down.transcript delete mode 100644 crates/test-compose/testdata/smoke/1_of_4_down.transcript delete mode 100644 crates/test-compose/testdata/smoke/all_pluto.transcript delete mode 100644 crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript delete mode 100644 crates/test-compose/testdata/smoke/default_alpha.transcript delete mode 100644 crates/test-compose/testdata/smoke/default_beta.transcript delete mode 100644 crates/test-compose/testdata/smoke/default_stable.transcript delete mode 100644 crates/test-compose/testdata/smoke/dkg.transcript delete mode 100644 crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript delete mode 100644 crates/test-compose/testdata/smoke/pluto_dkg.transcript delete mode 100644 crates/test-compose/testdata/smoke/pluto_keygen_create.transcript delete mode 100644 crates/test-compose/testdata/smoke/shim.sh delete mode 100644 crates/test-compose/testdata/smoke/very_large.transcript delete mode 100644 crates/test-compose/tests/transcript.rs diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index f9416551..2199a27b 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -1,9 +1,7 @@ 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: @@ -33,8 +31,6 @@ 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), the harness build and the scenario matrix. timeout-minutes: 100 steps: @@ -44,62 +40,34 @@ jobs: - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 - - name: Update apt package list - run: sudo apt-get update - - # The smoke test binary links pluto-testutil, which pulls in the eth2api - # and protobuf code generation, so it needs the same build tools as - # test.yml. - - name: Install `protobuf` - uses: awalsh128/cache-apt-pkgs-action@v1.6.0 - with: - packages: protobuf-compiler=3.21.12* - version: 3.21.12 - - 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 the smoke - # run, so the release compile does not consume the smoke 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 - # Same reasoning: compiled in its own step so the smoke timeout bounds - # observation, and a build break fails here. run: cargo test --locked -p pluto-test-compose --test smoke --no-run - name: Run smoke tests - # fromJSON: the input arrives as a number, but coercing through JSON - # keeps this valid should it ever arrive as a string. timeout-minutes: ${{ fromJSON(inputs.smoke_timeout) }} - # 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. + # 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 }} SMOKE_LOG_DIR: ${{ runner.temp }}/smoke-logs - # Containers run as root, so the artefacts they leave in the compose - # dir are root-owned; without this the runner cannot clean them up. + # Containers run as root; without this the runner cannot clean up. SMOKE_SUDO_PERMS: "1" run: | mkdir -p "$SMOKE_LOG_DIR" - # very_large requires more CPU than a GitHub-hosted runner provides - # reliably. - args=(--ignored --nocapture --skip scenario_very_large) + # very_large needs more CPU than a hosted runner has. + args=(--ignored --nocapture --test-threads=1 --skip scenario_very_large) if [ -n "$SCENARIOS" ]; then # Exact names: `dkg` alone would also select pluto_dkg. args+=(--exact) @@ -107,8 +75,7 @@ jobs: args+=("scenario_$name") done - # libtest runs nothing, and passes, for a name that selects no test, - # so a typo would give a green run. Every name must select a test. + # 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 @@ -120,8 +87,6 @@ jobs: 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/Cargo.lock b/Cargo.lock index 17f20f44..e6cb1c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5701,7 +5701,6 @@ dependencies = [ "nix", "pluto-eth2util", "pluto-k1util", - "pluto-testutil", "serde", "serde_json", "tempfile", diff --git a/crates/test-compose/Cargo.toml b/crates/test-compose/Cargo.toml index bdee6a5e..b0071f32 100644 --- a/crates/test-compose/Cargo.toml +++ b/crates/test-compose/Cargo.toml @@ -20,7 +20,6 @@ tokio-util.workspace = true tracing.workspace = true [dev-dependencies] -pluto-testutil.workspace = true tempfile.workspace = true test-case.workspace = true tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/test-compose/README.md b/crates/test-compose/README.md index 9d2bdc81..d11ae62c 100644 --- a/crates/test-compose/README.md +++ b/crates/test-compose/README.md @@ -1,119 +1,43 @@ # Pluto Compose -> A docker-compose test harness for standing up insecure local pluto/charon clusters, used by the smoke integration tests. +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. -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 -in `tests/smoke.rs` — there is no standalone CLI. Cluster generation happens in -three stages, exposed as crate 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 `src/auto.rs`) chains define → lock → run and runs `docker compose up`; it is -what the tests call after writing a config with `write_config`. - -This crate is test infrastructure: nothing in it ships in the `pluto` binary. - -## Node implementations - -Each node runs either charon or pluto, assigned round-robin from a scenario's `node_impls` -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. -- `key_gen_impl` 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. +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` 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 `write_alert_rules` in `src/define.rs`). 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 the workspace build prerequisites (see -`CONTRIBUTING.md`; the test binary links `pluto-testutil`, which needs `protoc` and -`oas3-gen` like the rest of the workspace). 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 needed. - -The scenarios are `#[ignore]`d tests named `scenario_`, so they only run when -asked for: - +`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 ``` -# 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) cargo test -p pluto-test-compose --test smoke -- \ - --ignored --nocapture --exact \ - scenario_pluto_keygen_create scenario_all_pluto scenario_mixed_2_charon_2_pluto scenario_pluto_dkg - -# Full matrix (pluto + charon-only scenarios): -PLUTO_REPO=$(git rev-parse --show-toplevel) cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture - -# The CI matrix: everything but the resource-heavy very_large scenario: -PLUTO_REPO=$(git rev-parse --show-toplevel) cargo test -p pluto-test-compose --test smoke -- \ - --ignored --nocapture --skip scenario_very_large - -# Keep docker-compose logs per scenario (/.log): -SMOKE_LOG_DIR=. cargo test -p pluto-test-compose --test smoke -- --ignored --nocapture -``` - -Without `--exact`, a name is a substring filter (`dkg` selects `pluto_dkg` too). -Environment variables read by the suite: | Variable | Effect | -|----------|--------| -| `PLUTO_REPO` | pluto checkout to build `pluto:local` from; scenarios that run pluto (`pluto_keygen_create`, `all_pluto`, `mixed_2_charon_2_pluto`, `pluto_dkg`) skip when it is unset | -| `SMOKE_SUDO_PERMS=1` | fix root-owned artefacts with `sudo chown`/`chmod` after each step (containers run as root); needed where the caller must clean the compose dir up afterwards, e.g. CI | -| `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 instead of the compose one | - -There is no global timeout to set: each scenario is bounded by its own 2–3 minute alert -window plus image builds, and libtest has no overall deadline. Scenarios run one at a -time whatever `--test-threads` says, because clusters competing for CPU and memory -produce duty timeouts a sequential run never sees. `.github/workflows/smoke-tests.yml` -runs the CI matrix on manual dispatch and bounds the run with its step timeout. +|---|---| +| `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. | -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. +The CI workflow (`.github/workflows/smoke-tests.yml`) is manual-only and runs +the same command. -### Alert criteria vs. charon +## 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 @@ -140,6 +64,4 @@ Scenarios that intentionally degrade the cluster tune the gate via config, not t ## Versioning -Charon is pinned to the pluto parity reference (`v1.7.1`) through the docker image tag -the smoke tests use: `CHARON_IMAGE_TAG` in `src/smoke.rs`. Bump it deliberately alongside -the parity target, not to track charon main. +The charon image tag is `CHARON_IMAGE_TAG` in `src/smoke.rs`. diff --git a/crates/test-compose/docker-compose.template b/crates/test-compose/docker-compose.template deleted file mode 100644 index 58f0a25a..00000000 --- a/crates/test-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/crates/test-compose/src/alert.rs b/crates/test-compose/src/alert.rs index e06104f1..e0c9dba6 100644 --- a/crates/test-compose/src/alert.rs +++ b/crates/test-compose/src/alert.rs @@ -22,15 +22,11 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info}; use crate::{ - define::{BROADCAST_RULE, ERROR_RATE_RULE, WARN_RATE_RULE, combined_output}, + define::{BROADCAST_RULE, ERROR_RATE_RULE, WARN_RATE_RULE}, duration::go_duration_string, error::{CommandError, ComposeError, Result}, }; -/// Sentinel sent on the alert channel when polling was still healthy at the -/// end of the observation window. -pub const ALERTS_POLLED: &str = "alerts_polled"; - /// Window after Prometheus first answers during which the cold-start /// transients are ignored. pub const ALERT_WARMUP: Duration = Duration::from_secs(60); @@ -47,24 +43,14 @@ pub fn is_startup_transient(rule: impl AsRef) -> bool { STARTUP_TRANSIENT_RULES.contains(&rule.as_ref()) } -/// Cadence of the alert collector. The defaults are what the harness runs -/// with; the knob exists so docker-free tests can finish quickly. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AlertTiming { - /// Time after Prometheus first answers during which startup transients - /// are ignored. - pub warmup: Duration, - /// Time between two polls. - pub poll_interval: Duration, -} - -impl Default for AlertTiming { - fn default() -> Self { - Self { - warmup: ALERT_WARMUP, - poll_interval: ALERT_POLL_INTERVAL, - } - } +/// 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. @@ -126,7 +112,7 @@ pub struct PromAlert { pub state: String, /// The alert annotations. #[serde(default)] - pub annotations: PromAnnotations, + pub annotations: PromAlertAnnotations, } /// The annotations of an alert. @@ -137,9 +123,6 @@ pub struct PromAlertAnnotations { pub description: String, } -/// Annotations of an alert instance. -pub type PromAnnotations = PromAlertAnnotations; - /// Source of alert rule snapshots. pub trait AlertPoller: Send + Sync + 'static { /// Fetches the current alerting rules. @@ -181,19 +164,14 @@ async fn query_alerts(dir: &Path) -> Result { .current_dir(dir) .kill_on_drop(true) .output() - .await - .map_err(|err| ComposeError::ExecCurlAlerts { - source: CommandError::Io(err), - out: String::new(), - })?; - - let out = combined_output(&output); - if !output.status.success() { - return Err(ComposeError::ExecCurlAlerts { - source: CommandError::Exit(output.status), - out, - }); - } + .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 }) } @@ -201,36 +179,30 @@ async fn query_alerts(dir: &Path) -> Result { /// 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 [`ALERTS_POLLED`] as its last message -/// if the final poll succeeded and at least one poll succeeded after the -/// warmup window, then closes the channel. +/// 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, - timing: AlertTiming, -) -> mpsc::Receiver { +) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(100); - tokio::spawn(collect(token, poller, timing, tx)); + tokio::spawn(collect(token, poller, tx)); rx } -async fn collect( - token: CancellationToken, - poller: impl AlertPoller, - timing: AlertTiming, - tx: mpsc::Sender, -) { - let Some(ready_at) = await_prometheus_ready(&token, &poller, timing.poll_interval).await else { +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(timing.warmup), + 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(timing.warmup); + 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; @@ -249,10 +221,10 @@ async fn collect( Ok(alerts) if alerts.status != "success" => { last_poll_ok = false; let _ = tx - .send(format!( + .send(AlertEvent::Alert(format!( "non success status from prometheus alerts: {}", alerts.status - )) + ))) .await; } Ok(alerts) => { @@ -281,16 +253,16 @@ async fn collect( info!(alert = %active.description, "Detected new alert"); - let _ = tx.send(active.description).await; + let _ = tx.send(AlertEvent::Alert(active.description)).await; } } } - sleep_or_cancel(&token, timing.poll_interval).await; + sleep_or_cancel(&token, ALERT_POLL_INTERVAL).await; } if post_warmup_poll_ok && last_poll_ok { - let _ = tx.send(ALERTS_POLLED.to_string()).await; + let _ = tx.send(AlertEvent::Polled).await; } } @@ -299,7 +271,6 @@ async fn collect( async fn await_prometheus_ready( token: &CancellationToken, poller: &impl AlertPoller, - poll_interval: Duration, ) -> Option { info!("Waiting for prometheus to answer the rules API"); @@ -310,7 +281,7 @@ async fn await_prometheus_ready( return Some(Instant::now()); } - sleep_or_cancel(token, poll_interval).await; + sleep_or_cancel(token, ALERT_POLL_INTERVAL).await; } None @@ -327,19 +298,13 @@ async fn query_or_cancel( token: &CancellationToken, poller: &impl AlertPoller, ) -> Option> { - let result = tokio::select! { - result = poller.query() => result, - () = token.cancelled() => return None, - }; + let result = token.run_until_cancelled(poller.query()).await?; (!token.is_cancelled()).then_some(result) } async fn sleep_or_cancel(token: &CancellationToken, duration: Duration) { - tokio::select! { - () = time::sleep(duration) => {} - () = token.cancelled() => {} - } + let _ = token.run_until_cancelled(time::sleep(duration)).await; } /// Extracts the firing alerts of a rules API response, in response order. @@ -367,8 +332,6 @@ pub fn get_active_alerts(alerts: &PromAlerts) -> Vec { mod tests { use std::io; - use test_case::test_case; - use super::*; use crate::define::{PLUTO_DOWN_RULE, PROXY_RATE_RULE, VAPI_RATE_RULE}; @@ -422,29 +385,6 @@ mod tests { assert_eq!(STARTUP_TRANSIENT_RULES.len(), 3); } - #[test] - fn prom_alerts_tolerates_missing_and_unknown_fields() { - let alerts: PromAlerts = serde_json::from_str( - r#"{"status":"success","extra":1,"data":{"groups":[{"rules":[{"alerts":[{}]}]}]}}"#, - ) - .expect("parse payload"); - - assert_eq!(alerts.status, "success"); - assert_eq!(alerts.data.groups.len(), 1); - assert!(get_active_alerts(&alerts).is_empty()); - } - - #[test] - fn alert_timing_default_matches_harness() { - assert_eq!( - AlertTiming::default(), - AlertTiming { - warmup: Duration::from_secs(60), - poll_interval: Duration::from_secs(2), - } - ); - } - /// Answers each poll from a script keyed by the time elapsed since the /// poller was created. struct ScriptedPoller { @@ -464,14 +404,6 @@ mod tests { data: PromData::default(), }) } - - fn with_status(status: &str) -> Result { - Ok(PromAlerts { - status: status.to_string(), - data: PromData::default(), - }) - } - fn firing(rule: &str, description: &str) -> Result { Ok(PromAlerts { status: "success".to_string(), @@ -493,10 +425,9 @@ mod tests { } fn failing() -> Result { - Err(ComposeError::ExecCurlAlerts { - source: CommandError::Io(io::Error::other("no such container")), - out: String::new(), - }) + Err(ComposeError::exec("exec curl alerts")(io::Error::other( + "no such container", + ))) } /// Runs the collector with the harness cadence under paused time for @@ -505,23 +436,23 @@ mod tests { async fn run_collector( window: Duration, script: impl Fn(Duration) -> Result + Send + Sync + 'static, - ) -> Vec { + ) -> Vec { let token = CancellationToken::new(); let poller = ScriptedPoller { start: Instant::now(), script: Box::new(script), }; - let mut rx = start_collector(token.clone(), poller, AlertTiming::default()); + let mut rx = start_collector(token.clone(), poller); time::sleep(window).await; token.cancel(); - let mut messages = Vec::new(); - while let Some(message) = rx.recv().await { - messages.push(message); + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); } - messages + events } const WINDOW: Duration = Duration::from_secs(125); @@ -532,87 +463,19 @@ mod tests { #[tokio::test(start_paused = true)] async fn healthy_window_reports_polled_only() { - let messages = run_collector(WINDOW, |_| healthy()).await; - assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); - } - - #[tokio::test(start_paused = true)] - async fn poller_dying_after_warmup_withholds_polled() { - let messages = - run_collector(WINDOW, |t| if t < secs(70) { healthy() } else { failing() }).await; - assert!(messages.is_empty(), "{messages:?}"); - } - - #[tokio::test(start_paused = true)] - async fn poller_recovering_before_deadline_reports_polled() { - let messages = run_collector(WINDOW, |t| { - if (secs(70)..secs(90)).contains(&t) { - failing() - } else { - healthy() - } - }) - .await; - assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + 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 messages = run_collector(WINDOW, |_| failing()).await; - assert!(messages.is_empty(), "{messages:?}"); - } - - #[tokio::test(start_paused = true)] - async fn readiness_waits_for_success_status() { - let messages = run_collector(WINDOW, |t| { - if t < secs(5) { - with_status("error") - } else { - healthy() - } - }) - .await; - assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); - } - - #[tokio::test(start_paused = true)] - async fn non_success_status_is_reported_every_poll_and_withholds_polled() { - let messages = run_collector(WINDOW, |t| { - if t < secs(10) { - healthy() - } else { - with_status("error") - } - }) - .await; - assert!(!messages.is_empty()); - assert!( - messages - .iter() - .all(|m| m == "non success status from prometheus alerts: error"), - "{messages:?}" - ); - } - - #[tokio::test(start_paused = true)] - async fn non_transient_alert_during_warmup_is_reported() { - let messages = run_collector(WINDOW, |t| { - if (secs(10)..secs(14)).contains(&t) { - firing(PLUTO_DOWN_RULE, "node0 is down") - } else { - healthy() - } - }) - .await; - assert_eq!( - messages, - vec!["node0 is down".to_string(), ALERTS_POLLED.to_string()] - ); + 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 messages = run_collector(WINDOW, |t| { + let events = run_collector(WINDOW, |t| { if t < secs(30) { firing(ERROR_RATE_RULE, "node0 has a high error rate") } else { @@ -620,31 +483,12 @@ mod tests { } }) .await; - assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); - } - - #[tokio::test(start_paused = true)] - async fn transient_alert_outliving_warmup_is_reported_once() { - let messages = run_collector(WINDOW, |t| { - if t < secs(80) { - firing(ERROR_RATE_RULE, "node0 has a high error rate") - } else { - healthy() - } - }) - .await; - assert_eq!( - messages, - vec![ - "node0 has a high error rate".to_string(), - ALERTS_POLLED.to_string() - ] - ); + assert_eq!(events, vec![AlertEvent::Polled]); } #[tokio::test(start_paused = true)] async fn persistent_alert_is_reported_once() { - let messages = run_collector(WINDOW, |t| { + let events = run_collector(WINDOW, |t| { if t >= secs(70) { firing(VAPI_RATE_RULE, "node1 has a high validator api error rate") } else { @@ -653,20 +497,14 @@ mod tests { }) .await; assert_eq!( - messages, + events, vec![ - "node1 has a high validator api error rate".to_string(), - ALERTS_POLLED.to_string() + AlertEvent::Alert("node1 has a high validator api error rate".to_string()), + AlertEvent::Polled ] ); } - #[test_case(Duration::from_secs(60), "1m0s" ; "harness_default")] - #[test_case(Duration::from_secs(1), "1s" ; "one_second")] - fn warmup_is_logged_in_go_format(warmup: Duration, expected: &str) { - assert_eq!(go_duration_string(warmup), expected); - } - /// Answers every poll with a healthy response until `stall_after` has /// elapsed since creation, then never answers again. struct StallingPoller { @@ -687,24 +525,24 @@ mod tests { /// 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 { + 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, AlertTiming::default()); + let mut rx = start_collector(token.clone(), poller); time::sleep(window).await; token.cancel(); let drain = async { - let mut messages = Vec::new(); - while let Some(message) = rx.recv().await { - messages.push(message); + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); } - messages + events }; time::timeout(secs(10), drain) @@ -712,21 +550,9 @@ mod tests { .expect("collector did not stop after cancel") } - #[tokio::test(start_paused = true)] - async fn stalled_readiness_query_stops_on_cancel() { - let messages = run_stalling(WINDOW, Duration::ZERO).await; - assert!(messages.is_empty(), "{messages:?}"); - } - #[tokio::test(start_paused = true)] async fn stalled_poll_during_warmup_stops_on_cancel_without_verdict() { - let messages = run_stalling(WINDOW, secs(1)).await; - assert!(messages.is_empty(), "{messages:?}"); - } - - #[tokio::test(start_paused = true)] - async fn stalled_poll_at_deadline_does_not_count_against_verdict() { - let messages = run_stalling(WINDOW, secs(100)).await; - assert_eq!(messages, vec![ALERTS_POLLED.to_string()]); + 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 index 660bd943..87d55cb4 100644 --- a/crates/test-compose/src/auto.rs +++ b/crates/test-compose/src/auto.rs @@ -3,7 +3,6 @@ //! alerts. use std::{ - fmt, io, path::{Path, PathBuf}, time::Duration, }; @@ -13,7 +12,7 @@ use tokio_util::sync::CancellationToken; use tracing::info; use crate::{ - alert::{ALERTS_POLLED, AlertTiming, DockerCurlPoller, start_collector}, + alert::{AlertEvent, DockerCurlPoller, start_collector}, config::{Config, load_config}, define::{DefineOptions, define}, error::{ComposeError, Result}, @@ -25,9 +24,10 @@ use crate::{ /// Hook that adjusts a step's template data before `docker-compose.yml` is /// rewritten. -pub type TmplFn = Box; +pub type TmplFn = fn(&mut TmplData); /// Configuration of [`auto`]. +#[derive(Debug, Clone)] pub struct AutoConfig { /// The compose directory holding `config.json`. pub dir: PathBuf, @@ -41,14 +41,8 @@ pub struct AutoConfig { pub print_yml: bool, /// Adjusts the run step template data. pub run_tmpl_fn: Option, - /// Adjusts the define step template data. - pub define_tmpl_fn: Option, /// Append the `docker compose up` output to this file instead of stdout. pub log_file: Option, - /// Options of the define step. - pub define_options: DefineOptions, - /// Alert collector cadence. - pub timing: AlertTiming, } impl AutoConfig { @@ -61,30 +55,11 @@ impl AutoConfig { sudo_perms: false, print_yml: false, run_tmpl_fn: None, - define_tmpl_fn: None, log_file: None, - define_options: DefineOptions::default(), - timing: AlertTiming::default(), } } } -impl fmt::Debug for AutoConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AutoConfig") - .field("dir", &self.dir) - .field("alert_timeout", &self.alert_timeout) - .field("sudo_perms", &self.sudo_perms) - .field("print_yml", &self.print_yml) - .field("run_tmpl_fn", &self.run_tmpl_fn.is_some()) - .field("define_tmpl_fn", &self.define_tmpl_fn.is_some()) - .field("log_file", &self.log_file) - .field("define_options", &self.define_options) - .field("timing", &self.timing) - .finish() - } -} - /// 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 @@ -98,10 +73,7 @@ pub async fn auto(conf: AutoConfig) -> Result<()> { sudo_perms, print_yml, run_tmpl_fn, - define_tmpl_fn, log_file, - define_options, - timing, } = conf; let mut sink = LogSink::open(log_file.as_deref())?; @@ -112,8 +84,8 @@ pub async fn auto(conf: AutoConfig) -> Result<()> { print_yml, }; - step.run("define", define_tmpl_fn, move |dir: &Path, conf| { - define(dir, conf, &define_options) + step.run("define", None, |dir: &Path, conf| { + define(dir, conf, &DefineOptions::default()) }) .await?; sink.banner("===== define step: docker compose up =====\n"); @@ -127,8 +99,9 @@ pub async fn auto(conf: AutoConfig) -> Result<()> { step.run("run", run_tmpl_fn, |dir: &Path, conf| run(dir, conf)) .await?; - // Ensure everything is clean before the alert test starts. - let _ = down(&dir, sudo_perms).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?; @@ -142,7 +115,7 @@ pub async fn auto(conf: AutoConfig) -> Result<()> { }); } - let mut alerts = start_collector(token.clone(), DockerCurlPoller::new(&dir), timing); + 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; @@ -159,12 +132,11 @@ async fn observe( sink: &LogSink, token: &CancellationToken, alert_timeout: Duration, - alerts: &mut mpsc::Receiver, + alerts: &mut mpsc::Receiver, ) -> Result<()> { match up(dir, sink, token).await? { - // `--abort-on-container-exit` exits 0 when a container stops cleanly, - // taking the whole cluster down with it. Nothing was observed for the - // full window, so this is a failure rather than "no alerts detected". + // `--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. @@ -174,11 +146,10 @@ async fn observe( let mut detected = Vec::new(); let mut polled = false; - while let Some(alert) = alerts.recv().await { - if alert == ALERTS_POLLED { - polled = true; - } else { - detected.push(alert); + while let Some(event) = alerts.recv().await { + match event { + AlertEvent::Alert(alert) => detected.push(alert), + AlertEvent::Polled => polled = true, } } @@ -206,7 +177,7 @@ impl StepRunner<'_> { where F: FnOnce(&Path, Config) -> Result + Send + 'static, { - let mut tmpl = run_step(name, self.dir, false, run_fn).await?; + let mut tmpl = run_step(name, self.dir, run_fn).await?; if self.sudo_perms { fix_perms(self.dir).await?; @@ -225,33 +196,18 @@ impl StepRunner<'_> { } } -/// Loads the config in `dir`, runs the generator step `run_fn` on it and, -/// when `up_after` is set, brings the resulting cluster up on stdout. `topic` -/// names the step in the log. -pub async fn run_step( - topic: &'static str, - dir: impl AsRef, - up_after: bool, - run_fn: F, -) -> Result +/// 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 dir = dir.as_ref().to_path_buf(); - - let conf = match load_config(&dir) { - Err(ComposeError::LoadConfig(err)) if err.kind() == io::ErrorKind::NotFound => { - return Err(ComposeError::ConfigNotFound { - dir: dir.display().to_string(), - }); - } - other => other?, - }; + let conf = load_config(dir)?; info!(command = topic, "Running compose command"); - let step_dir = dir.clone(); - let tmpl = task::spawn_blocking(move || run_fn(&step_dir, conf)) + let step_dir = dir.to_path_buf(); + task::spawn_blocking(move || run_fn(&step_dir, conf)) .await .map_err(|err| { if err.is_panic() { @@ -259,13 +215,7 @@ where } else { ComposeError::StepCancelled(err) } - })??; - - if up_after { - up(&dir, &LogSink::Stdout, &CancellationToken::new()).await?; - } - - Ok(tmpl) + })? } #[cfg(test)] @@ -273,69 +223,18 @@ mod tests { use super::*; use crate::config::{Step, write_config}; - #[tokio::test] - async fn run_step_without_config_reports_not_found() { - let dir = tempfile::tempdir().expect("tempdir"); - - let err = run_step("lock", dir.path(), false, |dir: &Path, conf| { - lock(dir, conf) - }) - .await - .expect_err("missing config must fail"); - - assert_eq!( - err.to_string(), - format!( - "compose config.json not found; write one with WriteConfig or New first: dir={}", - dir.path().display() - ) - ); - } - #[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(), false, |dir: &Path, conf| { - lock(dir, conf) - }) - .await - .expect_err("lock on a new config must fail"); + 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:?}" ); } - - #[tokio::test] - async fn run_step_surfaces_other_load_errors() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::write(dir.path().join("config.json"), "{").expect("write broken config"); - - let err = run_step("lock", dir.path(), false, |dir: &Path, conf| { - lock(dir, conf) - }) - .await - .expect_err("broken config must fail"); - - assert!(matches!(err, ComposeError::UnmarshalConfig(_)), "{err:?}"); - } - - #[test] - fn auto_config_defaults() { - let conf = AutoConfig::new("/tmp/compose"); - - assert_eq!(conf.dir, PathBuf::from("/tmp/compose")); - assert_eq!(conf.alert_timeout, Duration::ZERO); - assert!(!conf.sudo_perms); - assert!(!conf.print_yml); - assert!(conf.run_tmpl_fn.is_none()); - assert!(conf.define_tmpl_fn.is_none()); - assert!(conf.log_file.is_none()); - assert!(conf.define_options.pull_images); - assert_eq!(conf.timing, AlertTiming::default()); - assert!(format!("{conf:?}").contains("run_tmpl_fn: false")); - } } diff --git a/crates/test-compose/src/config.rs b/crates/test-compose/src/config.rs index a4be9821..12a4888b 100644 --- a/crates/test-compose/src/config.rs +++ b/crates/test-compose/src/config.rs @@ -2,7 +2,7 @@ use std::{fmt, fs, path::Path, time::Duration}; -use serde::{Deserialize, Deserializer, Serialize, de}; +use serde::{Deserialize, Serialize}; use crate::{ Result, define::ALERT_RULE_NAMES, error::ComposeError, fsutil::write_file, template::Port, @@ -15,15 +15,19 @@ pub(crate) const CONFIG_FILE: &str = "config.json"; const DEFAULT_IMAGE_TAG: &str = "latest"; const DEFAULT_BEACON_NODE: &str = "mock"; -const DEFAULT_KEY_GEN: KeyGen = KeyGen::Create; const DEFAULT_NUM_VALS: usize = 1; const DEFAULT_NUM_NODES: usize = 4; const DEFAULT_THRESHOLD: usize = 3; const DEFAULT_FEATURE_SET: &str = "alpha"; -const CHARON_IMAGE: &str = "obolnetwork/charon"; +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]"; @@ -86,12 +90,13 @@ impl fmt::Display for VcType { } /// Key generation process. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[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, } @@ -112,7 +117,7 @@ impl fmt::Display for KeyGen { } /// Node implementation to run. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NodeImpl { /// The reference Go implementation. @@ -130,11 +135,20 @@ impl NodeImpl { } } - fn parse(s: &str) -> Option { - match s { - "charon" => Some(NodeImpl::Charon), - "pluto" => Some(NodeImpl::Pluto), - _ => None, + /// 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"), } } } @@ -145,22 +159,12 @@ impl fmt::Display for NodeImpl { } } -impl<'de> Deserialize<'de> for NodeImpl { - fn deserialize>(deserializer: D) -> std::result::Result { - let name = String::deserialize(deserializer)?; - NodeImpl::parse(&name).ok_or_else(|| { - de::Error::custom(format!( - "unknown node implementation; must be charon or pluto: impl={name}" - )) - }) - } -} - /// Compose workflow step. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[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, @@ -209,9 +213,9 @@ pub(crate) mod nullable_vec { } /// Serde adaptor for the optional keygen implementation: absent is the empty -/// string, and unknown names are rejected with the keygen-specific message. +/// string. mod keygen_impl { - use serde::{Deserialize, Deserializer, Serializer, de}; + use serde::{Deserialize, Deserializer, Serializer, de::IntoDeserializer as _}; use super::NodeImpl; @@ -230,11 +234,7 @@ mod keygen_impl { return Ok(None); } - NodeImpl::parse(&name).map(Some).ok_or_else(|| { - de::Error::custom(format!( - "unknown keygen implementation; must be charon or pluto: impl={name}" - )) - }) + NodeImpl::deserialize(name.into_deserializer()).map(Some) } } @@ -269,7 +269,7 @@ mod nanos { /// /// 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, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Config { /// Config format version, see [`VERSION`]. @@ -338,39 +338,6 @@ pub struct Config { pub alert_disable_rules: Vec, } -impl Default for Config { - fn default() -> Self { - Self { - version: String::new(), - step: Step::New, - num_nodes: 0, - threshold: 0, - num_validators: 0, - image_tag: String::new(), - build_local: false, - node_impls: Vec::new(), - key_gen_impl: None, - pluto_image_tag: String::new(), - key_gen: KeyGen::Create, - split_keys_dir: String::new(), - beacon_nodes: String::new(), - external_relay: String::new(), - vcs: Vec::new(), - feature_set: String::new(), - disable_monitoring_ports: false, - insecure_keys: false, - slot_duration: Duration::ZERO, - beacon_fuzz: false, - p2p_fuzz: false, - synthetic_block_proposals: false, - monitoring: false, - builder_api: false, - alert_exclude_jobs: Vec::new(), - alert_disable_rules: Vec::new(), - } - } -} - impl Config { /// Returns the default config: four charon nodes with threshold three, /// one validator, `create` key generation, the beacon mock, two lighthouse @@ -385,7 +352,7 @@ impl Config { node_impls: vec![NodeImpl::Charon], pluto_image_tag: "local".to_string(), vcs: vec![VcType::Lighthouse, VcType::Lighthouse, VcType::Mock], - key_gen: DEFAULT_KEY_GEN, + key_gen: KeyGen::Create, beacon_nodes: DEFAULT_BEACON_NODE.to_string(), step: Step::New, feature_set: DEFAULT_FEATURE_SET.to_string(), @@ -410,9 +377,10 @@ impl Config { /// 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 { - index - .checked_rem(self.node_impls.len()) - .and_then(|i| self.node_impls.get(i)) + self.node_impls + .iter() + .cycle() + .nth(index) .copied() .unwrap_or(NodeImpl::Charon) } @@ -423,25 +391,14 @@ impl Config { self.key_gen_impl.unwrap_or_else(|| self.node_impl(0)) } - /// Returns the full docker image reference for an implementation. - pub fn impl_image(&self, node_impl: NodeImpl) -> String { + /// 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 => { - let tag = &self.image_tag; - format!("{CHARON_IMAGE}:{tag}") - } - } - } - - /// 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 => self.impl_image(node_impl), NodeImpl::Charon => String::new(), } } @@ -470,12 +427,14 @@ pub fn write_config(dir: impl AsRef, conf: &Config) -> Result<()> { let json = marshal_indent(conf).map_err(ComposeError::MarshalConfig)?; - write_file(dir.as_ref().join(CONFIG_FILE), json, 0o755).map_err(ComposeError::WriteConfig) + 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::LoadConfig)?; + 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()?; @@ -489,11 +448,9 @@ mod tests { use super::*; - #[test_case(&[], 0, NodeImpl::Charon ; "empty_defaults_to_charon")] #[test_case(&[NodeImpl::Pluto], 3, NodeImpl::Pluto ; "single_cycles")] - #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 0, NodeImpl::Charon ; "mixed_first")] - #[test_case(&[NodeImpl::Charon, NodeImpl::Pluto], 1, NodeImpl::Pluto ; "mixed_second")] #[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(), @@ -502,51 +459,6 @@ mod tests { assert_eq!(conf.node_impl(index), want); } - #[test] - fn keygen_impl_falls_back_to_node0() { - let mut conf = Config::new_default(); - conf.node_impls = vec![NodeImpl::Pluto, NodeImpl::Charon]; - assert_eq!(conf.keygen_impl(), NodeImpl::Pluto); - - conf.key_gen_impl = Some(NodeImpl::Charon); - assert_eq!(conf.keygen_impl(), NodeImpl::Charon); - } - - #[test] - fn images() { - let conf = Config { - image_tag: "v1".to_string(), - pluto_image_tag: "dev".to_string(), - ..Config::new_default() - }; - assert_eq!(conf.impl_image(NodeImpl::Charon), "obolnetwork/charon:v1"); - assert_eq!(conf.impl_image(NodeImpl::Pluto), "pluto:dev"); - assert_eq!(conf.image_override(NodeImpl::Charon), ""); - assert_eq!(conf.image_override(NodeImpl::Pluto), "pluto:dev"); - } - - #[test] - fn uses_pluto_checks_nodes_and_keygen() { - let mut conf = Config::new_default(); - assert!(!conf.uses_pluto()); - - conf.key_gen_impl = Some(NodeImpl::Pluto); - assert!(conf.uses_pluto()); - - conf.key_gen_impl = None; - conf.node_impls = vec![ - NodeImpl::Charon, - NodeImpl::Charon, - NodeImpl::Charon, - NodeImpl::Pluto, - ]; - assert!(conf.uses_pluto()); - - // A pluto entry beyond num_nodes is never reached. - conf.num_nodes = 3; - assert!(!conf.uses_pluto()); - } - #[test] fn config_roundtrips_through_json() { let mut conf = Config::new_default(); @@ -560,64 +472,22 @@ mod tests { assert_eq!(back, conf); } - #[test] - fn missing_fields_take_zero_values() { - let conf: Config = - serde_json::from_str(r#"{"version":"obol/charon/compose/1.0.0"}"#).expect("unmarshal"); - assert_eq!(conf.version, VERSION); - assert_eq!(conf.num_nodes, 0); - assert!(conf.node_impls.is_empty()); - assert_eq!(conf.key_gen_impl, None); - assert_eq!(conf.slot_duration, Duration::ZERO); - } - - #[test] - fn null_lists_load_as_empty() { - let conf: Config = serde_json::from_str(r#"{"node_impls":null,"validator_clients":null}"#) - .expect("unmarshal"); - assert!(conf.node_impls.is_empty()); - assert!(conf.vcs.is_empty()); - } - - #[test] - fn empty_lists_serialize_as_null() { - let conf = Config { - node_impls: Vec::new(), - vcs: Vec::new(), - ..Config::new_default() - }; - let json: serde_json::Value = - serde_json::from_slice(&marshal_indent(&conf).expect("marshal")).expect("parse"); - assert_eq!(json["node_impls"], serde_json::Value::Null); - assert_eq!(json["validator_clients"], serde_json::Value::Null); - assert_eq!( - json["keygen_impl"], - serde_json::Value::String(String::new()) - ); - assert!(json.get("alert_exclude_jobs").is_none()); - assert!(json.get("alert_disable_rules").is_none()); - } - #[test] fn config_validate_rejects_unknown_impl() { - // Enum-typed implementations cannot hold unknown names in memory, so - // the write-side assertions have no Rust counterpart; loading a - // hand-edited config with a bad impl still fails. + // 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 node implementation"), - "{err}" - ); + 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 keygen implementation"), + err.to_string().contains("unknown variant `plutoo`"), "{err}" ); @@ -629,15 +499,4 @@ mod tests { write_config(dir.path(), &conf).expect("write config"); assert_eq!(load_config(dir.path()).expect("load config"), conf); } - - #[test] - fn load_config_missing_file_keeps_not_found_kind() { - let dir = tempfile::tempdir().expect("tempdir"); - match load_config(dir.path()) { - Err(ComposeError::LoadConfig(err)) => { - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - } - other => panic!("unexpected result: {other:?}"), - } - } } diff --git a/crates/test-compose/src/define.rs b/crates/test-compose/src/define.rs index 55da3974..86547ea7 100644 --- a/crates/test-compose/src/define.rs +++ b/crates/test-compose/src/define.rs @@ -1,10 +1,6 @@ -//! Cluster definition step, compose directory cleaning and image builds. +//! Cluster definition step and local image builds. -use std::{ - env, fmt, fs, io, - path::Path, - process::{Command, Output}, -}; +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}; @@ -12,9 +8,9 @@ use tracing::info; use crate::{ Result, - config::{CMD_CREATE_DKG, CONFIG_FILE, Config, KeyGen, Step, write_config}, + config::{CHARON_IMAGE, CMD_CREATE_DKG, Config, KeyGen, NodeImpl, Step, write_config}, error::{CommandError, ComposeError}, - fsutil::{go_abs, go_path_join, go_rel, write_file}, + fsutil::{env_non_empty, go_abs, go_path_join, go_rel, write_file}, static_files::STATIC_FILES, template::{Kv, TmplData, TmplNode, write_docker_compose}, }; @@ -36,7 +32,7 @@ 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 `write_alert_rules` can generate; `alert_disable_rules` +/// 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, @@ -47,13 +43,11 @@ pub const ALERT_RULE_NAMES: [&str; 6] = [ BROADCAST_RULE, ]; -/// Error a key generator may return. -pub type KeyGenError = Box; - /// Generator for node p2p private keys. -pub type KeyGenFn = Box std::result::Result + Send + Sync>; +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. @@ -67,113 +61,11 @@ impl Default for DefineOptions { fn default() -> Self { Self { pull_images: true, - key_gen: Box::new(|| Ok(SecretKey::random(&mut OsRng))), + key_gen: || SecretKey::random(&mut OsRng), } } } -impl fmt::Debug for DefineOptions { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("DefineOptions") - .field("pull_images", &self.pull_images) - .field("key_gen", &"") - .finish() - } -} - -/// Deletes all compose artefacts in `dir`. -/// -/// The directory is only cleaned when its listing contains a `config.json` -/// entry whose full path is exactly `config.json`, i.e. when `dir` is the -/// working directory; anything else is reported as "config.json not found" -/// and left alone. Entries with `key` in their path are never deleted so a -/// long-lived split-keys folder survives. -pub fn clean(dir: impl AsRef) -> Result<()> { - let dir = dir.as_ref().to_string_lossy(); - let files = glob_all(&dir); - - // Make sure we ONLY delete compose artifacts. - let mut config_found = false; - let mut go_found = false; - - for file in &files { - if file == CONFIG_FILE { - config_found = true; - } else if file.ends_with(".go") || file.starts_with("go.") { - go_found = true; - } - } - - if !config_found { - info!("Not cleaning since config.json not found"); - return Ok(()); - } else if go_found { - return Err(ComposeError::GoFilesFound { - dir: dir.into_owned(), - }); - } - - info!(files = files.len(), "Cleaning compose dir"); - - for file in &files { - if file.contains("key") { - // Do not delete root folder with key in the name, since it might be - // long-lived split keys folder. - info!(path = %file, "Not deleting *key* folder"); - continue; - } - - remove_all(file).map_err(ComposeError::RemoveFile)?; - } - - Ok(()) -} - -/// Lists `dir/*` the way Go's `filepath.Glob(path.Join(dir, "*"))` does: -/// sorted, dotfiles included, each entry joined onto the cleaned directory, -/// and an unreadable or missing directory yielding no entries. -fn glob_all(dir: &str) -> Vec { - let pattern = go_path_join(dir, "*"); - let dir_part = match pattern.rfind('/') { - Some(i) => &pattern[..=i], - None => "", - }; - let dir_part = match dir_part { - "" => ".", - "/" => "/", - d => d.strip_suffix('/').unwrap_or(d), - }; - - let Ok(entries) = fs::read_dir(dir_part) else { - return Vec::new(); - }; - - let mut names: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .collect(); - names.sort_unstable(); - - names - .iter() - .map(|name| go_path_join(dir_part, name)) - .collect() -} - -/// Removes a file or directory tree; a missing path is not an error. -fn remove_all(path: &str) -> io::Result<()> { - let result = match fs::symlink_metadata(path) { - Ok(meta) if meta.is_dir() => fs::remove_dir_all(path), - Ok(_) => fs::remove_file(path), - Err(err) => Err(err), - }; - - match result { - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), - other => other, - } -} - /// 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(); @@ -206,7 +98,7 @@ pub fn define(dir: impl AsRef, mut conf: Config, opts: &DefineOptions) -> } if conf.build_local { - build_local()?; + build_local(NodeImpl::Charon)?; } if opts.pull_images && !conf.build_local && conf.image_tag == "latest" { @@ -214,7 +106,7 @@ pub fn define(dir: impl AsRef, mut conf: Config, opts: &DefineOptions) -> } if opts.pull_images && conf.uses_pluto() && conf.pluto_image_tag == "local" { - build_local_pluto()?; + build_local(NodeImpl::Pluto)?; } if !conf.split_keys_dir.is_empty() { @@ -226,21 +118,19 @@ pub fn define(dir: impl AsRef, mut conf: Config, opts: &DefineOptions) -> // charon create dkg requires operator ENRs, so we need to create // p2pkeys now. - let p2pkeys = new_p2p_keys(conf.num_nodes, &opts.key_gen)?; + let mut enrs = Vec::with_capacity(conf.num_nodes); - let mut enrs = Vec::with_capacity(p2pkeys.len()); + for i in 0..conf.num_nodes { + let key = (opts.key_gen)(); - for (i, key) in p2pkeys.iter().enumerate() { // 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)) - .map_err(ComposeError::SaveEnrPrivateKey)?; + pluto_k1util::save(&key, Path::new(&key_file))?; - let record = Record::from_key(key)?; - enrs.push(record.to_string()); + enrs.push(Record::from_key(&key)?.to_string()); } let kvs = vec![ @@ -289,8 +179,17 @@ pub fn define(dir: impl AsRef, mut conf: Config, opts: &DefineOptions) -> write_config(dir, &conf)?; copy_static_folders(dir)?; - write_prometheus_config(dir, &conf)?; - write_alert_rules(dir, &conf)?; + + 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"); @@ -316,8 +215,8 @@ pub(crate) fn rel_split_keys_dir(dir: &str, split_keys_dir: &str) -> Result Result<()> { info!("Pulling latest charon docker image"); let status = Command::new("docker") - .args(["pull", "obolnetwork/charon:latest"]) - .status() - .map_err(|err| ComposeError::RunDockerPull(CommandError::Io(err)))?; - - if !status.success() { - return Err(ComposeError::RunDockerPull(CommandError::Exit(status))); - } - - Ok(()) -} - -/// Builds the `obolnetwork/charon:local` docker image from the checkout the -/// `CHARON_REPO` environment variable points at. -pub fn build_local() -> Result<()> { - let repo = repo_from_env("CHARON_REPO").ok_or(ComposeError::CharonRepoNotSet)?; - - info!(repo = %repo, "Building `obolnetwork/charon:local` docker container"); + .args(["pull", &format!("{CHARON_IMAGE}:latest")]) + .status(); - docker_build(&repo, &["build", "-t", "obolnetwork/charon:local", "."]) + CommandError::check(status).map_err(ComposeError::exec("run docker pull")) } -/// Builds the `pluto:local` docker image from the checkout the `PLUTO_REPO` +/// Builds the `:local` docker image of `node_impl` from the checkout its repo /// environment variable points at. /// -/// 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 +/// 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_pluto() -> Result<()> { - let repo = repo_from_env("PLUTO_REPO").ok_or(ComposeError::PlutoRepoNotSet)?; +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 `pluto:local` docker container"); + info!(repo = %repo, "Building `{image}` docker container"); - let mut args = vec![ - "build".to_string(), - "-t".to_string(), - "pluto:local".to_string(), - ]; + let mut args = vec!["build".to_string(), "-t".to_string(), image]; - if let Ok(hash) = git_commit_hash_short(&repo) { + 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()); - docker_build(&repo, &args) -} - -/// Reads a repo path from the environment; unset, empty or non-UTF-8 values -/// count as not set. -fn repo_from_env(var: &str) -> Option { - env::var(var).ok().filter(|repo| !repo.is_empty()) -} - -/// Runs `docker ` in `repo`, reporting the combined output on failure. -fn docker_build>(repo: &str, args: &[S]) -> Result<()> { let output = Command::new("docker") - .args(args) - .current_dir(repo) - .output() - .map_err(|err| ComposeError::ExecDockerBuild { - source: CommandError::Io(err), - output: String::new(), - })?; - - if !output.status.success() { - return Err(ComposeError::ExecDockerBuild { - source: CommandError::Exit(output.status), - output: combined_output(&output), - }); - } + .args(&args) + .current_dir(&repo) + .output(); - Ok(()) -} - -/// 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 + CommandError::check_output(output) + .map(drop) + .map_err(ComposeError::exec("exec docker build")) } /// Returns the repo's short (7 char) commit hash. @@ -414,12 +273,9 @@ fn git_commit_hash_short(repo: &str) -> Result { let output = Command::new("git") .args(["rev-parse", "--short=7", "HEAD"]) .current_dir(repo) - .output() - .map_err(|err| ComposeError::GitRevParse(CommandError::Io(err)))?; + .output(); - if !output.status.success() { - return Err(ComposeError::GitRevParse(CommandError::Exit(output.status))); - } + let output = CommandError::check_output(output).map_err(ComposeError::exec("git rev-parse"))?; Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } @@ -427,26 +283,29 @@ fn git_commit_hash_short(repo: &str) -> Result { /// Copies the embedded static folders to the compose dir; scripts are made /// executable. fn copy_static_folders(dir: &Path) -> Result<()> { - for file in STATIC_FILES { - let sub_dir = dir.join(file.dir); - mkdir_all(&sub_dir, 0o755).map_err(ComposeError::MkdirAll)?; + 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(sub_dir.join(file.name), file.bytes, mode).map_err(ComposeError::WriteFile)?; + write_file(dir.join(file.dir).join(file.name), file.bytes, mode) + .map_err(ComposeError::io("write file"))?; } Ok(()) } -/// Writes Prometheus scrape configs for the actual cluster size, replacing +/// 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 write_prometheus_config(dir: &Path, conf: &Config) -> Result<()> { +pub(crate) fn prometheus_config(conf: &Config) -> String { let mut b = String::from( "global: scrape_interval: 5s @@ -475,17 +334,14 @@ rule_files: ", ); - let prom_dir = dir.join("prometheus"); - mkdir_all(&prom_dir, 0o755).map_err(ComposeError::MkdirPrometheus)?; - - write_file(prom_dir.join("prometheus.yml"), b, 0o644).map_err(ComposeError::WritePrometheusYml) + b } -/// Writes the Prometheus alert rules the smoke test gates on. +/// 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 write_alert_rules(dir: &Path, conf: &Config) -> Result<()> { +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() @@ -504,16 +360,12 @@ pub(crate) fn write_alert_rules(dir: &Path, conf: &Config) -> Result<()> { } }; - // Warn topics that are mock artefacts, not node behaviour: the validator - // mock warns about pending duties before the first epoch, and the tracker - // warns about every broadcast the mock beacon node never includes on-chain. + // 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"; - // Inject a zero for every scraped node job (`0 * up`) so a node with no - // core_bcast_broadcast_total series at all (the counter is only created - // on first broadcast) alerts too. Summed per job because the per-duty - // sync_message series legitimately pauses most epochs. Scoped to node - // jobs: the relay never broadcasts duties. + // `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]); @@ -528,10 +380,8 @@ pub(crate) fn write_alert_rules(dir: &Path, conf: &Config) -> Result<()> { PLUTO_DOWN_RULE, rule_block(PLUTO_DOWN_RULE, "up == 0", "is down"), ), - // Windowed instead of charon's absolute app_log_error_total > 0: a - // fresh simnet cluster logs exactly one consensus timeout error per - // node at the first epoch boundary, which an absolute counter gate - // could never recover from. + // 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( @@ -576,23 +426,13 @@ pub(crate) fn write_alert_rules(dir: &Path, conf: &Config) -> Result<()> { ), ]; - let mut b = String::from("groups:\n- name: pluto\n rules:\n"); - - for (name, block) in &rule_blocks { - if conf.alert_disable_rules.iter().any(|rule| rule == name) { - continue; - } - - b.push_str(block); - b.push('\n'); - } - - let rules = b.strip_suffix('\n').unwrap_or(&b); - - let prom_dir = dir.join("prometheus"); - mkdir_all(&prom_dir, 0o755).map_err(ComposeError::MkdirPrometheus)?; + let blocks: Vec<&str> = rule_blocks + .iter() + .filter(|(name, _)| !conf.alert_disable_rules.iter().any(|rule| rule == name)) + .map(|(_, block)| block.as_str()) + .collect(); - write_file(prom_dir.join("rules.yml"), rules, 0o644).map_err(ComposeError::WriteRulesYml) + format!("groups:\n- name: pluto\n rules:\n{}", blocks.join("\n")) } /// Formats one alert rule block, firing after 15 seconds of `expr`. @@ -607,13 +447,6 @@ fn rule_block(name: &str, expr: &str, description: &str) -> String { ) } -/// Generates `n` node p2p private keys with `key_gen`. -fn new_p2p_keys(n: usize, key_gen: &KeyGenFn) -> Result> { - (0..n) - .map(|_| key_gen().map_err(ComposeError::NewKey)) - .collect() -} - /// 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 { @@ -622,32 +455,17 @@ pub(crate) fn node_file(dir: &str, i: usize, file: &str) -> String { #[cfg(test)] mod tests { - use std::fs; - use super::*; - /// Writes alert rules for `conf` into a temp dir and returns them. - fn write_rules(conf: &Config) -> String { - let dir = tempfile::tempdir().expect("tempdir"); - write_alert_rules(dir.path(), conf).expect("write alert rules"); - - fs::read_to_string(dir.path().join("prometheus").join("rules.yml")).expect("read rules.yml") - } - /// 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 write_prometheus_config_scrapes_all_nodes() { - let dir = tempfile::tempdir().expect("tempdir"); - + fn prometheus_config_scrapes_all_nodes() { let mut conf = Config::new_default(); conf.num_nodes = 10; - write_prometheus_config(dir.path(), &conf).expect("write prometheus config"); - - let content = fs::read_to_string(dir.path().join("prometheus").join("prometheus.yml")) - .expect("read prometheus.yml"); + let content = prometheus_config(&conf); assert!(content.contains("- targets: [ 'relay:3620' ]"), "{content}"); for i in 0..conf.num_nodes { @@ -671,8 +489,8 @@ mod tests { /// jobs with no core_bcast_broadcast_total series, so a node that never /// broadcasts fails instead of silently passing. #[test] - fn write_alert_rules_broadcast_covers_missing_series() { - let content = write_rules(&Config::new_default()); + fn alert_rules_broadcast_covers_missing_series() { + let content = alert_rules(&Config::new_default()); assert!( content.contains( @@ -685,11 +503,11 @@ mod tests { /// `alert_exclude_jobs` exempts a node from every behavioural rule while /// "Pluto Down" keeps watching it. #[test] - fn write_alert_rules_excludes_degraded_jobs() { + fn alert_rules_excludes_degraded_jobs() { let mut conf = Config::new_default(); conf.alert_exclude_jobs = vec!["node0".to_string()]; - let content = write_rules(&conf); + let content = alert_rules(&conf); assert!( content.contains(r#"increase(app_log_error_total{job!~"node0"}[30s]) > 0"#), @@ -727,8 +545,8 @@ mod tests { /// The Warn Log Rate gate excludes exactly the two charon mock-noise /// topics. #[test] - fn write_alert_rules_warn_topics() { - let content = write_rules(&Config::new_default()); + 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}" @@ -738,8 +556,8 @@ mod tests { /// Charon's dead "Outstanding Duty Rate" rule stays removed: broadcast /// counts can never exceed scheduled counts, so it could never fire. #[test] - fn write_alert_rules_drops_outstanding_duty() { - let content = write_rules(&Config::new_default()); + 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}"); } @@ -747,11 +565,11 @@ mod tests { /// `alert_disable_rules` drops exactly the named rules and validation /// rejects unknown names. #[test] - fn write_alert_rules_disable_rules() { + 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 = write_rules(&conf); + let content = alert_rules(&conf); assert!(!content.contains("Error Log Rate"), "{content}"); assert!(!content.contains(r#"endpoint!="proxy""#), "{content}"); // The remaining gates stay. @@ -767,22 +585,6 @@ mod tests { assert!(err.to_string().contains("unknown alert rule name"), "{err}"); } - #[test] - fn write_alert_rules_has_no_trailing_newline_and_all_rules() { - let content = write_rules(&Config::new_default()); - assert!(content.starts_with("groups:\n- name: pluto\n rules:\n")); - // The blank-line separator after the last block is trimmed; the block's - // own newline stays. - assert!(content.ends_with("\"\n"), "{content:?}"); - assert!(!content.ends_with("\n\n"), "{content:?}"); - for name in ALERT_RULE_NAMES { - assert!( - content.contains(&format!(" - alert: {name}\n")), - "{content}" - ); - } - } - #[test] fn define_rejects_non_new_step() { let mut conf = Config::new_default(); @@ -794,105 +596,4 @@ mod tests { "compose config not new, so can't be defined: step=locked" ); } - - #[test] - fn define_rejects_split_keys_dir_outside_compose_dir() { - let dir = tempfile::tempdir().expect("tempdir"); - let outside = tempfile::tempdir().expect("tempdir"); - - let mut conf = Config::new_default(); - conf.split_keys_dir = outside.path().to_string_lossy().into_owned(); - - let opts = DefineOptions { - pull_images: false, - ..DefineOptions::default() - }; - let err = define(dir.path(), conf, &opts).expect_err("must fail"); - assert!( - err.to_string() - .starts_with("split-keys-dir must be a child of compose dir: relative=.."), - "{err}" - ); - } - - #[test] - fn rel_split_keys_dir_variants() { - assert_eq!(rel_split_keys_dir("/a/b", "").expect("empty"), ""); - assert_eq!( - rel_split_keys_dir("/a/b", "/a/b/keys").expect("child"), - "keys" - ); - assert_eq!( - rel_split_keys_dir("/a/b", "/a/keys").expect("sibling"), - "../keys" - ); - } - - #[test] - fn clean_leaves_dir_when_config_path_is_not_bare() { - // Entries are compared by full path, so a config.json below a real - // directory is never recognised and nothing is deleted. - let dir = tempfile::tempdir().expect("tempdir"); - fs::write(dir.path().join(CONFIG_FILE), "{}").expect("write config"); - fs::write(dir.path().join("docker-compose.yml"), "x").expect("write yml"); - - clean(dir.path()).expect("clean"); - - assert!(dir.path().join(CONFIG_FILE).exists()); - assert!(dir.path().join("docker-compose.yml").exists()); - } - - #[test] - fn glob_all_lists_sorted_joined_entries_and_tolerates_missing_dir() { - let dir = tempfile::tempdir().expect("tempdir"); - fs::write(dir.path().join("b"), "").expect("write"); - fs::write(dir.path().join("a"), "").expect("write"); - fs::write(dir.path().join(".hidden"), "").expect("write"); - - let dir_str = dir.path().to_string_lossy().into_owned(); - let got = glob_all(&format!("{dir_str}/")); - assert_eq!( - got, - vec![ - format!("{dir_str}/.hidden"), - format!("{dir_str}/a"), - format!("{dir_str}/b"), - ] - ); - - assert!(glob_all(&format!("{dir_str}/does-not-exist")).is_empty()); - } - - #[test] - fn node_file_paths() { - assert_eq!(node_file("/c", 0, ""), "/c/node0"); - assert_eq!( - node_file("/c/", 2, "charon-enr-private-key"), - "/c/node2/charon-enr-private-key" - ); - assert_eq!(node_file("", 1, ""), "node1"); - } - - #[test] - fn copy_static_folders_writes_all_files_with_modes() { - let dir = tempfile::tempdir().expect("tempdir"); - copy_static_folders(dir.path()).expect("copy"); - - for file in STATIC_FILES { - let path = dir.path().join(file.dir).join(file.name); - assert_eq!(fs::read(&path).expect("read"), file.bytes, "{path:?}"); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let mode = fs::metadata(&path).expect("meta").permissions().mode() & 0o777; - let want = if file.name.ends_with(".sh") { - 0o755 - } else { - 0o644 - }; - assert_eq!(mode, want, "{path:?}"); - } - } - } } diff --git a/crates/test-compose/src/duration.rs b/crates/test-compose/src/duration.rs index 1723504a..52e9f03c 100644 --- a/crates/test-compose/src/duration.rs +++ b/crates/test-compose/src/duration.rs @@ -77,26 +77,10 @@ mod tests { // Vectors generated with Go's time.Duration.String(). #[test_case(0, "0s")] - #[test_case(1, "1ns")] - #[test_case(999, "999ns")] - #[test_case(1000, "1µs" ; "one_microsecond")] - #[test_case(1500, "1.5µs" ; "one_and_half_microseconds")] - #[test_case(999_999, "999.999µs" ; "just_under_one_millisecond")] - #[test_case(1_000_000, "1ms")] - #[test_case(12_000_000, "12ms")] - #[test_case(500_000_000, "500ms")] + #[test_case(1500, "1.5µs" ; "fractional_microseconds")] #[test_case(999_999_999, "999.999999ms")] - #[test_case(1_000_000_000, "1s")] - #[test_case(1_500_000_000, "1.5s")] - #[test_case(59_000_000_000, "59s")] #[test_case(60_000_000_000, "1m0s")] - #[test_case(61_000_000_000, "1m1s")] - #[test_case(120_000_000_000, "2m0s")] - #[test_case(5_400_000_000_000, "1h30m0s")] - #[test_case(3_600_000_000_000, "1h0m0s")] #[test_case(3_661_500_000_000, "1h1m1.5s")] - #[test_case(360_000_000_000_000, "100h0m0s")] - #[test_case(1_234_567_890_123, "20m34.567890123s")] #[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 index 5914db89..ee754381 100644 --- a/crates/test-compose/src/error.rs +++ b/crates/test-compose/src/error.rs @@ -1,282 +1,158 @@ -use std::{io, process::ExitStatus}; +use std::{ + io, + process::{ExitStatus, Output}, +}; use pluto_eth2util::enr::RecordError; use pluto_k1util::K1UtilError; -use crate::{config::Step, gotmpl}; +use crate::config::{NodeImpl, Step}; /// Failure of a child process such as `docker` or `git`. #[derive(Debug, thiserror::Error)] pub enum CommandError { - /// The process could not be spawned or waited on. #[error(transparent)] Io(#[from] io::Error), - /// The process ran but exited unsuccessfully. #[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 { - /// The directory holds Go sources, so it is not a compose directory. - #[error("go files found, compose dir incorrect: dir={dir}")] - GoFilesFound { - /// The directory that was about to be cleaned. - dir: String, - }, - - /// Deleting a compose artefact failed. - #[error("remove file: {0}")] - RemoveFile(#[source] io::Error), - - /// `define` requires a config at the `new` step. #[error("compose config not new, so can't be defined: step={step}")] - NotNew { - /// The step the config is actually at. - step: Step, - }, + NotNew { step: Step }, - /// `lock` requires a config at the `defined` step. #[error("compose config not defined, so can't be locked: step={step}")] - NotDefined { - /// The step the config is actually at. - step: Step, - }, + NotDefined { step: Step }, - /// `run` requires a config at the `locked` step. #[error("compose config not locked, so can't be run: step={step}")] - NotLocked { - /// The step the config is actually at. - step: Step, - }, - - /// The configured key generator failed to produce a p2p key. - #[error("new key: {0}")] - NewKey(#[source] Box), + NotLocked { step: Step }, - /// Writing a node's ENR private key failed. #[error("save charon-enr-private-key: {0}")] - SaveEnrPrivateKey(#[source] K1UtilError), + SaveEnrPrivateKey(#[from] K1UtilError), - /// Building a node's ENR failed. #[error(transparent)] Enr(#[from] RecordError), - /// The split keys directory is outside the compose directory. #[error("split-keys-dir must be a child of compose dir: relative={relative}")] - SplitKeysDirNotChild { - /// The split keys directory relative to the compose directory. - relative: String, - }, - - /// Resolving a directory to an absolute path failed. - #[error("abs dir: {0}")] - AbsDir(#[source] io::Error), + SplitKeysDirNotChild { relative: String }, - /// The split keys directory cannot be expressed relative to the compose - /// directory. #[error("relative split keys dir: Rel: can't make {target} relative to {base}")] - RelativeSplitKeysDir { - /// The absolute compose directory. - base: String, - /// The absolute split keys directory. - target: String, - }, - - /// `docker pull` failed. - #[error("run docker pull: {0}")] - RunDockerPull(#[source] CommandError), + RelativeSplitKeysDir { base: String, target: String }, - /// A local charon build was requested without `CHARON_REPO`. #[error( - "cannot build local charon binary; CHARON_REPO env var, the path to the charon repo, is not set" + "cannot build local {node_impl} binary; {var} env var, the path to the {node_impl} repo, is not set" )] - CharonRepoNotSet, + RepoNotSet { + node_impl: NodeImpl, + var: &'static str, + }, - /// A local pluto build was requested without `PLUTO_REPO`. - #[error( - "cannot build local pluto binary; PLUTO_REPO env var, the path to the pluto repo, is not set" - )] - PlutoRepoNotSet, + /// A file system operation named by `context` failed. + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, - /// `docker build` failed. - #[error("exec docker build: {source}: output={output}")] - ExecDockerBuild { - /// The process failure. + /// A child process named by `cmd` could not be run or failed. + #[error("{cmd}: {source}")] + Exec { + cmd: String, #[source] source: CommandError, - /// Combined stdout and stderr of the build. - output: String, }, - /// `git rev-parse` failed. - #[error("git rev-parse: {0}")] - GitRevParse(#[source] CommandError), - - /// Creating a static config directory failed. - #[error("mkdir all: {0}")] - MkdirAll(#[source] io::Error), - - /// Writing a static config file failed. - #[error("write file: {0}")] - WriteFile(#[source] io::Error), - - /// Creating the prometheus directory failed. - #[error("mkdir prometheus: {0}")] - MkdirPrometheus(#[source] io::Error), - - /// Writing the prometheus scrape config failed. - #[error("write prometheus.yml: {0}")] - WritePrometheusYml(#[source] io::Error), - - /// Writing the prometheus alert rules failed. - #[error("write rules.yml: {0}")] - WriteRulesYml(#[source] io::Error), - - /// `alert_disable_rules` names a rule that does not exist. #[error("unknown alert rule name in alert_disable_rules: rule={rule}")] - UnknownAlertRule { - /// The unknown rule name. - rule: String, - }, + UnknownAlertRule { rule: String }, - /// Serialising the config failed. #[error("marshal config: {0}")] MarshalConfig(#[source] serde_json::Error), - /// Writing `config.json` failed. - #[error("write config: {0}")] - WriteConfig(#[source] io::Error), - - /// Reading `config.json` failed. - #[error("load config: {0}")] - LoadConfig(#[source] io::Error), - - /// Parsing `config.json` failed. #[error("unmarshal Config: {0}")] UnmarshalConfig(#[source] serde_json::Error), - /// `run` needs at least one validator client type to cycle through. #[error("no validator clients configured")] NoValidatorClients, - /// A node's external port offset does not fit the port type. #[error("external port overflow: node={index}")] - PortOverflow { - /// The node index whose ports overflowed. - index: usize, - }, + PortOverflow { index: usize }, - /// Rendering the teku command template failed. - #[error("teku template: {0}")] - TekuTemplate(#[source] gotmpl::Error), - - /// Parsing the docker-compose template failed. - #[error("new template: {0}")] - NewTemplate(#[source] gotmpl::Error), - - /// Rendering the docker-compose template failed. - #[error("exec template: {0}")] - ExecTemplate(#[source] gotmpl::Error), - - /// Writing `docker-compose.yml` failed. - #[error("write docker-compose.yml: {0}")] - WriteDockerCompose(#[source] io::Error), - - /// Opening the `docker compose up` log file failed. - #[error("open log file: {0}")] - OpenLogFile(#[source] io::Error), - - /// Printing `docker-compose.yml` with `cat` failed. - #[error("exec cat docker-compose.yml: {0}")] - ExecCatDockerCompose(#[source] CommandError), - - /// A `sudo` command fixing artefact permissions failed. - #[error("exec sudo {program}: {source}")] - ExecSudo { - /// The program run under sudo (`chown` or `chmod`). - program: String, - /// The process failure. - #[source] - source: CommandError, - }, - - /// `docker compose down` failed. - #[error("run down: {0}")] - RunDown(#[source] CommandError), - - /// `docker compose build` failed. - #[error("exec docker compose build: {source}: output={output}")] - ExecComposeBuild { - /// The process failure. - #[source] - source: CommandError, - /// Combined stdout and stderr of the build. - output: String, - }, - - /// `docker compose up` failed. - #[error("exec docker compose up: {0}")] - ExecComposeUp(#[source] CommandError), - - /// `docker compose up --no-start --build` failed. - #[error("exec docker compose up --no-start --build: {source}: output={output}")] - ExecComposeCreate { - /// The process failure. - #[source] - source: CommandError, - /// Combined stdout and stderr of the command. - output: String, - }, - - /// The cluster exited before the alert observation window elapsed. #[error("cluster stopped before the observation window elapsed")] ClusterStopped, - /// Prometheus polling was not still healthy when the window closed. #[error("prometheus was not polled successfully through the end of the observation window")] PrometheusNotPolled, - /// Alerts fired while the cluster was observed. #[error("alerts detected: alerts=[{}]", .alerts.join(" "))] - AlertsDetected { - /// Descriptions of the firing alerts, in the order they were detected. - alerts: Vec, - }, - - /// The compose directory holds no `config.json`. - #[error("compose config.json not found; write one with WriteConfig or New first: dir={dir}")] - ConfigNotFound { - /// The compose directory. - dir: String, - }, + AlertsDetected { alerts: Vec }, - /// Querying the Prometheus rules API through the `curl` container failed. - #[error("exec curl alerts: {source}: out={out}")] - ExecCurlAlerts { - /// The process failure. - #[source] - source: CommandError, - /// Combined stdout and stderr of the query. - out: String, - }, - - /// Parsing the Prometheus rules API response failed. #[error("unmarshal alerts: {source}: out={out}")] UnmarshalAlerts { - /// The parse failure. #[source] source: serde_json::Error, - /// The response that failed to parse. out: String, }, - /// The blocking generator step was cancelled before it completed. #[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 index 5b4956ce..c9f6943a 100644 --- a/crates/test-compose/src/fsutil.rs +++ b/crates/test-compose/src/fsutil.rs @@ -1,7 +1,15 @@ //! File and path helpers with Go `os`/`path` semantics where the generated //! output depends on them. -use std::{fs, io, path::Path}; +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. /// @@ -118,40 +126,17 @@ pub(crate) fn go_rel(base: &str, target: &str) -> Option { #[cfg(test)] mod tests { - use test_case::test_case; - use super::*; - #[test_case("", "." ; "empty")] - #[test_case(".", "." ; "dot")] - #[test_case("a/b/c", "a/b/c" ; "already_clean")] - #[test_case("a//b/./c/", "a/b/c" ; "collapse")] - #[test_case("a/b/../c", "a/c" ; "dotdot")] - #[test_case("a/../..", ".." ; "dotdot_escapes")] - #[test_case("/..", "/" ; "rooted_dotdot")] - #[test_case("/tmp/x/", "/tmp/x" ; "trailing_slash")] - #[test_case("./config.json", "config.json" ; "leading_dot")] - fn path_clean(input: &str, want: &str) { - assert_eq!(go_path_clean(input), want); - } - - #[test_case("", "*", "*" ; "empty_dir")] - #[test_case(".", "*", "*" ; "dot_dir")] - #[test_case("/compose", "keys", "/compose/keys" ; "abs")] - #[test_case("/compose", "./keys/", "/compose/keys" ; "cleans")] - #[test_case("dir/", "node0", "dir/node0" ; "trailing_slash")] - fn path_join(a: &str, b: &str, want: &str) { - assert_eq!(go_path_join(a, b), want); + #[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_case("/a/b", "/a/b", Some(".") ; "same")] - #[test_case("/a/b", "/a/b/c", Some("c") ; "child")] - #[test_case("/a/b", "/a/b/c/d", Some("c/d") ; "grandchild")] - #[test_case("/a/b", "/a", Some("..") ; "parent")] - #[test_case("/a/b", "/c", Some("../../c") ; "sibling_tree")] - #[test_case("/", "/a", Some("a") ; "from_root")] - fn rel(base: &str, target: &str, want: Option<&str>) { - assert_eq!(go_rel(base, target), want.map(str::to_string)); + #[test] + fn rel() { + assert_eq!(go_rel("/a/b", "/c"), Some("../../c".to_string())); } #[test] diff --git a/crates/test-compose/src/golden_tests.rs b/crates/test-compose/src/golden_tests.rs index 6387346c..e73c7a72 100644 --- a/crates/test-compose/src/golden_tests.rs +++ b/crates/test-compose/src/golden_tests.rs @@ -8,22 +8,26 @@ use std::{fs, path::Path}; -use pluto_testutil::random::generate_insecure_k1_key; +use k256::SecretKey; use test_case::test_case; use crate::{ - Config, DefineOptions, KeyGen, NodeImpl, Result, Step, TmplData, config::marshal_indent, - define, lock, new, run, + 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: seed-0 insecure keys, -/// no image pulls or builds. +/// 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: Box::new(|| Ok(generate_insecure_k1_key(0))), + key_gen: || SecretKey::from_slice(&[1u8; 32]).expect("valid secret key"), } } @@ -114,7 +118,7 @@ fn docker_compose( fn new_default_config() { let dir = tempfile::tempdir().expect("tempdir"); - new(dir.path(), Config::new_default()).expect("new"); + 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); diff --git a/crates/test-compose/src/gotmpl.rs b/crates/test-compose/src/gotmpl.rs deleted file mode 100644 index fa39db84..00000000 --- a/crates/test-compose/src/gotmpl.rs +++ /dev/null @@ -1,621 +0,0 @@ -//! Interpreter for the subset of Go `text/template` syntax used by the -//! compose templates. -//! -//! Supported: literal text, `{{.}}`, `{{.Field.Chain}}`, `{{$var.Field}}`, -//! `{{if pipeline}}…{{end}}`, `{{range pipeline}}…{{end}}`, -//! `{{range $elem := pipeline}}`, `{{range $idx, $elem := pipeline}}` and the -//! `{{- ` / ` -}}` whitespace trim markers, all with Go's semantics. -//! -//! Everything else (`else`, `with`, `define`, functions, pipes, literals, -//! comparisons) is rejected while parsing so that a template edit relying on -//! an unimplemented construct fails loudly instead of rendering wrongly. - -use std::{iter::Peekable, vec::IntoIter}; - -/// The whitespace characters stripped by the trim markers. -const SPACE_CHARS: &[char] = &[' ', '\t', '\r', '\n']; - -/// Template parse or execution error. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum Error { - /// An action opened with `{{` was never closed. - #[error("unclosed action at byte {offset}")] - UnclosedAction { - /// Byte offset of the `{{`. - offset: usize, - }, - - /// The action uses a construct this interpreter does not implement. - #[error("unsupported template action at byte {offset}: {{{{{action}}}}}")] - Unsupported { - /// Byte offset of the action. - offset: usize, - /// The trimmed action body. - action: String, - }, - - /// `{{end}}` without a matching `if` or `range`. - #[error("unexpected {{{{end}}}} at byte {offset}")] - UnexpectedEnd { - /// Byte offset of the action. - offset: usize, - }, - - /// An `if` or `range` was never closed. - #[error("missing {{{{end}}}} for {kind} at byte {offset}")] - MissingEnd { - /// Which construct is open. - kind: &'static str, - /// Byte offset of the opening action. - offset: usize, - }, - - /// A token is not a field chain or variable reference. - #[error("bad operand: {token}")] - BadOperand { - /// The offending token. - token: String, - }, - - /// Field access on a value without that field. - #[error("can't evaluate field {field} in {kind}")] - NoField { - /// The requested field. - field: String, - /// The kind of value it was requested on. - kind: &'static str, - }, - - /// A `$var` that is not in scope. - #[error("undefined variable: ${name}")] - UndefinedVariable { - /// The variable name without the `$`. - name: String, - }, - - /// `range` over a value that is not a list. - #[error("range can't iterate over {kind}")] - NotIterable { - /// The kind of value ranged over. - kind: &'static str, - }, - - /// Printing a value that has no textual form. - #[error("can't print {kind}")] - NotPrintable { - /// The kind of value printed. - kind: &'static str, - }, - - /// A range index does not fit the template integer type. - #[error("range index overflow")] - IndexOverflow, -} - -/// A value the template can evaluate. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Value { - /// A string, printed verbatim. - Str(String), - /// A signed integer. - Int(i64), - /// A boolean, printed as `true` / `false`. - Bool(bool), - /// A list, iterable with `range`. - List(Vec), - /// A struct-like value with named fields. - Object(Vec<(String, Value)>), -} - -impl Value { - /// Builds an object from `(field, value)` pairs. - pub fn object>(fields: impl IntoIterator) -> Self { - Value::Object(fields.into_iter().map(|(k, v)| (k.into(), v)).collect()) - } - - /// Builds a string value. - pub fn str(value: impl Into) -> Self { - Value::Str(value.into()) - } - - /// Builds a list of string values. - pub fn str_list>(items: impl IntoIterator) -> Self { - Value::List(items.into_iter().map(Value::str).collect()) - } - - fn kind(&self) -> &'static str { - match self { - Value::Str(_) => "string", - Value::Int(_) => "int", - Value::Bool(_) => "bool", - Value::List(_) => "list", - Value::Object(_) => "object", - } - } - - fn field(&self, name: &str) -> Result<&Value, Error> { - if let Value::Object(fields) = self - && let Some((_, value)) = fields.iter().find(|(k, _)| k == name) - { - return Ok(value); - } - - Err(Error::NoField { - field: name.to_string(), - kind: self.kind(), - }) - } - - /// Go truthiness: false, zero, empty string and empty list are false. - fn truthy(&self) -> bool { - match self { - Value::Str(s) => !s.is_empty(), - Value::Int(i) => *i != 0, - Value::Bool(b) => *b, - Value::List(l) => !l.is_empty(), - Value::Object(_) => true, - } - } - - fn print(&self, out: &mut String) -> Result<(), Error> { - match self { - Value::Str(s) => out.push_str(s), - Value::Int(i) => out.push_str(&i.to_string()), - Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), - Value::List(_) | Value::Object(_) => { - return Err(Error::NotPrintable { kind: self.kind() }); - } - } - - Ok(()) - } -} - -/// What an operand starts from: the current dot or a `$variable`. -#[derive(Debug)] -enum Base { - Dot, - Var(String), -} - -/// A field chain rooted at dot or a variable, e.g. `.Nodes`, `$vc.Label`, `.`. -#[derive(Debug)] -struct Operand { - base: Base, - path: Vec, -} - -#[derive(Debug)] -enum Node { - Text(String), - Print(Operand), - If { - cond: Operand, - body: Vec, - }, - Range { - index_var: Option, - elem_var: Option, - over: Operand, - body: Vec, - }, -} - -#[derive(Debug)] -enum Token { - Text(String), - Action { body: String, offset: usize }, -} - -/// A parsed template. -#[derive(Debug)] -pub struct Template { - nodes: Vec, -} - -impl Template { - /// Parses template source, rejecting unsupported constructs. - pub fn parse(src: &str) -> Result { - let tokens = lex(src)?; - let mut tokens = tokens.into_iter().peekable(); - let nodes = parse_nodes(&mut tokens, None)?; - - Ok(Self { nodes }) - } - - /// Renders the template with `dot` as the root value. - pub fn execute(&self, dot: &Value) -> Result { - let mut out = String::new(); - let mut vars = Vec::new(); - exec(&self.nodes, dot, &mut vars, &mut out)?; - - Ok(out) - } -} - -fn is_space(c: char) -> bool { - SPACE_CHARS.contains(&c) -} - -fn lex(src: &str) -> Result, Error> { - let mut tokens = Vec::new(); - let mut rest = src; - let mut pos = 0usize; - let mut trim_next = false; - - loop { - let Some(open) = rest.find("{{") else { - push_text(&mut tokens, rest, trim_next, false); - break; - }; - - let text = rest.get(..open).unwrap_or_default(); - let after = rest.get(open.saturating_add(2)..).unwrap_or_default(); - - // `{{- ` (dash followed by a space) trims the preceding text. - let trim_left = - after.starts_with('-') && after.get(1..).is_some_and(|s| s.starts_with(is_space)); - push_text(&mut tokens, text, trim_next, trim_left); - - let body_skip = if trim_left { 2 } else { 0 }; - let body_rest = after.get(body_skip..).unwrap_or_default(); - let action_offset = pos.saturating_add(open); - - let Some(close) = body_rest.find("}}") else { - return Err(Error::UnclosedAction { - offset: action_offset, - }); - }; - - let mut body = body_rest.get(..close).unwrap_or_default(); - - // ` -}}` (space followed by dash) trims the following text. - let trim_right = body.ends_with('-') - && body - .get(..body.len().saturating_sub(1)) - .is_some_and(|s| s.ends_with(is_space)); - if trim_right { - body = body.get(..body.len().saturating_sub(2)).unwrap_or_default(); - } - - tokens.push(Token::Action { - body: body.trim_matches(is_space).to_string(), - offset: action_offset, - }); - trim_next = trim_right; - - let consumed = open - .saturating_add(2) - .saturating_add(body_skip) - .saturating_add(close) - .saturating_add(2); - pos = pos.saturating_add(consumed); - rest = rest.get(consumed..).unwrap_or_default(); - } - - Ok(tokens) -} - -fn push_text(tokens: &mut Vec, text: &str, trim_start: bool, trim_end: bool) { - let mut text = text; - if trim_start { - text = text.trim_start_matches(is_space); - } - if trim_end { - text = text.trim_end_matches(is_space); - } - if !text.is_empty() { - tokens.push(Token::Text(text.to_string())); - } -} - -fn parse_nodes( - tokens: &mut Peekable>, - open: Option<(&'static str, usize)>, -) -> Result, Error> { - let mut nodes = Vec::new(); - - while let Some(token) = tokens.next() { - let (body, offset) = match token { - Token::Text(text) => { - nodes.push(Node::Text(text)); - continue; - } - Token::Action { body, offset } => (body, offset), - }; - - // Commas are their own tokens in Go's lexer (`$i, $node`). - let spaced = body.replace(',', " , "); - let words: Vec<&str> = spaced.split_whitespace().collect(); - let unsupported = || Error::Unsupported { - offset, - action: body.clone(), - }; - - match words.as_slice() { - ["end"] => { - return match open { - Some(_) => Ok(nodes), - None => Err(Error::UnexpectedEnd { offset }), - }; - } - ["if", cond] => { - let cond = parse_operand(cond)?; - let body = parse_nodes(tokens, Some(("if", offset)))?; - nodes.push(Node::If { cond, body }); - } - ["range", over] => { - let over = parse_operand(over)?; - let body = parse_nodes(tokens, Some(("range", offset)))?; - nodes.push(Node::Range { - index_var: None, - elem_var: None, - over, - body, - }); - } - ["range", elem, ":=", over] => { - let elem_var = Some(parse_var_decl(elem)?); - let over = parse_operand(over)?; - let body = parse_nodes(tokens, Some(("range", offset)))?; - nodes.push(Node::Range { - index_var: None, - elem_var, - over, - body, - }); - } - ["range", index, ",", elem, ":=", over] => { - let index_var = Some(parse_var_decl(index)?); - let elem_var = Some(parse_var_decl(elem)?); - let over = parse_operand(over)?; - let body = parse_nodes(tokens, Some(("range", offset)))?; - nodes.push(Node::Range { - index_var, - elem_var, - over, - body, - }); - } - [ - "if" | "range" | "end" | "else" | "with" | "define" | "template" | "block" - | "break" | "continue" | "nil", - .., - ] => return Err(unsupported()), - [single] => nodes.push(Node::Print(parse_operand(single)?)), - _ => return Err(unsupported()), - } - } - - match open { - Some((kind, offset)) => Err(Error::MissingEnd { kind, offset }), - None => Ok(nodes), - } -} - -fn is_ident(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Parses `$name` in a range declaration. -fn parse_var_decl(token: &str) -> Result { - match token.strip_prefix('$') { - Some(name) if is_ident(name) => Ok(name.to_string()), - _ => Err(Error::BadOperand { - token: token.to_string(), - }), - } -} - -fn parse_operand(token: &str) -> Result { - let bad = || Error::BadOperand { - token: token.to_string(), - }; - - if token == "." { - return Ok(Operand { - base: Base::Dot, - path: Vec::new(), - }); - } - - if let Some(chain) = token.strip_prefix('.') { - let path: Vec = chain.split('.').map(str::to_string).collect(); - if !path.iter().all(|p| is_ident(p)) { - return Err(bad()); - } - - return Ok(Operand { - base: Base::Dot, - path, - }); - } - - if let Some(var) = token.strip_prefix('$') { - let mut parts = var.split('.'); - let name = parts.next().unwrap_or_default(); - if !is_ident(name) { - return Err(bad()); - } - - let path: Vec = parts.map(str::to_string).collect(); - if !path.iter().all(|p| is_ident(p)) { - return Err(bad()); - } - - return Ok(Operand { - base: Base::Var(name.to_string()), - path, - }); - } - - Err(bad()) -} - -fn eval<'v>( - operand: &Operand, - dot: &'v Value, - vars: &'v [(String, Value)], -) -> Result<&'v Value, Error> { - let mut value = match &operand.base { - Base::Dot => dot, - Base::Var(name) => { - let Some((_, value)) = vars.iter().rev().find(|(k, _)| k == name) else { - return Err(Error::UndefinedVariable { name: name.clone() }); - }; - value - } - }; - - for field in &operand.path { - value = value.field(field)?; - } - - Ok(value) -} - -fn exec( - nodes: &[Node], - dot: &Value, - vars: &mut Vec<(String, Value)>, - out: &mut String, -) -> Result<(), Error> { - for node in nodes { - match node { - Node::Text(text) => out.push_str(text), - Node::Print(operand) => eval(operand, dot, vars)?.print(out)?, - Node::If { cond, body } => { - if eval(cond, dot, vars)?.truthy() { - exec(body, dot, vars, out)?; - } - } - Node::Range { - index_var, - elem_var, - over, - body, - } => { - let items = match eval(over, dot, vars)? { - Value::List(items) => items.clone(), - other => return Err(Error::NotIterable { kind: other.kind() }), - }; - - for (i, item) in items.iter().enumerate() { - let depth = vars.len(); - if let Some(name) = index_var { - let index = i64::try_from(i).map_err(|_| Error::IndexOverflow)?; - vars.push((name.clone(), Value::Int(index))); - } - if let Some(name) = elem_var { - vars.push((name.clone(), item.clone())); - } - - exec(body, item, vars, out)?; - vars.truncate(depth); - } - } - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use test_case::test_case; - - use super::*; - - fn render(src: &str, dot: &Value) -> Result { - Template::parse(src)?.execute(dot) - } - - fn sample() -> Value { - Value::object([ - ("Name", Value::str("compose")), - ("Empty", Value::str("")), - ("Yes", Value::Bool(true)), - ("No", Value::Bool(false)), - ("Zero", Value::Int(0)), - ("Count", Value::Int(3)), - ("None", Value::List(vec![])), - ("Items", Value::str_list(["a", "b"])), - ( - "Nodes", - Value::List(vec![ - Value::object([ - ("Label", Value::str("x")), - ("Ports", Value::str_list(["1", "2"])), - ]), - Value::object([("Label", Value::str("")), ("Ports", Value::List(vec![]))]), - ]), - ), - ]) - } - - #[test_case("plain text", "plain text" ; "text_only")] - #[test_case("a {{.Name}} b", "a compose b" ; "field")] - #[test_case("{{ .Name }}", "compose" ; "inner_padding")] - #[test_case("{{.Count}}/{{.Yes}}/{{.No}}", "3/true/false" ; "int_and_bool")] - #[test_case("[{{if .Name}}y{{end}}]", "[y]" ; "if_string_true")] - #[test_case("[{{if .Empty}}y{{end}}]", "[]" ; "if_string_false")] - #[test_case("[{{if .Yes}}y{{end}}][{{if .No}}n{{end}}]", "[y][]" ; "if_bool")] - #[test_case("[{{if .Zero}}y{{end}}][{{if .Count}}c{{end}}]", "[][c]" ; "if_int")] - #[test_case("[{{if .None}}y{{end}}][{{if .Items}}i{{end}}]", "[][i]" ; "if_list")] - #[test_case("{{range .Items}}<{{.}}>{{end}}", "" ; "range_dot")] - #[test_case("{{range $v := .Items}}<{{$v}}>{{end}}", "" ; "range_elem_var")] - #[test_case("{{range $i, $v := .Items}}{{$i}}={{$v}};{{end}}", "0=a;1=b;" ; "range_index_var")] - #[test_case("{{range .None}}x{{end}}-", "-" ; "range_empty")] - #[test_case( - "{{range $i, $n := .Nodes}}{{$i}}:{{$n.Label}}{{if $n.Ports}}[{{range $n.Ports}}{{.}}{{end}}]{{end}};{{end}}", - "0:x[12];1:;" ; "nested_range_and_var_fields" - )] - #[test_case("a \n {{- .Name}}", "acompose" ; "trim_left")] - #[test_case("{{.Name -}} \n\n b", "composeb" ; "trim_right")] - #[test_case("x\n {{- if .Yes}}\n y\n {{end -}}\n z", "x\n y\n z" ; "trim_both_sides_of_block")] - #[test_case("{{if .Yes -}}\n a\n{{- end}}", "a" ; "trim_inside_block")] - fn renders(src: &str, want: &str) { - assert_eq!(render(src, &sample()), Ok(want.to_string())); - } - - #[test] - fn dash_without_space_keeps_text() { - // `{{-` without a following space is not a trim marker: the dash - // belongs to the action and makes it an unsupported token. - let err = render("a {{-x}}", &sample()).expect_err("must fail"); - assert!(matches!(err, Error::BadOperand { .. }), "{err:?}"); - } - - #[test_case("{{.Name", Error::UnclosedAction { offset: 0 } ; "unclosed")] - #[test_case("a{{end}}", Error::UnexpectedEnd { offset: 1 } ; "stray_end")] - #[test_case("{{if .Yes}}x", Error::MissingEnd { kind: "if", offset: 0 } ; "missing_end_if")] - #[test_case("{{range .Items}}x", Error::MissingEnd { kind: "range", offset: 0 } ; "missing_end_range")] - #[test_case("{{if .Yes}}a{{else}}b{{end}}", Error::Unsupported { offset: 12, action: "else".to_string() } ; "else_action")] - #[test_case("{{with .Name}}{{end}}", Error::Unsupported { offset: 0, action: "with .Name".to_string() } ; "with")] - #[test_case("{{.Name | printf}}", Error::Unsupported { offset: 0, action: ".Name | printf".to_string() } ; "pipe")] - #[test_case("{{printf \"x\"}}", Error::Unsupported { offset: 0, action: "printf \"x\"".to_string() } ; "function")] - #[test_case("{{if eq .Name \"x\"}}{{end}}", Error::Unsupported { offset: 0, action: "if eq .Name \"x\"".to_string() } ; "comparison")] - #[test_case("{{range $i := }}{{end}}", Error::Unsupported { offset: 0, action: "range $i :=".to_string() } ; "bad_range_decl")] - #[test_case("{{\"lit\"}}", Error::BadOperand { token: "\"lit\"".to_string() } ; "literal")] - #[test_case("{{$}}", Error::BadOperand { token: "$".to_string() } ; "root_var")] - #[test_case("{{.Missing}}", Error::NoField { field: "Missing".to_string(), kind: "object" } ; "missing_field")] - #[test_case("{{.Name.Inner}}", Error::NoField { field: "Inner".to_string(), kind: "string" } ; "field_on_string")] - #[test_case("{{$v}}", Error::UndefinedVariable { name: "v".to_string() } ; "undefined_var")] - #[test_case("{{range .Name}}{{end}}", Error::NotIterable { kind: "string" } ; "range_string")] - #[test_case("{{.Items}}", Error::NotPrintable { kind: "list" } ; "print_list")] - #[test_case("{{.}}", Error::NotPrintable { kind: "object" } ; "print_object")] - fn rejects(src: &str, want: Error) { - assert_eq!(render(src, &sample()), Err(want)); - } - - #[test] - fn variables_are_scoped_to_their_range() { - let err = render("{{range $v := .Items}}{{end}}{{$v}}", &sample()).expect_err("must fail"); - assert_eq!( - err, - Error::UndefinedVariable { - name: "v".to_string() - } - ); - } -} diff --git a/crates/test-compose/src/lib.rs b/crates/test-compose/src/lib.rs index 6736470a..dd83acee 100644 --- a/crates/test-compose/src/lib.rs +++ b/crates/test-compose/src/lib.rs @@ -1,22 +1,19 @@ -//! Docker-compose cluster generator for local and CI smoke testing of pluto -//! and charon nodes. +//! Docker-compose cluster generator for smoke testing pluto and charon nodes. //! -//! A cluster is produced in steps. Each step reads `config.json` from the -//! compose directory, advances its `step` field and regenerates -//! `docker-compose.yml` from the bundled template: +//! A cluster is produced in steps; each reads `config.json` from the compose +//! directory, advances its `step` and rewrites `docker-compose.yml`: //! -//! 1. [`new`] cleans the directory and writes a fresh config. -//! 2. [`define`] renders the cluster-definition step (`charon create dkg`, or a -//! no-op container for `create` key generation), copies the static -//! monitoring configs and writes the Prometheus scrape and alert-rule files. -//! 3. [`lock`] renders the cluster-lock step (`charon create cluster` or a full -//! `charon dkg` run). -//! 4. [`run`] renders the compose file that runs the nodes, validator clients, -//! relay and monitoring stack. +//! 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 three steps against a docker daemon, brings the -//! cluster up and watches Prometheus for alerts while it runs; [`smoke`] -//! holds the scenario matrix the smoke tests feed into it. +//! [`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; @@ -25,9 +22,7 @@ mod define; mod duration; mod error; mod fsutil; -mod gotmpl; mod lock; -mod new; mod process; mod run; pub mod smoke; @@ -37,27 +32,7 @@ mod template; #[cfg(test)] mod golden_tests; -pub use alert::{ - ALERT_POLL_INTERVAL, ALERT_WARMUP, ALERTS_POLLED, ActiveAlert, AlertPoller, AlertTiming, - DockerCurlPoller, PromAlert, PromAlertAnnotations, PromAlerts, PromAnnotations, PromData, - PromGroup, PromRule, STARTUP_TRANSIENT_RULES, get_active_alerts, is_startup_transient, - start_collector, -}; -pub use auto::{AutoConfig, TmplFn, auto, run_step}; -pub use config::{ - CHARON_PORTS, Config, KeyGen, NodeImpl, Step, VERSION, VcType, load_config, write_config, -}; -pub use define::{ - ALERT_RULE_NAMES, BROADCAST_RULE, DefineOptions, ERROR_RATE_RULE, KeyGenError, KeyGenFn, - PLUTO_DOWN_RULE, PROXY_RATE_RULE, VAPI_RATE_RULE, WARN_RATE_RULE, build_local, - build_local_pluto, clean, define, -}; -pub use duration::go_duration_string; -pub use error::{CommandError, ComposeError, Result}; -pub use lock::lock; -pub use new::new; -pub use process::{ - LogSink, UpOutcome, build_and_create, down, fix_perms, print_docker_compose, up, -}; -pub use run::run; -pub use template::{Kv, Port, TmplData, TmplNode, TmplVc, write_docker_compose}; +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 index fd1b7a1e..d23726fb 100644 --- a/crates/test-compose/src/lock.rs +++ b/crates/test-compose/src/lock.rs @@ -14,6 +14,15 @@ use crate::{ 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 @@ -71,7 +80,7 @@ pub fn lock(dir: impl AsRef, mut conf: Config) -> Result { KeyGen::Dkg => { let nodes = (0..conf.num_nodes) .map(|i| TmplNode { - env_vars: new_node_envs(i, &conf, None), + env_vars: new_node_envs(i, &conf, NodeMode::Dkg), image: conf.image_override(conf.node_impl(i)), command: CMD_DKG.to_string(), ..TmplNode::default() @@ -106,9 +115,9 @@ pub(crate) fn quoted_bool(value: bool) -> String { } /// Returns the environment variables for a charon node container: the -/// common flags, then either the DKG flags (config step `defined`) or the -/// run flags, plus the loki/tempo flags when monitoring is on. -pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option) -> Vec { +/// 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(); @@ -117,9 +126,8 @@ pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option beacon_node = ""; } - // 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. + // 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 { @@ -141,18 +149,19 @@ pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option Kv::new("feature-set", conf.feature_set.as_str()), ]; - if conf.step == Step::Defined { - // Define lock config - 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)), - ]); + 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; - } + return kvs; + } + NodeMode::Run(vc_type) => vc_type, + }; - // Define run config kvs.extend([ Kv::new( "lock-file", @@ -163,7 +172,7 @@ pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option Kv::new("simnet-beacon_mock", quoted_bool(beacon_mock)), Kv::new( "simnet-validator-mock", - quoted_bool(vc_type == Some(VcType::Mock)), + quoted_bool(vc_type == VcType::Mock), ), Kv::new( "simnet-slot-duration", @@ -181,9 +190,8 @@ pub(crate) fn new_node_envs(index: usize, conf: &Config, vc_type: Option Kv::new("builder-api", quoted_bool(conf.builder_api)), ]); - // 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. + // 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"), @@ -211,42 +219,14 @@ mod tests { .unwrap_or_else(|| panic!("missing {key}")) } - #[test] - fn defined_step_uses_dkg_flags() { - let mut conf = Config::new_default(); - conf.step = Step::Defined; - - let kvs = new_node_envs(2, &conf, None); - assert_eq!( - keys(&kvs), - [ - "private-key-file", - "monitoring-address", - "p2p-external-hostname", - "p2p-tcp-address", - "p2p-relays", - "log-level", - "log-color", - "feature-set", - "data-dir", - "definition-file", - "insecure-keys", - ] - ); - assert_eq!(value(&kvs, "data-dir"), "/compose/node2"); - assert_eq!(value(&kvs, "p2p-relays"), "http://relay:3640"); - assert_eq!(value(&kvs, "insecure-keys"), "\"false\""); - } - #[test] fn run_step_reflects_config_toggles() { let mut conf = Config::new_default(); - conf.step = Step::Locked; 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, Some(VcType::Mock)); + 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\""); @@ -256,10 +236,14 @@ mod tests { assert!(!keys(&kvs).contains(&"loki-addresses")); conf.monitoring = true; - let kvs = new_node_envs(3, &conf, Some(VcType::Teku)); + 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] @@ -272,20 +256,4 @@ mod tests { "compose config not defined, so can't be locked: step=new" ); } - - #[test] - fn lock_create_maps_split_keys_dir_into_container() { - let dir = tempfile::tempdir().expect("tempdir"); - let keys_dir = dir.path().join("split-keys"); - std::fs::create_dir(&keys_dir).expect("mkdir"); - - let mut conf = Config::new_default(); - conf.step = Step::Defined; - conf.split_keys_dir = keys_dir.to_string_lossy().into_owned(); - - let data = lock(dir.path(), conf).expect("lock"); - let kvs = &data.nodes[0].env_vars; - assert_eq!(value(kvs, "split-existing-keys"), "\"true\""); - assert_eq!(value(kvs, "split-keys-dir"), "/compose/split-keys"); - } } diff --git a/crates/test-compose/src/new.rs b/crates/test-compose/src/new.rs deleted file mode 100644 index 81124258..00000000 --- a/crates/test-compose/src/new.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Creation of a fresh compose config. - -use std::path::Path; - -use tracing::info; - -use crate::{ - Result, - config::{Config, Step, write_config}, - define::clean, -}; - -/// Cleans `dir` and writes `conf` as a new (`step: new`) `config.json`. -pub fn new(dir: impl AsRef, mut conf: Config) -> Result<()> { - let dir = dir.as_ref(); - - clean(dir)?; - - conf.step = Step::New; - - info!(dir = %dir.display(), config = ?conf, "Writing config to compose dir"); - - write_config(dir, &conf) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::load_config; - - #[test] - fn new_resets_step_and_writes_config() { - let dir = tempfile::tempdir().expect("tempdir"); - - let mut conf = Config::new_default(); - conf.step = Step::Locked; - - new(dir.path(), conf.clone()).expect("new"); - - conf.step = Step::New; - assert_eq!(load_config(dir.path()).expect("load"), conf); - } -} diff --git a/crates/test-compose/src/process.rs b/crates/test-compose/src/process.rs index 889a46ed..bb142053 100644 --- a/crates/test-compose/src/process.rs +++ b/crates/test-compose/src/process.rs @@ -1,26 +1,21 @@ //! 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, -//! so the command sequence can be observed with stand-in programs (see the -//! transcript tests) as well as against a real docker daemon. +//! 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::{ExitStatus, Stdio}, + process::Stdio, }; use tokio::process::Command; use tokio_util::sync::CancellationToken; use tracing::info; -use crate::{ - define::combined_output, - error::{CommandError, ComposeError, Result}, -}; +use crate::error::{CommandError, ComposeError, Result}; /// Destination of the `docker compose up` output: the stdout of this process /// or an append-only log file. @@ -44,7 +39,7 @@ impl LogSink { .mode(0o644) .open(path) .map(Self::File) - .map_err(ComposeError::OpenLogFile), + .map_err(ComposeError::io("open log file")), } } @@ -84,14 +79,6 @@ pub enum UpOutcome { Cancelled, } -fn exit_ok(status: ExitStatus) -> std::result::Result<(), CommandError> { - if status.success() { - Ok(()) - } else { - Err(CommandError::Exit(status)) - } -} - /// 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"); @@ -100,10 +87,9 @@ pub async fn print_docker_compose(dir: impl AsRef) -> Result<()> { .arg("docker-compose.yml") .current_dir(dir.as_ref()) .status() - .await - .map_err(|err| ComposeError::ExecCatDockerCompose(CommandError::Io(err)))?; + .await; - exit_ok(status).map_err(ComposeError::ExecCatDockerCompose) + CommandError::check(status).map_err(ComposeError::exec("exec cat docker-compose.yml")) } /// Hands the compose artefacts back to the current user. Containers run as @@ -123,16 +109,9 @@ pub async fn fix_perms(dir: impl AsRef) -> Result<()> { .args(args) .current_dir(dir) .status() - .await - .map_err(|err| ComposeError::ExecSudo { - program: program.to_string(), - source: CommandError::Io(err), - })?; - - exit_ok(status).map_err(|source| ComposeError::ExecSudo { - program: program.to_string(), - source, - })?; + .await; + + CommandError::check(status).map_err(ComposeError::exec(format!("exec sudo {program}")))?; } Ok(()) @@ -153,10 +132,9 @@ pub async fn down(dir: impl AsRef, sudo_perms: bool) -> Result<()> { .args(["compose", "down", "--remove-orphans", "--timeout=2"]) .current_dir(dir) .status() - .await - .map_err(|err| ComposeError::RunDown(CommandError::Io(err)))?; + .await; - exit_ok(status).map_err(ComposeError::RunDown) + CommandError::check(status).map_err(ComposeError::exec("run down")) } /// Builds the images in parallel, then runs `docker compose up` with its @@ -180,33 +158,16 @@ pub async fn up( .current_dir(dir) .kill_on_drop(true); - let output = tokio::select! { - output = build.output() => output.map_err(|err| ComposeError::ExecComposeBuild { - source: CommandError::Io(err), - output: String::new(), - })?, - () = token.cancelled() => { - return Err(ComposeError::ExecComposeBuild { - source: CommandError::Io(io::Error::other("signal: killed")), - output: String::new(), - }); - } - }; - if !output.status.success() { - return Err(ComposeError::ExecComposeBuild { - source: CommandError::Exit(output.status), - output: combined_output(&output), - }); - } + 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"); - let stdout = sink - .stdio() - .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; - let stderr = sink - .stdio() - .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; + const UP: &str = "exec docker compose up"; + let mut child = Command::new("docker") .args([ "compose", @@ -216,28 +177,24 @@ pub async fn up( "--quiet-pull", ]) .current_dir(dir) - .stdout(stdout) - .stderr(stderr) + .stdout(sink.stdio().map_err(ComposeError::exec(UP))?) + .stderr(sink.stdio().map_err(ComposeError::exec(UP))?) .kill_on_drop(true) .spawn() - .map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))?; + .map_err(ComposeError::exec(UP))?; - let status = tokio::select! { - status = child.wait() => { - status.map_err(|err| ComposeError::ExecComposeUp(CommandError::Io(err)))? - } - () = token.cancelled() => { - let _ = child.kill().await; - return Ok(UpOutcome::Cancelled); - } + 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::ExecComposeUp(CommandError::Exit(status))) + Err(ComposeError::exec(UP)(CommandError::Exit(status))) } } @@ -250,25 +207,18 @@ pub async fn build_and_create(dir: impl AsRef) -> Result<()> { .args(["compose", "up", "--no-start", "--build"]) .current_dir(dir.as_ref()) .output() - .await - .map_err(|err| ComposeError::ExecComposeCreate { - source: CommandError::Io(err), - output: String::new(), - })?; + .await; - if output.status.success() { - Ok(()) - } else { - Err(ComposeError::ExecComposeCreate { - source: CommandError::Exit(output.status), - output: combined_output(&output), - }) - } + CommandError::check_output(output) + .map(drop) + .map_err(ComposeError::exec( + "exec docker compose up --no-start --build", + )) } #[cfg(test)] mod tests { - use std::{fs, os::unix::fs::PermissionsExt}; + use std::fs; use super::*; @@ -290,45 +240,6 @@ mod tests { ); } - #[test] - fn log_sink_creates_file_with_0644() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("new.log"); - - let sink = LogSink::open(Some(&path)).expect("open sink"); - drop(sink); - - let mode = fs::metadata(&path).expect("metadata").permissions().mode(); - assert_eq!(mode & 0o777, 0o644); - } - - #[test] - fn log_sink_open_missing_parent_fails() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("missing").join("compose.log"); - - let err = LogSink::open(Some(&path)).expect_err("open must fail"); - assert!(matches!(err, ComposeError::OpenLogFile(_)), "{err:?}"); - assert!(err.to_string().starts_with("open log file: "), "{err}"); - } - - #[test] - fn log_sink_stdout_never_fails() { - let mut sink = LogSink::open(None).expect("stdout sink"); - assert!(matches!(sink, LogSink::Stdout)); - sink.banner(""); - } - - #[tokio::test] - async fn print_docker_compose_runs_cat_in_dir() { - let dir = tempfile::tempdir().expect("tempdir"); - fs::write(dir.path().join("docker-compose.yml"), "services: {}\n").expect("write yml"); - - print_docker_compose(dir.path()) - .await - .expect("cat of an existing file succeeds"); - } - #[tokio::test] async fn print_docker_compose_reports_cat_failure() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/test-compose/src/run.rs b/crates/test-compose/src/run.rs index 51c26e8f..7f0cc4d1 100644 --- a/crates/test-compose/src/run.rs +++ b/crates/test-compose/src/run.rs @@ -8,22 +8,10 @@ use crate::{ Result, config::{CHARON_PORTS, CMD_RUN, CMD_UNSAFE_RUN, Config, Step, VcType}, error::ComposeError, - gotmpl::{Template, Value}, - lock::{new_node_envs, quoted_bool}, + lock::{NodeMode, new_node_envs, quoted_bool}, template::{Kv, TmplData, TmplNode, TmplVc, write_docker_compose}, }; -/// Command template for the teku validator client; rendered per node with -/// its index, keystore pairs and the builder API toggle. -const TEKU_COMMAND: &str = r#"| - 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}}"#; - /// 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. @@ -46,23 +34,17 @@ pub fn run(dir: impl AsRef, conf: Config) -> Result { let mut nodes = Vec::with_capacity(conf.num_nodes); let mut vcs = Vec::with_capacity(conf.num_nodes); - for i in 0..conf.num_nodes { - let typ = i - .checked_rem(conf.vcs.len()) - .and_then(|idx| conf.vcs.get(idx)) - .copied() - .ok_or(ComposeError::NoValidatorClients)?; - + 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, Some(typ)), + env_vars: new_node_envs(i, &conf, NodeMode::Run(typ)), image: conf.image_override(conf.node_impl(i)), ..TmplNode::default() }; @@ -126,63 +108,56 @@ fn get_vc( num_vals: usize, insecure: bool, builder_api: bool, -) -> Result { - let mut resp = match typ { +) -> TmplVc { + match typ { VcType::Mock => TmplVc::default(), VcType::Vouch | VcType::Lighthouse | VcType::Lodestar => TmplVc { - label: typ.as_str().to_string(), - build: typ.as_str().to_string(), + label: typ.to_string(), + build: typ.to_string(), ..TmplVc::default() }, VcType::Teku => TmplVc { - label: typ.as_str().to_string(), + label: typ.to_string(), image: "consensys/teku:latest".to_string(), - command: TEKU_COMMAND.to_string(), + command: teku_command(node_idx, num_vals, insecure, builder_api), ..TmplVc::default() }, - }; - - if typ == VcType::Teku { - let keys: Vec = (0..num_vals) - .map(|i| { - if insecure { - format!( - "/compose/node{node_idx}/validator_keys/keystore-insecure-{i}.json:/compose/node{node_idx}/validator_keys/keystore-insecure-{i}.txt" - ) - } else { - format!( - "/compose/node{node_idx}/validator_keys/keystore-{i}.json:/compose/node{node_idx}/validator_keys/keystore-{i}.txt" - ) - } - }) - .collect(); - - let node_idx = - i64::try_from(node_idx).map_err(|_| ComposeError::PortOverflow { index: node_idx })?; - - let data = Value::object([ - ("TekuKeys", Value::str_list(keys)), - ("NodeIdx", Value::Int(node_idx)), - ("BuilderAPI", Value::Bool(builder_api)), - ]); - - resp.command = Template::parse(&resp.command) - .and_then(|tmpl| tmpl.execute(&data)) - .map_err(ComposeError::TekuTemplate)?; } +} - Ok(resp) +/// 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 test_case::test_case; - use super::*; #[test] - fn teku_template_renders() { - let vc = get_vc(VcType::Teku, 0, 1, false, true).expect("teku vc"); + 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!( @@ -197,37 +172,6 @@ mod tests { ); } - #[test] - fn teku_template_insecure_keys_and_no_builder() { - let vc = get_vc(VcType::Teku, 2, 2, true, false).expect("teku vc"); - assert!(vc.command.contains( - "--validator-keys=\"/compose/node2/validator_keys/keystore-insecure-0.json:/compose/node2/validator_keys/keystore-insecure-0.txt\"\n --validator-keys=\"/compose/node2/validator_keys/keystore-insecure-1.json:/compose/node2/validator_keys/keystore-insecure-1.txt\"\n --validators-proposer-default" - ), "{}", vc.command); - assert!( - vc.command - .ends_with("--validators-proposer-blinded-blocks-enabled=false") - ); - } - - #[test_case(VcType::Vouch, "vouch" ; "vouch")] - #[test_case(VcType::Lighthouse, "lighthouse" ; "lighthouse")] - #[test_case(VcType::Lodestar, "lodestar" ; "lodestar")] - fn built_vcs(typ: VcType, name: &str) { - let vc = get_vc(typ, 1, 1, false, false).expect("vc"); - assert_eq!(vc.label, name); - assert_eq!(vc.build, name); - assert!(vc.image.is_empty()); - assert!(vc.command.is_empty()); - } - - #[test] - fn mock_vc_is_empty() { - assert_eq!( - get_vc(VcType::Mock, 0, 1, false, false).expect("vc"), - TmplVc::default() - ); - } - #[test] fn run_rejects_non_locked_step() { let conf = Config::new_default(); @@ -238,36 +182,4 @@ mod tests { "compose config not locked, so can't be run: step=new" ); } - - #[test] - fn run_requires_validator_clients() { - let mut conf = Config::new_default(); - conf.step = Step::Locked; - conf.vcs.clear(); - let dir = tempfile::tempdir().expect("tempdir"); - let err = run(dir.path(), conf).expect_err("must fail"); - assert_eq!(err.to_string(), "no validator clients configured"); - } - - #[test] - fn run_p2p_fuzz_and_disabled_ports() { - let mut conf = Config::new_default(); - conf.step = Step::Locked; - conf.p2p_fuzz = true; - conf.disable_monitoring_ports = true; - - let dir = tempfile::tempdir().expect("tempdir"); - let data = run(dir.path(), conf).expect("run"); - - assert_eq!(data.charon_command, "[unsafe,run]"); - assert!(!data.monitoring_ports); - assert!(data.nodes.iter().all(|n| n.ports.is_empty())); - - let fuzz = data.nodes[0].env_vars.last().expect("env"); - assert_eq!( - (fuzz.key.as_str(), fuzz.value.as_str()), - ("p2p-fuzz", "\"true\"") - ); - assert!(data.nodes[1].env_vars.iter().all(|kv| kv.key != "p2p-fuzz")); - } } diff --git a/crates/test-compose/src/smoke.rs b/crates/test-compose/src/smoke.rs index 92d52278..3d372b26 100644 --- a/crates/test-compose/src/smoke.rs +++ b/crates/test-compose/src/smoke.rs @@ -1,15 +1,15 @@ //! 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 docker-based tests, the docker-free -//! transcript tests and the CI workflow all run the same scenarios. +//! The matrix is library code so the tests and the CI workflow share it. -use std::{env, path::PathBuf, time::Duration}; +use std::{path::PathBuf, time::Duration}; use crate::{ - auto::{AutoConfig, TmplFn}, + auto::AutoConfig, config::{Config, KeyGen, NodeImpl, VcType}, define::{BROADCAST_RULE, ERROR_RATE_RULE, VAPI_RATE_RULE}, + fsutil::env_non_empty, template::TmplData, }; @@ -24,10 +24,6 @@ pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2 * 60); /// instead of the bundled one. pub const EXTERNAL_RELAY_ENV: &str = "SMOKE_EXTERNAL_RELAY"; -/// Environment variable pointing at the pluto checkout the `pluto:local` -/// image is built from. Scenarios that need it are skipped when it is unset. -pub const PLUTO_REPO_ENV: &str = "PLUTO_REPO"; - /// 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 { @@ -38,9 +34,7 @@ pub fn base_config() -> Config { conf.insecure_keys = true; conf.vcs = vec![VcType::Mock]; - if let Ok(relay) = env::var(EXTERNAL_RELAY_ENV) - && !relay.is_empty() - { + if let Some(relay) = env_non_empty(EXTERNAL_RELAY_ENV) { conf.external_relay = relay; } @@ -53,58 +47,46 @@ pub struct Scenario { /// Unique scenario name, also the test name. pub name: &'static str, /// Adjusts the base config. - pub config_fn: Option, + pub config_fn: fn(&mut Config), /// Adjusts the run step template data. pub run_tmpl_fn: Option, - /// Adjusts the define step template data. - pub define_tmpl_fn: Option, /// Print `docker-compose.yml` after each step. pub print_yml: bool, - /// Alert observation window; zero means [`DEFAULT_TIMEOUT`]. + /// Alert observation window. pub timeout: Duration, - /// The scenario builds and runs pluto, so it needs [`PLUTO_REPO_ENV`]. - pub require_pluto: bool, } impl Scenario { const fn new(name: &'static str) -> Self { Self { name, - config_fn: None, + config_fn: |_| {}, run_tmpl_fn: None, - define_tmpl_fn: None, print_yml: false, - timeout: Duration::ZERO, - require_pluto: false, + timeout: DEFAULT_TIMEOUT, } } /// The scenario's cluster config. pub fn config(&self) -> Config { let mut conf = base_config(); - if let Some(config_fn) = self.config_fn { - config_fn(&mut conf); - } + (self.config_fn)(&mut conf); conf } - /// The alert observation window. - pub fn timeout(&self) -> Duration { - if self.timeout.is_zero() { - DEFAULT_TIMEOUT - } else { - self.timeout - } + /// 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.alert_timeout = self.timeout; conf.print_yml = self.print_yml; - conf.run_tmpl_fn = self.run_tmpl_fn.map(|f| Box::new(f) as TmplFn); - conf.define_tmpl_fn = self.define_tmpl_fn.map(|f| Box::new(f) as TmplFn); + conf.run_tmpl_fn = self.run_tmpl_fn; conf } @@ -123,128 +105,123 @@ fn unset_node0_p2p(data: &mut TmplData) { } /// The smoke matrix. -pub fn scenarios() -> Vec { - vec![ - Scenario { - print_yml: true, - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Create; - conf.feature_set = "alpha".to_string(); - }), - ..Scenario::new("default_alpha") +pub const SCENARIOS: &[Scenario] = &[ + Scenario { + print_yml: true, + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "alpha".to_string(); }, - Scenario { - config_fn: Some(|conf| { - conf.num_nodes = 3; - conf.threshold = 2; - conf.key_gen = KeyGen::Create; - conf.feature_set = "beta".to_string(); - }), - ..Scenario::new("default_beta") + ..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 { - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Create; - conf.feature_set = "stable".to_string(); - }), - ..Scenario::new("default_stable") + ..Scenario::new("default_beta") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.feature_set = "stable".to_string(); }, - Scenario { - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Dkg; - }), - ..Scenario::new("dkg") + ..Scenario::new("default_stable") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Dkg; }, - Scenario { - config_fn: Some(|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::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; }, - Scenario { - config_fn: Some(|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") + 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(), + ]; }, - Scenario { - config_fn: Some(|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") + 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()]; }, - Scenario { - config_fn: Some(|conf| { - conf.builder_api = true; - }), - ..Scenario::new("blinded_blocks_vmock") + run_tmpl_fn: Some(unset_node0_p2p), + ..Scenario::new("1_of_3_down") + }, + Scenario { + config_fn: |conf| { + conf.builder_api = true; }, - Scenario { - require_pluto: true, - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Create; - conf.key_gen_impl = Some(NodeImpl::Pluto); - }), - ..Scenario::new("pluto_keygen_create") + ..Scenario::new("blinded_blocks_vmock") + }, + Scenario { + config_fn: |conf| { + conf.key_gen = KeyGen::Create; + conf.key_gen_impl = Some(NodeImpl::Pluto); }, - Scenario { - require_pluto: true, - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Create; - conf.node_impls = vec![NodeImpl::Pluto]; - conf.synthetic_block_proposals = false; - }), - ..Scenario::new("all_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 { - require_pluto: true, - config_fn: Some(|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::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 { - require_pluto: true, - config_fn: Some(|conf| { - conf.key_gen = KeyGen::Dkg; - conf.node_impls = vec![NodeImpl::Pluto]; - conf.synthetic_block_proposals = false; - }), - ..Scenario::new("pluto_dkg") + ..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() - .into_iter() + SCENARIOS + .iter() .find(|scenario| scenario.name == name) + .copied() } #[cfg(test)] @@ -259,10 +236,9 @@ mod tests { #[test] fn scenario_matrix() { - let scenarios = scenarios(); let mut names = HashSet::new(); - for scenario in &scenarios { + for scenario in SCENARIOS { assert!(!scenario.name.is_empty(), "scenario without a name"); assert!( names.insert(scenario.name), @@ -271,50 +247,19 @@ mod tests { ); let conf = scenario.config(); - assert_eq!( - scenario.require_pluto, - conf.uses_pluto(), - "{}: require_pluto must match the config", - scenario.name - ); - 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); - } - - #[test] - fn timeouts() { - assert_eq!( - scenario("default_alpha").expect("scenario").timeout(), - Duration::from_secs(120) - ); + assert_eq!(SCENARIOS.len(), 12); assert_eq!( - scenario("very_large").expect("scenario").timeout(), - Duration::from_secs(180) - ); - assert!(scenario("missing").is_none()); - } - - #[test] - fn auto_config_carries_scenario_knobs() { - let conf = scenario("1_of_4_down") - .expect("scenario") - .auto_config("/tmp/compose"); - - assert_eq!(conf.alert_timeout, DEFAULT_TIMEOUT); - assert!(!conf.print_yml); - assert!(conf.run_tmpl_fn.is_some()); - assert!(conf.define_tmpl_fn.is_none()); - assert!( - scenario("default_alpha") - .expect("scenario") - .auto_config("/tmp/compose") - .print_yml + SCENARIOS + .iter() + .filter(|scenario| scenario.requires_pluto()) + .count(), + 4 ); } @@ -347,11 +292,4 @@ mod tests { ); assert_eq!(keys(1), vec!["p2p-relays"]); } - - #[test] - fn unset_node0_p2p_tolerates_no_nodes() { - let mut data = TmplData::default(); - unset_node0_p2p(&mut data); - assert!(data.nodes.is_empty()); - } } diff --git a/crates/test-compose/src/static_files.rs b/crates/test-compose/src/static_files.rs index 87e17c7d..07bdf9f5 100644 --- a/crates/test-compose/src/static_files.rs +++ b/crates/test-compose/src/static_files.rs @@ -94,12 +94,4 @@ mod tests { assert_eq!(STATIC_FILES.len(), 16); assert!(Path::new(STATIC_DIR).is_dir()); } - - #[test] - fn table_is_sorted() { - let keys: Vec<(&str, &str)> = STATIC_FILES.iter().map(|f| (f.dir, f.name)).collect(); - let mut sorted = keys.clone(); - sorted.sort_unstable(); - assert_eq!(keys, sorted); - } } diff --git a/crates/test-compose/src/template.rs b/crates/test-compose/src/template.rs index 60862ae9..a0379497 100644 --- a/crates/test-compose/src/template.rs +++ b/crates/test-compose/src/template.rs @@ -1,4 +1,4 @@ -//! Template data for `docker-compose.yml` and its renderer. +//! Data model for `docker-compose.yml` and the writer that renders it. use std::path::Path; @@ -6,16 +6,12 @@ use serde::Serialize; use crate::{ Result, - config::nullable_vec, + config::{CHARON_IMAGE, nullable_vec}, error::ComposeError, fsutil::write_file, - gotmpl::{Template, Value}, }; -/// The bundled docker-compose template. -const COMPOSE_TEMPLATE: &str = include_str!("../docker-compose.template"); - -/// Root data of the docker-compose template. +/// Everything `docker-compose.yml` is rendered from. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[serde(rename_all = "PascalCase")] pub struct TmplData { @@ -114,108 +110,186 @@ pub struct Port { pub internal: u32, } -impl From<&Port> for Value { - fn from(port: &Port) -> Self { - Value::object([ - ("External", Value::Int(i64::from(port.external))), - ("Internal", Value::Int(i64::from(port.internal))), - ]) - } +/// 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")) } -impl From<&Kv> for Value { - fn from(kv: &Kv) -> Self { - Value::object([ - ("Key", Value::str(&kv.key)), - ("Value", Value::str(&kv.value)), - ("EnvKey", Value::str(kv.env_key())), - ]) +/// 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:"); -impl From<&TmplNode> for Value { - fn from(node: &TmplNode) -> Self { - Value::object([ - ("Image", Value::str(&node.image)), - ("Entrypoint", Value::str(&node.entrypoint)), - ("Command", Value::str(&node.command)), - ( - "EnvVars", - Value::List(node.env_vars.iter().map(Value::from).collect()), - ), - ( - "Ports", - Value::List(node.ports.iter().map(Value::from).collect()), - ), - ]) + 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(""); } -} -impl From<&TmplVc> for Value { - fn from(vc: &TmplVc) -> Self { - Value::object([ - ("Label", Value::str(&vc.label)), - ("Image", Value::str(&vc.image)), - ("Build", Value::str(&vc.build)), - ("Command", Value::str(&vc.command)), - ( - "Ports", - Value::List(vc.ports.iter().map(Value::from).collect()), - ), - ]) + if data.relay { + y.line(RELAY_SERVICE); } -} -impl From<&TmplData> for Value { - fn from(data: &TmplData) -> Self { - Value::object([ - ("ComposeDir", Value::str(&data.compose_dir)), - ("CharonImageTag", Value::str(&data.charon_image_tag)), - ("CharonEntrypoint", Value::str(&data.charon_entrypoint)), - ("CharonCommand", Value::str(&data.charon_command)), - ( - "Nodes", - Value::List(data.nodes.iter().map(Value::from).collect()), - ), - ( - "VCs", - Value::List(data.vcs.iter().map(Value::from).collect()), - ), - ("Relay", Value::Bool(data.relay)), - ("Monitoring", Value::Bool(data.monitoring)), - ("Alerting", Value::Bool(data.alerting)), - ("MonitoringPorts", Value::Bool(data.monitoring_ports)), - ]) + 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(""); } -} -/// Renders the bundled template with `data` and writes `docker-compose.yml` -/// into `dir`. -pub fn write_docker_compose(dir: impl AsRef, data: &TmplData) -> Result<()> { - let template = Template::parse(COMPOSE_TEMPLATE).map_err(ComposeError::NewTemplate)?; - let rendered = template - .execute(&Value::from(data)) - .map_err(ComposeError::ExecTemplate)?; + 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(""); + } - write_file(dir.as_ref().join("docker-compose.yml"), rendered, 0o755) - .map_err(ComposeError::WriteDockerCompose) + 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 } -#[cfg(test)] -mod tests { - use test_case::test_case; +/// Line-oriented YAML output; every value is inserted verbatim. +#[derive(Default)] +struct Yaml(String); - use super::*; +impl Yaml { + fn line(&mut self, s: impl AsRef) { + self.0.push_str(s.as_ref()); + self.0.push('\n'); + } - #[test_case("p2p-tcp-address", "P2P_TCP_ADDRESS" ; "dashes")] - #[test_case("simnet-beacon_mock", "SIMNET_BEACON_MOCK" ; "mixed_separators")] - #[test_case("name", "NAME" ; "plain")] - fn env_key(key: &str, want: &str) { - assert_eq!(Kv::new(key, "").env_key(), want); + /// `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}")); + } } - #[test] - fn bundled_template_parses() { - Template::parse(COMPOSE_TEMPLATE).expect("template must parse"); + 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/crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden b/crates/test-compose/testdata/TestDockerCompose_define_create_yml.golden index feb6b363..b18abf72 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden b/crates/test-compose/testdata/TestDockerCompose_define_dkg_yml.golden index 75984835..c93ec31a 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_pluto_keygen_yml.golden index 7eb1fa5b..9c08fe28 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_create_yml.golden index f485f9f5..5e4ae573 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_mixed_impls_yml.golden index 408a78a0..a02fce9d 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden b/crates/test-compose/testdata/TestDockerCompose_lock_dkg_yml.golden index c829bf1a..9a372e75 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden b/crates/test-compose/testdata/TestDockerCompose_run_mixed_impls_yml.golden index 5d9e3abf..ffe4069b 100644 --- a/crates/test-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/crates/test-compose/testdata/TestDockerCompose_run_yml.golden b/crates/test-compose/testdata/TestDockerCompose_run_yml.golden index d642c188..210622c6 100644 --- a/crates/test-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/crates/test-compose/testdata/smoke/1_of_3_down.transcript b/crates/test-compose/testdata/smoke/1_of_3_down.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/1_of_3_down.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/1_of_4_down.transcript b/crates/test-compose/testdata/smoke/1_of_4_down.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/1_of_4_down.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/all_pluto.transcript b/crates/test-compose/testdata/smoke/all_pluto.transcript deleted file mode 100644 index 35b159c2..00000000 --- a/crates/test-compose/testdata/smoke/all_pluto.transcript +++ /dev/null @@ -1,22 +0,0 @@ -git rev-parse --short=7 HEAD -docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript b/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/blinded_blocks_vmock.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/default_alpha.transcript b/crates/test-compose/testdata/smoke/default_alpha.transcript deleted file mode 100644 index 81f15c29..00000000 --- a/crates/test-compose/testdata/smoke/default_alpha.transcript +++ /dev/null @@ -1,23 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -cat docker-compose.yml -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -cat docker-compose.yml -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -cat docker-compose.yml -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/default_beta.transcript b/crates/test-compose/testdata/smoke/default_beta.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/default_beta.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/default_stable.transcript b/crates/test-compose/testdata/smoke/default_stable.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/default_stable.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/dkg.transcript b/crates/test-compose/testdata/smoke/dkg.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/dkg.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript b/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript deleted file mode 100644 index 35b159c2..00000000 --- a/crates/test-compose/testdata/smoke/mixed_2_charon_2_pluto.transcript +++ /dev/null @@ -1,22 +0,0 @@ -git rev-parse --short=7 HEAD -docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/pluto_dkg.transcript b/crates/test-compose/testdata/smoke/pluto_dkg.transcript deleted file mode 100644 index 35b159c2..00000000 --- a/crates/test-compose/testdata/smoke/pluto_dkg.transcript +++ /dev/null @@ -1,22 +0,0 @@ -git rev-parse --short=7 HEAD -docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript b/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript deleted file mode 100644 index 35b159c2..00000000 --- a/crates/test-compose/testdata/smoke/pluto_keygen_create.transcript +++ /dev/null @@ -1,22 +0,0 @@ -git rev-parse --short=7 HEAD -docker build -t pluto:local --build-arg GIT_COMMIT_HASH_SHORT=abcdef0 . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/testdata/smoke/shim.sh b/crates/test-compose/testdata/smoke/shim.sh deleted file mode 100644 index 44b96dcb..00000000 --- a/crates/test-compose/testdata/smoke/shim.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/sh -# Stand-in for the external programs the compose harness spawns (docker, sudo, -# git, cat). Installed under all four names in a temporary bin directory that -# is put first on PATH. Every invocation is appended to ../transcript.log as -# " \t" so the command sequence of a harness run can be -# compared without a docker daemon. -# -# Canned behaviour, enough to drive the harness through a full run: -# - `git rev-parse --short=7 HEAD` prints a fixed hash; -# - the prometheus rules query answers a successful, empty rule set; -# - `docker compose up --no-start --build` drops a marker, after which the -# final `docker compose up ...` blocks like a live cluster until killed; -# - everything else exits 0 silently. -root=$(dirname "$0")/.. -program=$(basename "$0") -line=$program -for arg in "$@"; do - line="$line $arg" -done -printf '%s\t%s\n' "$line" "$PWD" >> "$root/transcript.log" - -case "$program $*" in - "git rev-parse --short=7 HEAD") - printf 'abcdef0\n' - ;; - "docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert") - printf '{"status":"success","data":{"groups":[]}}\n' - ;; - "docker compose up --no-start --build") - : > "$root/created" - ;; - "docker compose up --remove-orphans --abort-on-container-exit --quiet-pull") - if [ -e "$root/created" ]; then - exec sleep 3600 - fi - ;; -esac -exit 0 diff --git a/crates/test-compose/testdata/smoke/very_large.transcript b/crates/test-compose/testdata/smoke/very_large.transcript deleted file mode 100644 index 5530a9b7..00000000 --- a/crates/test-compose/testdata/smoke/very_large.transcript +++ /dev/null @@ -1,20 +0,0 @@ -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -docker compose up --no-start --build -docker compose build --parallel -docker compose up --remove-orphans --abort-on-container-exit --quiet-pull -sudo chown -R : . -sudo chmod -R a+wrX . -docker compose down --remove-orphans --timeout=2 -polls=yes diff --git a/crates/test-compose/tests/smoke.rs b/crates/test-compose/tests/smoke.rs index 2c970906..c99f74be 100644 --- a/crates/test-compose/tests/smoke.rs +++ b/crates/test-compose/tests/smoke.rs @@ -18,30 +18,20 @@ //! `/.log` instead of stdout. //! - `SMOKE_EXTERNAL_RELAY=`: route the cluster through an external relay. -use std::{env, path::PathBuf}; +use std::path::PathBuf; -use pluto_test_compose::{auto, smoke, write_config}; +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(()); -fn env_flag(name: &str) -> bool { - env::var_os(name).is_some_and(|value| !value.is_empty() && value != "0") -} - -fn env_path(name: &str) -> Option { - env::var_os(name) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) -} - 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.require_pluto && env_path(smoke::PLUTO_REPO_ENV).is_none() { - eprintln!("skipping {name}: {} not set", smoke::PLUTO_REPO_ENV); + if scenario.requires_pluto() && env_non_empty(PLUTO_REPO_ENV).is_none() { + eprintln!("skipping {name}: {PLUTO_REPO_ENV} not set"); return; } @@ -54,8 +44,9 @@ async fn run_scenario(name: &str) { write_config(dir.path(), &scenario.config()).expect("write config"); let mut conf = scenario.auto_config(dir.path()); - conf.sudo_perms = env_flag("SMOKE_SUDO_PERMS"); - conf.log_file = env_path("SMOKE_LOG_DIR").map(|log_dir| log_dir.join(format!("{name}.log"))); + 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. @@ -95,6 +86,6 @@ smoke_tests! { #[test] fn every_scenario_has_a_test() { - let names: Vec<&str> = smoke::scenarios().iter().map(|s| s.name).collect(); + let names: Vec<&str> = smoke::SCENARIOS.iter().map(|s| s.name).collect(); assert_eq!(names, SCENARIO_NAMES); } diff --git a/crates/test-compose/tests/transcript.rs b/crates/test-compose/tests/transcript.rs deleted file mode 100644 index c8af256e..00000000 --- a/crates/test-compose/tests/transcript.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Command-transcript parity with the Go harness, without docker. -//! -//! Each test re-executes this test binary to run one smoke scenario through -//! [`pluto_test_compose::auto`] with stand-in `docker`, `sudo`, `git` and `cat` -//! programs first on `PATH`. The stand-ins log every invocation; the log is -//! normalised and compared with `testdata/smoke/.transcript`, which -//! was captured from `go test ./smoke -integration -sudo-perms` running the -//! same scenario through the same stand-ins. -//! -//! The re-exec exists because `PATH` is per process and the tests run in -//! parallel threads. Set `PLUTO_COMPOSE_TRANSCRIPT_OUT=` to also dump the -//! normalised transcripts for inspection. - -use std::{ - env, fs, - os::unix::fs::PermissionsExt, - path::{Path, PathBuf}, - process::Command, - time::Duration, -}; - -use pluto_test_compose::{AlertTiming, auto, smoke, write_config}; - -const SHIM: &str = include_str!("../testdata/smoke/shim.sh"); -const SHIM_PROGRAMS: [&str; 4] = ["docker", "sudo", "git", "cat"]; -const GOLDEN_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/smoke"); -const SCENARIO_ENV: &str = "TRANSCRIPT_SCENARIO"; -const DIR_ENV: &str = "TRANSCRIPT_DIR"; -const POLL_PREFIX: &str = - "docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert"; - -fn install_shims(bin: &Path) { - for program in SHIM_PROGRAMS { - let path = bin.join(program); - fs::write(&path, SHIM).expect("write shim"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); - } -} - -/// Replaces the numeric owner of `sudo chown -R : .` with a -/// placeholder, so the transcript does not depend on who runs the test. -fn normalize_owner(cmd: &str) -> String { - let Some(rest) = cmd.strip_prefix("sudo chown -R ") else { - return cmd.to_string(); - }; - let Some((owner, tail)) = rest.split_once(' ') else { - return cmd.to_string(); - }; - let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()); - match owner.split_once(':') { - Some((uid, gid)) if numeric(uid) && numeric(gid) => { - format!("sudo chown -R : {tail}") - } - _ => cmd.to_string(), - } -} - -/// Normalises a raw shim log: working directories become `` or ``, -/// the chown owner becomes a placeholder, and the alert polls (whose count -/// depends on timing) collapse into a trailing `polls=yes|no` line. -fn normalize(raw: &str, repo: &Path) -> String { - let mut repo_paths = vec![repo.to_string_lossy().into_owned()]; - if let Ok(canonical) = fs::canonicalize(repo) { - repo_paths.push(canonical.to_string_lossy().into_owned()); - } - - let mut out = String::new(); - let mut polled = false; - for line in raw.lines() { - let (cmd, cwd) = line.split_once('\t').unwrap_or((line, "")); - if cmd.starts_with(POLL_PREFIX) { - polled = true; - continue; - } - - let cwd = if repo_paths.iter().any(|p| p == cwd) { - "" - } else { - "" - }; - out.push_str(&normalize_owner(cmd)); - out.push('\t'); - out.push_str(cwd); - out.push('\n'); - } - - out.push_str(if polled { "polls=yes\n" } else { "polls=no\n" }); - - out -} - -fn assert_transcript(name: &str) { - let root = tempfile::Builder::new() - .prefix("transcript-") - .tempdir() - .expect("tempdir"); - let bin = root.path().join("bin"); - let repo = root.path().join("repo"); - let compose = root.path().join("compose"); - for dir in [&bin, &repo, &compose] { - fs::create_dir(dir).expect("create dir"); - } - install_shims(&bin); - - let path = env::join_paths( - std::iter::once(bin.clone()) - .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), - ) - .expect("join PATH"); - let output = Command::new(env::current_exe().expect("current exe")) - .args(["transcript_child", "--exact", "--ignored", "--nocapture"]) - .env("PATH", path) - .env(SCENARIO_ENV, name) - .env(DIR_ENV, &compose) - .env(smoke::PLUTO_REPO_ENV, &repo) - .env_remove(smoke::EXTERNAL_RELAY_ENV) - .output() - .expect("run transcript child"); - assert!( - output.status.success(), - "{name}: transcript child failed: {}\n--- stdout ---\n{}\n--- stderr ---\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let raw = fs::read_to_string(root.path().join("transcript.log")).expect("read transcript"); - let actual = normalize(&raw, &repo); - - if let Some(out_dir) = env::var_os("PLUTO_COMPOSE_TRANSCRIPT_OUT") { - let out_dir = PathBuf::from(out_dir); - fs::create_dir_all(&out_dir).expect("create transcript out dir"); - fs::write(out_dir.join(format!("{name}.transcript")), &actual).expect("dump transcript"); - } - - let golden = Path::new(GOLDEN_DIR).join(format!("{name}.transcript")); - let expected = fs::read_to_string(&golden) - .unwrap_or_else(|err| panic!("read golden {}: {err}", golden.display())); - assert_eq!( - actual, expected, - "{name}: command transcript differs from the Go harness" - ); -} - -/// The re-executed half: runs one scenario with a short alert window against -/// the shims. A no-op unless the parent set the scenario, so a plain -/// `cargo test -- --ignored` does not trip over it. -#[tokio::test] -#[ignore = "helper re-executed by the transcript tests"] -async fn transcript_child() { - let Some(name) = env::var_os(SCENARIO_ENV) else { - return; - }; - let name = name.to_string_lossy().into_owned(); - let dir = PathBuf::from(env::var_os(DIR_ENV).expect("TRANSCRIPT_DIR is set")); - let scenario = smoke::scenario(&name).unwrap_or_else(|| panic!("unknown scenario {name}")); - - write_config(&dir, &scenario.config()).expect("write config"); - - let mut conf = scenario.auto_config(&dir); - conf.alert_timeout = Duration::from_secs(3); - conf.sudo_perms = true; - conf.timing = AlertTiming { - warmup: Duration::from_secs(1), - poll_interval: Duration::from_millis(100), - }; - - auto(conf).await.expect("auto run against the shims"); -} - -macro_rules! transcript_tests { - ($($test:ident => $name:literal),* $(,)?) => { - const SCENARIO_NAMES: &[&str] = &[$($name),*]; - - $( - #[test] - fn $test() { - assert_transcript($name); - } - )* - }; -} - -transcript_tests! { - transcript_default_alpha => "default_alpha", - transcript_default_beta => "default_beta", - transcript_default_stable => "default_stable", - transcript_dkg => "dkg", - transcript_very_large => "very_large", - transcript_1_of_4_down => "1_of_4_down", - transcript_1_of_3_down => "1_of_3_down", - transcript_blinded_blocks_vmock => "blinded_blocks_vmock", - transcript_pluto_keygen_create => "pluto_keygen_create", - transcript_all_pluto => "all_pluto", - transcript_mixed_2_charon_2_pluto => "mixed_2_charon_2_pluto", - transcript_pluto_dkg => "pluto_dkg", -} - -#[test] -fn every_scenario_has_a_transcript_test() { - let names: Vec<&str> = smoke::scenarios().iter().map(|s| s.name).collect(); - assert_eq!(names, SCENARIO_NAMES); -} - -#[test] -fn normalize_collapses_polls_and_placeholders() { - let repo = Path::new("/work/repo"); - let raw = "git rev-parse --short=7 HEAD\t/work/repo\n\ - docker compose exec -T curl curl -s http://prometheus:9090/api/v1/rules?type=alert\t/tmp/c\n\ - sudo chown -R 501:20 .\t/tmp/c\n\ - sudo chmod -R a+wrX .\t/tmp/c\n"; - - assert_eq!( - normalize(raw, repo), - "git rev-parse --short=7 HEAD\t\nsudo chown -R : .\t\nsudo chmod -R a+wrX .\t\npolls=yes\n" - ); - assert_eq!(normalize("", repo), "polls=no\n"); - assert_eq!( - normalize_owner("sudo chown -R root:wheel ."), - "sudo chown -R root:wheel ." - ); -} From 2e4687dbf159e959d862eb798b7c51062c667a9e Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 8 Sep 2026 12:45:16 +0700 Subject: [PATCH 3/4] fix: pin commit hash --- .github/workflows/smoke-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 2199a27b..14c3c03d 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v6 - name: Cache cargo registry and target - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Install `oas3-gen` run: cargo install oas3-gen@0.24.0 --locked From 460553557b9a625131096bfd27c3dbf58ca52564 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Wed, 9 Sep 2026 09:48:08 +0700 Subject: [PATCH 4/4] fix: doc --- crates/test-compose/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/test-compose/src/config.rs b/crates/test-compose/src/config.rs index 12a4888b..610f1d50 100644 --- a/crates/test-compose/src/config.rs +++ b/crates/test-compose/src/config.rs @@ -272,7 +272,7 @@ mod nanos { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct Config { - /// Config format version, see [`VERSION`]. + /// Config format version, see `VERSION`. pub version: String, /// Current workflow step. pub step: Step,