Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/go-harness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Go simnet harness

on:
push:
branches: ["main"]
paths:
- "**/*.rs"
- "**/Cargo.toml"
- "Cargo.lock"
- "test-infra/harness/**"
- ".github/workflows/go-harness.yml"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
paths:
- "**/*.rs"
- "**/Cargo.toml"
- "Cargo.lock"
- "test-infra/harness/**"
- ".github/workflows/go-harness.yml"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
# Fast signal: all-charon simnet scenarios (in-process and via the beacon
# gateway). No Rust build required.
charon-simnet:
name: Charon simnet scenarios
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: test-infra/harness/go.mod
cache-dependency-path: test-infra/harness/go.sum

- name: Run charon simnet tests
working-directory: test-infra/harness
run: go test -v -timeout=12m -run 'TestSimnetAttesterCharon' ./...

# Full suite with the pluto binary available. The pluto/mixed scenarios
# self-skip until `pluto run` lands, then activate automatically.
pluto-simnet:
name: Pluto simnet scenarios
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: test-infra/harness/go.mod
cache-dependency-path: test-infra/harness/go.sum

- name: Cache cargo registry and target
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

- name: Update apt package list
run: sudo apt-get update

- name: Install protobuf
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # 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

- name: Build pluto
run: cargo build -p pluto-cli

- name: Run simnet tests
working-directory: test-infra/harness
env:
PLUTO_BIN: ${{ github.workspace }}/target/debug/pluto
run: go test -v -timeout=20m ./...
107 changes: 107 additions & 0 deletions test-infra/harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Go simnet harness

A Go test harness that runs simnet-style end-to-end duty tests over
distributed-validator clusters mixing **charon** nodes (in-process, reusing
charon's own `app.Run` and test utilities) and **pluto** nodes (subprocesses
of the pluto binary). It reuses charon `v1.7.1` (the pluto parity reference)
as a library: `cluster.NewForT` fixtures, `testutil/beaconmock`,
`testutil/validatormock` and `testutil/relay`.

## Architecture

```text
┌────────────────────────── Go test process ──────────────────────────┐
│ │
deterministic │ relay (in-process libp2p) │
cluster fixture ─▶ │ │
(lock, p2p keys, │ beaconmock (shared chain state, deterministic duties) │
BLS shares) │ ▲ ▲ ▲ │
│ │ Go │ HTTP │ HTTP │
│ charon app.Run gateway[0..k] ◀── capture ── gateway[k..n] │
│ (in-process, ▲ ▲ │
│ real p2p) │ beacon API │ beacon API │
└───────────────────┼─────────────────────────────┼────────────────────┘
│ │
charon app.Run pluto run (subprocess)
(gateway mode) ▲
│ validator API
validatormock (harness-driven)
```

Key design points, mirroring charon's `testutil/integration` simnet:

- **One shared beaconmock, one HTTP gateway per node.** Charon's beaconmock
implements dynamic behaviour (duties, validators) on its Go client
interface only; its HTTP server serves static stubs. The gateway fronts
the shared mock over real HTTP so external processes get a complete
beacon API, and captures submissions per node for assertions.
- **Real p2p partial-signature exchange.** Upstream simnet uses an
in-memory ParSigEx transport; the harness omits it so out-of-process
nodes can participate in QBFT consensus and threshold signing.
- **Assertions at the beacon boundary.** Every node must submit an
attestation for the same slot, and all payloads must be identical after
JSON normalization (they are group-signed threshold aggregates). For the
in-process baseline, upstream-style `BroadcastCallback` assertions are
used instead.

## Scenarios

| Test | Cluster | Purpose |
|---|---|---|
| `TestSimnetAttesterCharonInProcess` | 3× charon (in-process bmock) | Baseline; validates fixture/relay/assertion plumbing |
| `TestSimnetAttesterCharonViaGateway` | 3× charon via gateway | Proves the gateway serves a complete beacon API over HTTP |
| `TestSimnetAttesterPluto` | 3× pluto | Pure-pluto duty e2e (skips until `pluto run` lands) |
| `TestSimnetAttesterMixed` | 2× charon + 2× pluto, threshold 3 | Cross-implementation interop; threshold forces both implementations to participate in every duty |

## Running

```bash
cd test-infra/harness

# Charon-only scenarios (no pluto binary needed), ~10s total:
go test -v -run 'TestSimnetAttesterCharon' ./...

# Full suite; pluto scenarios skip unless PLUTO_BIN supports `pluto run`:
PLUTO_BIN=../../target/debug/pluto go test -v ./...
```

The pluto scenarios probe `$PLUTO_BIN run --help` and skip cleanly until the
`run` command exists, so they are safe to keep enabled in CI.

### Validating the subprocess path today

The subprocess runner passes `charon run`-shaped flags, so a charon binary
can stand in for pluto to exercise the entire subprocess path (on-disk
fixture layout, flags, readiness polling, HTTP-driven validator mock,
capture assertions):

