Documentation
¶
Overview ¶
Package devnet provides a test harness for running integration tests against a private Cardano DevNet consisting of Dingo and cardano-node instances connected via Docker Compose.
The chain-observation types in this file carry no build tag so the state machine driving the DevNet scenarios is unit-tested on every ordinary `go test ./...` run, without Docker. The pieces that dial real nodes are gated behind the `devnet` tag.
Index ¶
- Constants
- func ArtifactDir() (string, bool)
- func ArtifactName(testName string) string
- func MaxBlockNumber(snaps []ChainSnapshot) uint64
- func MaxServerTipSlot(snaps []ChainSnapshot) uint64
- func MaxTipSlot(snaps []ChainSnapshot) uint64
- func MinTipSlot(snaps []ChainSnapshot) uint64
- func SlotsDuration(slots uint64, slotDuration time.Duration) time.Duration
- func WriteFailureArtifacts(ctx context.Context, src ArtifactSource, plan FailureCapturePlan, ...)
- type AgreementResult
- type ArtifactSource
- type ChainGroup
- type ChainPoint
- type ChainSnapshot
- type ChainTip
- type DevNetConfig
- func (c *DevNetConfig) BlockFetchStabilityWindowSlots() uint64
- func (c *DevNetConfig) EpochDuration() time.Duration
- func (c *DevNetConfig) ExpectedBlockTime() time.Duration
- func (c *DevNetConfig) ExpectedBlocksPerSlot() float64
- func (c *DevNetConfig) NextEpochBoundary(slot uint64) uint64
- func (c *DevNetConfig) NonceStabilityWindowSlots() uint64
- func (c *DevNetConfig) SlotDuration() time.Duration
- func (c *DevNetConfig) Validate() error
- type FailureCapturePlan
- type NodeEndpoint
- type ObservedChain
- func (c *ObservedChain) Await(ctx context.Context, desc string, cond func(ChainSnapshot) bool) error
- func (c *ObservedChain) Connected()
- func (c *ObservedChain) Disconnected(err error)
- func (c *ObservedChain) RollBackward(point ChainPoint, serverTip ChainTip)
- func (c *ObservedChain) RollForward(h ObservedHeader, serverTip ChainTip)
- func (c *ObservedChain) Snapshot() ChainSnapshot
- type ObservedHeader
- type ScenarioPlan
- type TimelinePhase
Constants ¶
const ( PhaseReadiness = "readiness" PhasePropagation = "propagation" PhaseAgreement = "agreement" PhaseEpochTransition = "epoch-transition" PhasePeerInterruption = "peer-interruption" PhaseRelayRestart = "relay-restart" )
Phase names on the shared scenario timeline.
const CapturedLogTailLines = 2000
CapturedLogTailLines bounds the per-service logs a failed scenario copies out. A DevNet node logs at debug level and emits tens of megabytes a minute, so an unbounded copy per failed scenario is a multi-gigabyte artifact directory on a long canonical run. run-tests.sh separately preserves the complete compose log for the whole run under network/, so this copy only has to carry the window around the failure.
const ReferenceRunnerBudget = 5 * time.Minute
ReferenceRunnerBudget is the wall-clock ceiling for one accelerated scenario, excluding image build, on the reference runner documented in internal/test/devnet/README.md. It is a hard timeout, not a target: the scenario is event-driven and normally finishes well inside it.
Variables ¶
This section is empty.
Functions ¶
func ArtifactDir ¶ added in v0.70.1
ArtifactDir returns the directory failure evidence is written to, and whether one is configured. run-tests.sh creates it and preserves it on failure.
func ArtifactName ¶ added in v0.70.1
ArtifactName encodes a Go test name as one path segment. t.Name() renders a subtest as parent/child, which would otherwise scatter a scenario's evidence across nested directories.
The separator is escaped rather than replaced, because replacing it is lossy: with the separator rewritten to an ordinary character, TestX/a-b and TestX/a/b both become TestX-a-b and two failing subtests write into one directory. Escaping keeps the mapping one-to-one, so distinct test names always get distinct directories -- a property of the encoding rather than a probability, which is what a digest of the name would give instead.
String-level injectivity only delivers that guarantee if the filesystem stores the name it is given. Windows does not: it rejects : * ? " < > | outright, and it strips a trailing dot or space, which would quietly land "." and ".." -- encoded here as %2E and %2E. -- in one directory. Those are escaped for the same reason "." and ".." are: not because they escape the segment, but because the filesystem reads them as something other than the name asked for.
Case is the one difference deliberately left unescaped, so the guarantee above is a guarantee on any filesystem that distinguishes case, and not on one that folds it: on macOS or Windows, TestX/a and TestX/A encode differently but land in one directory. Closing that would cost the readable name, which is the thing the encoding exists to protect -- escaping case renders TestSustainedConsensus as %54est%53ustained%43onsensus, and a digest suffix trades the exact name for a probability. DevNet captures on Linux, where case is significant, and two Go test names differing only in case is a naming problem of its own. TestArtifactNameKeepsCase pins this boundary.
Only what a filesystem would reject or rewrite is escaped, so a name built from Go identifiers comes back exactly as written. That is every scenario in the canonical suite, and it is what makes the directory findable from the name in the failure output.
func MaxBlockNumber ¶
func MaxBlockNumber(snaps []ChainSnapshot) uint64
MaxBlockNumber returns the highest observed block height across snapshots. The scenario uses it to require forward progress between disruption phases, so that each outage starts from a network that is demonstrably still producing rather than merely converged.
func MaxServerTipSlot ¶
func MaxServerTipSlot(snaps []ChainSnapshot) uint64
MaxServerTipSlot returns the highest slot any node reported as its own tip.
This is not the same as MaxTipSlot: an observer intersects at origin and replays history before it reaches the tip, so its observed tip lags during catch-up. Assertions about what the chain is doing *now* — a baseline for "forged after this point", or the next epoch boundary to cross — must use the peer's reported tip, or replayed history could satisfy them without the network having done anything.
func MaxTipSlot ¶
func MaxTipSlot(snaps []ChainSnapshot) uint64
MaxTipSlot returns the highest tip slot across snapshots.
func MinTipSlot ¶
func MinTipSlot(snaps []ChainSnapshot) uint64
MinTipSlot returns the lowest tip slot across snapshots, i.e. how far the slowest node has got.
func SlotsDuration ¶
SlotsDuration returns the wall-clock time n slots take. DevNet slot counts come from a checked-in spec and are small, but the conversion is clamped rather than trusted so a nonsensical spec cannot wrap a duration into the past.
func WriteFailureArtifacts ¶ added in v0.70.1
func WriteFailureArtifacts( ctx context.Context, src ArtifactSource, plan FailureCapturePlan, snapshots []ChainSnapshot, logf func(format string, args ...any), )
WriteFailureArtifacts writes the evidence a failed scenario needs to be diagnosable after the network is gone: what every node's chain actually did, which containers were up, and each service's logs.
It is best-effort by design — a capture error must not mask the test failure that triggered it, and one unreadable service must not cost the rest of the evidence — so problems are logged rather than returned. A nil src means the Docker side is unavailable; the observed chains are recorded in-process and are still written.
Types ¶
type AgreementResult ¶
AgreementResult reports whether a set of nodes agree on the chain at the deepest slot they have all observed.
func AgreementAtDeepestCommonSlot ¶
func AgreementAtDeepestCommonSlot( snaps []ChainSnapshot, ) (AgreementResult, bool)
AgreementAtDeepestCommonSlot picks the highest slot every snapshot observed a header for and compares the hashes there. Using a slot all nodes actually reached makes the check a deterministic expected point rather than a tolerance window: nodes at different tips still get compared on the chain they share, and a fork shows up as a hash mismatch instead of being masked by a slot-distance allowance.
The second return is false when the snapshots share no observed slot, which means the comparison could not be made at all.
func (AgreementResult) String ¶
func (r AgreementResult) String() string
String renders the per-node hashes for failure messages.
type ArtifactSource ¶ added in v0.70.1
type ArtifactSource interface {
ContainerStatus(ctx context.Context) (string, error)
// Logs returns a service's container logs, limited to the last
// tailLines lines when tailLines is positive.
Logs(
ctx context.Context,
service string,
tailLines int,
) (string, error)
}
ArtifactSource supplies the Docker-side evidence a capture writes out. NodeControl implements it.
type ChainGroup ¶
type ChainGroup struct {
// contains filtered or unexported fields
}
ChainGroup observes several nodes at once and lets a scenario wait on conditions that span them. Every member shares one change broadcaster, so a group wait costs a single channel receive regardless of how many nodes the topology has.
func NewChainGroup ¶
func NewChainGroup(nodes ...string) *ChainGroup
NewChainGroup returns a group observing the named nodes.
func (*ChainGroup) Await ¶
func (g *ChainGroup) Await( ctx context.Context, desc string, cond func([]ChainSnapshot) bool, ) error
Await blocks until cond holds across every node's observed chain or ctx expires. On timeout the error reports each node's state.
func (*ChainGroup) Chain ¶
func (g *ChainGroup) Chain(node string) *ObservedChain
Chain returns the observer for a node, or nil if it is not in the group.
func (*ChainGroup) Chains ¶
func (g *ChainGroup) Chains() []*ObservedChain
Chains returns every observer in the order the group was built.
func (*ChainGroup) Snapshots ¶
func (g *ChainGroup) Snapshots() []ChainSnapshot
Snapshots returns one snapshot per node, in group order.
type ChainPoint ¶
ChainPoint is a slot/hash pair identifying a position on a chain. The zero value is the origin.
func (ChainPoint) IsOrigin ¶
func (p ChainPoint) IsOrigin() bool
IsOrigin reports whether the point refers to the chain origin, which ChainSync encodes as an empty point rather than a slot/hash pair.
type ChainSnapshot ¶
type ChainSnapshot struct {
Node string `json:"node"`
Tip ChainTip `json:"tip"`
ServerTip ChainTip `json:"serverTip"`
Headers []ObservedHeader `json:"headers"`
RollForwards int `json:"rollForwards"`
RollBackwards int `json:"rollBackwards"`
MaxRollbackDepth uint64 `json:"maxRollbackDepth"`
Connects int `json:"connects"`
Disconnects int `json:"disconnects"`
Connected bool `json:"connected"`
LastError string `json:"lastError,omitempty"`
}
ChainSnapshot is a consistent point-in-time copy of one node's observed chain, safe to read without holding the observer's lock. The json tags matter: a snapshot is what NodeControl.CaptureFailureArtifacts writes to observed-chains.json, and that file is often the only surviving record of what a node's chain did once the DevNet has been torn down.
func (ChainSnapshot) HashAt ¶
func (s ChainSnapshot) HashAt(slot uint64) ([]byte, bool)
HashAt returns the observed header hash at slot, if the slot is still inside the retained window.
func (ChainSnapshot) String ¶
func (s ChainSnapshot) String() string
String renders the snapshot as a single diagnostic line. Await failures embed it so a timeout says what the node was actually doing.
type ChainTip ¶
type ChainTip struct {
SlotNumber uint64 `json:"slot"`
BlockNumber uint64 `json:"block"`
Hash []byte `json:"hash"`
}
ChainTip holds the chain tip information retrieved from a node.
type DevNetConfig ¶
type DevNetConfig struct {
PoolCount int
NetworkMagic uint32
EpochLength uint64
SlotLength float64
ActiveSlotsCoeff float64
SecurityParam uint64
}
DevNetConfig holds the parsed configuration values from testnet.yaml.
func LoadDevNetConfig ¶
func LoadDevNetConfig() (*DevNetConfig, error)
LoadDevNetConfig reads the active network spec and returns the parsed DevNetConfig. The path is taken from the DEVNET_TESTNET_YAML environment variable; if unset, it defaults to defaultTestnetYAMLPath (relative to the test package).
func LoadDevNetConfigFrom ¶
func LoadDevNetConfigFrom(path string) (*DevNetConfig, error)
LoadDevNetConfigFrom parses the network spec at an explicit path. It is the form used by tests that check the checked-in specs directly, without depending on which mode is currently exported to the environment.
func (*DevNetConfig) BlockFetchStabilityWindowSlots ¶
func (c *DevNetConfig) BlockFetchStabilityWindowSlots() uint64
BlockFetchStabilityWindowSlots returns 3k/f, the shorter window used for blockfetch stability.
func (*DevNetConfig) EpochDuration ¶
func (c *DevNetConfig) EpochDuration() time.Duration
EpochDuration returns the wall-clock length of one epoch.
func (*DevNetConfig) ExpectedBlockTime ¶
func (c *DevNetConfig) ExpectedBlockTime() time.Duration
ExpectedBlockTime returns the average wall-clock time between blocks. This is slotDuration / activeSlotsCoeff (e.g. 2.5s with 1s slots and f=0.4). Panics if ActiveSlotsCoeff is zero or negative (invalid configuration).
func (*DevNetConfig) ExpectedBlocksPerSlot ¶
func (c *DevNetConfig) ExpectedBlocksPerSlot() float64
ExpectedBlocksPerSlot returns the approximate probability that any given slot produces a block (i.e. activeSlotsCoeff, ignoring per-pool stake fraction).
func (*DevNetConfig) NextEpochBoundary ¶
func (c *DevNetConfig) NextEpochBoundary(slot uint64) uint64
NextEpochBoundary returns the first epoch-start slot strictly after slot. Deriving the target from the observed tip rather than assuming slot == epochLength lets a scenario cross a real transition even when it attaches to a network that has been running for a while.
func (*DevNetConfig) NonceStabilityWindowSlots ¶
func (c *DevNetConfig) NonceStabilityWindowSlots() uint64
NonceStabilityWindowSlots returns 4k/f, the window cardano-node uses for the candidate-nonce freeze. The candidate nonce for the next epoch stops evolving this many slots before the epoch ends, so an epoch shorter than the window can never freeze it.
func (*DevNetConfig) SlotDuration ¶
func (c *DevNetConfig) SlotDuration() time.Duration
SlotDuration returns the wall-clock duration of a single slot.
func (*DevNetConfig) Validate ¶
func (c *DevNetConfig) Validate() error
Validate checks that the spec describes an internally consistent network. Every violation is reported, not just the first, so shrinking a network's timing tells you everything that has to move with it.
type FailureCapturePlan ¶ added in v0.70.1
FailureCapturePlan is what a scenario preserves when it fails: the directory to write under, the name that separates one scenario's evidence from another's, and the compose services whose logs to keep.
func PlanFailureCapture ¶ added in v0.70.1
func PlanFailureCapture( root string, testName string, endpoints []NodeEndpoint, ) (FailureCapturePlan, bool)
PlanFailureCapture decides whether a scenario can preserve evidence and what it would preserve. Capture needs somewhere to write and at least one container to read, so an unset DEVNET_ARTIFACT_DIR or a topology of endpoints that name no container disables it rather than producing an empty directory or asking Docker for services that do not exist.
type NodeEndpoint ¶ added in v0.70.1
type NodeEndpoint struct {
Name string
Address string // host:port
Role string // "producer" or "relay"
IsDingo bool // node runs Dingo
IsReference bool // node runs the cardano-node reference impl
// Container is the compose service name, used by the scenario to
// interrupt and restart the node, and by failure capture to name the
// service whose logs to preserve. A disruption step against an
// endpoint with no container fails rather than being skipped: a run
// that quietly omitted its interruption phases would not be the
// release evidence it claims to be.
Container string
}
NodeEndpoint describes a node that the test harness can connect to using the Ouroboros Node-to-Node mini-protocol over TCP.
This is plain data and carries no build tag, so the failure-capture planning in artifacts.go stays testable without a running DevNet. The code that dials an endpoint lives in the devnet-tagged files.
type ObservedChain ¶
type ObservedChain struct {
// contains filtered or unexported fields
}
ObservedChain accumulates the ChainSync messages one node sends and exposes them as timeout-bound conditions. It replaces polling a node's tip over repeated short-lived connections: the chain view is pushed by the node, so a condition becomes true as soon as the protocol event that satisfies it arrives.
All methods are safe for concurrent use; the observer goroutine writes while scenario assertions read.
func NewObservedChain ¶
func NewObservedChain(node string) *ObservedChain
NewObservedChain returns an observer for a single named node.
func (*ObservedChain) Await ¶
func (c *ObservedChain) Await( ctx context.Context, desc string, cond func(ChainSnapshot) bool, ) error
Await blocks until cond holds for this node's observed chain or ctx expires. cond is evaluated once before waiting, then again after every observed protocol event, so no polling interval is involved.
func (*ObservedChain) Connected ¶
func (c *ObservedChain) Connected()
Connected records that the observer established (or re-established) a ChainSync session with the node.
func (*ObservedChain) Disconnected ¶
func (c *ObservedChain) Disconnected(err error)
Disconnected records that the ChainSync session dropped. The observed chain is deliberately preserved so a reconnect resumes from it.
func (*ObservedChain) RollBackward ¶
func (c *ObservedChain) RollBackward(point ChainPoint, serverTip ChainTip)
RollBackward records a rollback to point, dropping every observed header above it. A rollback to the origin clears the observed chain.
The recorded depth counts dropped headers still inside the retained window, so a rollback deeper than that window under-reports rather than over-reports.
func (*ObservedChain) RollForward ¶
func (c *ObservedChain) RollForward(h ObservedHeader, serverTip ChainTip)
RollForward records a header the node sent, extending the observed chain. serverTip is the node's own reported tip from the same message.
func (*ObservedChain) Snapshot ¶
func (c *ObservedChain) Snapshot() ChainSnapshot
Snapshot returns a deep-enough copy for assertions to read safely.
type ObservedHeader ¶
type ObservedHeader struct {
Slot uint64 `json:"slot"`
BlockNumber uint64 `json:"block"`
Hash []byte `json:"hash"`
// BodySize is the header's declared block body size. It is what
// lets a scenario tell a block carrying transactions from an empty
// one without a node-to-client connection, which the mixed
// cardano-node topology does not expose.
BodySize uint64 `json:"bodySize"`
}
ObservedHeader is one block header seen in a ChainSync RollForward.
func AgreedHeaderAbove ¶
func AgreedHeaderAbove( snaps []ChainSnapshot, minSlot uint64, ) (ObservedHeader, bool)
AgreedHeaderAbove returns the lowest header above minSlot that every snapshot observed with an identical hash, and whether one exists.
This is the propagation check: a header only becomes "agreed" once it has reached every node in the topology, including the non-forging relay, so a block that never diffused is never mistaken for one that did. Taking the lowest such header rather than the deepest makes the answer the first block produced after the caller's baseline, which is what a propagation assertion is actually about.
type ScenarioPlan ¶
type ScenarioPlan struct {
Config *DevNetConfig
Phases []TimelinePhase
// HardTimeout bounds the whole scenario.
HardTimeout time.Duration
// InterruptionHold is how long a node stays down during the
// interruption and restart phases.
InterruptionHold time.Duration
// RecoveryBudget is the allowance for a restarted node to rejoin and
// catch back up to the network's chain.
RecoveryBudget time.Duration
// OutageBlocks is how far the rest of the network must advance while
// a node is stopped. Holding the outage to observed chain progress
// rather than wall clock keeps the disruption meaningful on a fast
// or slow runner alike, and keeps it inside what k can reconcile.
OutageBlocks uint64
// EpochMargin is how far past an epoch boundary the scenario runs
// before asserting agreement, so the assertion covers headers built
// on the new epoch nonce rather than the boundary block alone.
EpochMargin uint64
}
ScenarioPlan is the deterministic schedule an accelerated DevNet run follows. Every budget is derived from the network's own timing parameters, so changing the spec moves the schedule with it instead of silently invalidating hardcoded waits.
func NewScenarioPlan ¶
func NewScenarioPlan(cfg *DevNetConfig) (*ScenarioPlan, error)
NewScenarioPlan derives the schedule for a network spec. It rejects a spec that is not internally consistent, since a plan built on one would describe a network that cannot run.
func (*ScenarioPlan) FitsReferenceBudget ¶
func (p *ScenarioPlan) FitsReferenceBudget() bool
FitsReferenceBudget reports whether the plan fits the documented reference-runner budget.
func (*ScenarioPlan) Phase ¶
func (p *ScenarioPlan) Phase(name string) (TimelinePhase, bool)
Phase returns the named phase.
func (*ScenarioPlan) String ¶
func (p *ScenarioPlan) String() string
String renders the schedule for the scenario log, so a run records the timeline it was held to.
func (*ScenarioPlan) Total ¶
func (p *ScenarioPlan) Total() time.Duration
Total returns the scenario length: the last phase's deadline on the shared clock.
type TimelinePhase ¶
TimelinePhase is one stage of the shared scenario timeline.
Deadline is measured from the scenario's start, not from the end of the previous phase. That is what makes this one timeline rather than a series of independent waits: a phase that completes early hands its slack to everything after it, and no assertion adds its own relative slot window on top of the phases before it.