```bash
PLUTO_BIN=/path/to/charon go test -v -run TestSimnetAttesterMixed ./...
```

## Requirements on `pluto run`

For the pluto scenarios to activate, `pluto run` needs to accept the
charon-equivalent flags the harness passes (see `pluto.go`):
`--lock-file`, `--private-key-file`, `--beacon-node-endpoints`,
`--validator-api-address`, `--monitoring-address`, `--p2p-tcp-address`,
`--p2p-relays` — and to serve the validator API on the configured address
(readiness is detected by TCP connect).

## Versioning

Charon is pinned to the pluto parity reference (`v1.7.1`) in `go.mod`. The
two `replace` directives 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.

## Extending

- More duty types: add scenarios passing different beaconmock options
(upstream simnet's proposer/sync-committee configurations translate
directly; see `simnetBMockOpts` and charon's `TestSimnetDuties`).
- Fault injection: start N nodes but kill/withhold one below threshold,
asserting duties still complete (compose-smoke style).
- The gateway logs any non-GET request it reverse-proxies; if a future
client needs an endpoint dynamically, the log line names it.
126 changes: 126 additions & 0 deletions test-infra/harness/beacon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package harness

import (
"context"
"sync"
"testing"
"time"

eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/stretchr/testify/require"

"github.com/obolnetwork/charon/eth2util"
"github.com/obolnetwork/charon/testutil/beaconmock"
)

// Submission is a request an external node POSTed to its beacon gateway.
type Submission struct {
NodeIdx int
Path string
ConsensusVersion string
Body []byte
At time.Time
}

// BeaconNet is a single shared beaconmock fronted by one HTTP gateway per
// node. All gateways serve the same mock state, so every node observes the
// same chain and duties, while submissions remain attributable per node.
type BeaconNet struct {
Mock beaconmock.Mock
GatewayAddrs []string // http://127.0.0.1:port per node

mu sync.Mutex
subs []Submission
}

// StartBeaconNet starts the shared beaconmock and one gateway per node.
// The mock options mirror charon's app simnet wiring (app.newETH2Client)
// plus the upstream simnet test overrides for an attester-only flow.
func StartBeaconNet(t *testing.T, ctx context.Context, fixture *Fixture, opts ...beaconmock.Option) *BeaconNet {
t.Helper()

genesisTime, err := eth2util.ForkVersionToGenesisTime(simnetForkVersion)
require.NoError(t, err)

mockOpts := append(simnetBMockOpts(),
beaconmock.WithSlotDuration(time.Second),
beaconmock.WithGenesisTime(genesisTime),
beaconmock.WithDeterministicAttesterDuties(dutyFactor),
beaconmock.WithValidatorSet(mockValidators(fixture.DVPubkeys(t))),
)
mockOpts = append(mockOpts, opts...)

mock, err := beaconmock.New(ctx, mockOpts...)
require.NoError(t, err)

t.Cleanup(func() {
_ = mock.Close()
})

net := &BeaconNet{Mock: mock}

for i := range fixture.N {
addr := startGateway(t, net, i)
net.GatewayAddrs = append(net.GatewayAddrs, addr)
}

return net
}

// dutyFactor spreads duties deterministically in an epoch, mirroring
// charon's app simnet wiring.
const dutyFactor = 100

// simnetBMockOpts returns the beaconmock options charon's upstream simnet
// test layers on top of the app defaults: single-slot epochs and an
// attester-only duty flow.
func simnetBMockOpts() []beaconmock.Option {
return []beaconmock.Option{
beaconmock.WithSlotsPerEpoch(1),
beaconmock.WithNoProposerDuties(),
beaconmock.WithNoSyncCommitteeDuties(),
}
}

// record stores a submission for later assertion.
func (n *BeaconNet) record(sub Submission) {
n.mu.Lock()
defer n.mu.Unlock()

n.subs = append(n.subs, sub)
}

// Submissions returns a snapshot of all captured submissions.
func (n *BeaconNet) Submissions() []Submission {
n.mu.Lock()
defer n.mu.Unlock()

return append([]Submission(nil), n.subs...)
}

// mockValidators mirrors charon's unexported app.createMockValidators: it
// registers the cluster's distributed validators as active validators in
// the beaconmock.
func mockValidators(pubkeys []eth2p0.BLSPubKey) beaconmock.ValidatorSet {
resp := make(beaconmock.ValidatorSet)

for i, pubkey := range pubkeys {
vIdx := eth2p0.ValidatorIndex(i)

resp[vIdx] = &eth2v1.Validator{
Balance: eth2p0.Gwei(31300000000),
Index: vIdx,
Status: eth2v1.ValidatorStateActiveOngoing,
Validator: &eth2p0.Validator{
WithdrawalCredentials: []byte("12345678901234567890123456789012"),
EffectiveBalance: eth2p0.Gwei(31300000000),
PublicKey: pubkey,
ExitEpoch: 18446744073709551615,
WithdrawableEpoch: 18446744073709551615,
},
}
}

return resp
}
Loading
Loading