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
29 changes: 29 additions & 0 deletions cardano_node_tests/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,35 @@ def pytest_keyboard_interrupt() -> None:
(session_basetemp / INTERRUPTED_NAME).touch()


def pytest_runtest_logreport(report: tp.Any) -> None:
"""Emit an Antithesis SDK assertion for every test failure."""
if report.when != "call" or not report.failed:
return
sdk_file = pl.Path(os.environ.get("ANTITHESIS_OUTPUT_DIR", "/tmp/antithesis")) / "sdk.jsonl"
sdk_file.parent.mkdir(parents=True, exist_ok=True)
longrepr = report.longrepr
reprcrash = getattr(longrepr, "reprcrash", None)
exc_message = reprcrash.message if reprcrash else (str(longrepr)[:2000] if longrepr else "")
assertion = {
"antithesis_assert": {
"type": "always",
"condition": False,
"display_name": report.nodeid,
"message": exc_message,
"details": {"traceback": str(longrepr)[-2000:] if longrepr else ""},
"location": {
"function": report.nodeid,
"file": str(report.fspath),
"begin_line": 0,
"begin_column": 0,
"class": "",
},
}
}
with sdk_file.open("a") as f:
f.write(json.dumps(assertion) + "\n")


@pytest.fixture(scope="session")
def init_pytest_temp_dirs(tmp_path_factory: TempPathFactory) -> None:
"""Init `PytestTempDirs`."""
Expand Down
29 changes: 22 additions & 7 deletions cardano_node_tests/tests/test_tx_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from cardano_node_tests.tests import common
from cardano_node_tests.tests import issues
from cardano_node_tests.tests import tx_common
from cardano_node_tests.utils import antithesis
from cardano_node_tests.utils import cluster_nodes
from cardano_node_tests.utils import clusterlib_utils
from cardano_node_tests.utils import dbsync_utils
Expand Down Expand Up @@ -181,13 +182,27 @@ def test_transfer_funds(
)

out_utxos = cluster.g_query.get_utxo(tx_raw_output=tx_output)
assert (
clusterlib.filter_utxos(utxos=out_utxos, address=src_addr.address)[0].amount
== clusterlib.calculate_utxos_balance(tx_output.txins) - tx_output.fee - amount
), f"Incorrect balance for source address `{src_addr.address}`"
assert (
clusterlib.filter_utxos(utxos=out_utxos, address=dst_addr.address)[0].amount == amount
), f"Incorrect balance for destination address `{dst_addr.address}`"

src_actual = clusterlib.filter_utxos(utxos=out_utxos, address=src_addr.address)[0].amount
src_expected = clusterlib.calculate_utxos_balance(tx_output.txins) - tx_output.fee - amount
antithesis.always(
src_actual == src_expected,
"Source balance decreased by transfer amount and fee",
{"src_addr": src_addr.address, "expected": src_expected, "actual": src_actual},
)
assert src_actual == src_expected, (
f"Incorrect balance for source address `{src_addr.address}`"
)

dst_actual = clusterlib.filter_utxos(utxos=out_utxos, address=dst_addr.address)[0].amount
antithesis.always(
dst_actual == amount,
"Destination received exact transfer amount",
{"dst_addr": dst_addr.address, "expected": amount, "actual": dst_actual},
)
assert dst_actual == amount, (
f"Incorrect balance for destination address `{dst_addr.address}`"
)

common.check_missing_utxos(cluster_obj=cluster, utxos=out_utxos)

Expand Down
42 changes: 42 additions & 0 deletions cardano_node_tests/utils/antithesis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Antithesis SDK wrappers.

All functions are no-ops when the ``antithesis`` package is not installed,
so tests that use them run normally outside the Antithesis environment.
Install the package only inside the Antithesis Docker image — do not add it
to pyproject.toml.
"""

import typing as tp

try:
import antithesis.assertions as _ant

def always(condition: bool, message: str, details: tp.Mapping[str, tp.Any]) -> None:
"""Assert *condition* is true on every invocation."""
_ant.always(condition, message, details)

def sometimes(condition: bool, message: str, details: tp.Mapping[str, tp.Any]) -> None:
"""Assert *condition* is true at least once across all calls."""
_ant.sometimes(condition, message, details)

def reachable(message: str, details: tp.Mapping[str, tp.Any]) -> None:
"""Assert this code location is reached at least once."""
_ant.reachable(message, details)

def unreachable(message: str, details: tp.Mapping[str, tp.Any]) -> None:
"""Assert this code location is never reached."""
_ant.unreachable(message, details)

except ImportError:

def always(condition: bool, message: str, details: tp.Mapping[str, tp.Any]) -> None: # type: ignore[misc]
pass

def sometimes(condition: bool, message: str, details: tp.Mapping[str, tp.Any]) -> None: # type: ignore[misc]
pass

def reachable(message: str, details: tp.Mapping[str, tp.Any]) -> None: # type: ignore[misc]
pass

def unreachable(message: str, details: tp.Mapping[str, tp.Any]) -> None: # type: ignore[misc]
pass
77 changes: 77 additions & 0 deletions docker-antithesis/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Dockerfile for cardano-node-tests (Antithesis-compatible driver image)
#
# All heavy dependencies are baked in at image build time so the container
# runs without any network access (required by Antithesis environments).
#
# Build args:
# GIT_REVISION — git commit hash stored as $GIT_REVISION in the image
# NODE_REV — cardano-node git ref to pre-build (default: master)
#
# Build and push to GHCR before submitting to Antithesis:
# docker build -f docker-antithesis/Dockerfile \
# --build-arg GIT_REVISION=$(git rev-parse HEAD) \
# --build-arg NODE_REV=master \
# -t ghcr.io/saratomaz/cardano-node-tests-antithesis:latest .
# docker push ghcr.io/saratomaz/cardano-node-tests-antithesis:latest

FROM nixos/nix:2.25.5

ARG GIT_REVISION
ARG NODE_REV=master

ENV GIT_REVISION=${GIT_REVISION}
# Store the baked-in node revision for reference at runtime.
ENV BAKED_NODE_REV=${NODE_REV}

# Configure Nix with IOG binary cache and required experimental features.
RUN mkdir -p /etc/nix && \
printf 'extra-substituters = https://cache.iog.io\n\
extra-trusted-public-keys = hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=\n\
experimental-features = nix-command flakes\n\
accept-flake-config = true\n' >> /etc/nix/nix.conf

WORKDIR /work
COPY . /work/

# Pre-build cardano-node, cardano-submit-api, cardano-cli, and bech32 into /opt/cardano/.
# NODE_REV is fixed at image build time — no network access is needed at runtime.
RUN mkdir -p /opt/cardano && \
nix build \
--accept-flake-config --no-write-lock-file \
"github://github.com/IntersectMBO/cardano-node?ref=${NODE_REV}#cardano-node" \
-o /opt/cardano/cardano-node && \
nix build \
--accept-flake-config --no-write-lock-file \
"github://github.com/IntersectMBO/cardano-node?ref=${NODE_REV}#cardano-submit-api" \
-o /opt/cardano/cardano-submit-api && \
nix build \
--accept-flake-config --no-write-lock-file \
"github://github.com/IntersectMBO/cardano-node?ref=${NODE_REV}#cardano-cli" \
-o /opt/cardano/cardano-cli && \
nix build \
--accept-flake-config --no-write-lock-file \
"github://github.com/IntersectMBO/cardano-node?ref=${NODE_REV}#bech32" \
-o /opt/cardano/bech32

# Pre-warm the testenv dev shell (pulls nixpkgs, postgres, uv, python313 into the
# nix store) and create the Python venv at /opt/tests-venv with all project
# dependencies installed. This is the same step regression.sh does at runtime
# but done here so no pip/uv network calls are needed in the Antithesis env.
RUN nix develop --accept-flake-config .#testenv --command \
bash -c 'python3 -m venv /opt/tests-venv --prompt tests-venv && \
. /opt/tests-venv/bin/activate && \
cd /work && \
uv sync --active --no-dev && \
pip install "antithesis>=0.2.0,<0.3.0"'

# Pre-warm the base dev shell (bash, coreutils, git, jq, …) so its store
# paths are cached and the regression.sh shebang resolves offline.
RUN nix develop --accept-flake-config .#base --command true

# Create the Antithesis test driver directory and install the entry-points.
# singleton_driver_* files are run once per test run by Antithesis.
RUN mkdir -p /opt/antithesis/test/v1/quickstart && \
cp /work/docker-antithesis/antithesis_run.sh \
/opt/antithesis/test/v1/quickstart/singleton_driver_regression.sh && \
chmod +x /opt/antithesis/test/v1/quickstart/singleton_driver_regression.sh && \
chmod +x /work/docker-antithesis/node_run.sh
13 changes: 13 additions & 0 deletions docker-antithesis/Dockerfile.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Config image for Antithesis.
#
# Contains only the docker-compose.yaml that tells Antithesis how to run
# the services. Must be pushed to the Antithesis registry alongside the
# driver image.
#
# Build:
# docker build -f docker/Dockerfile.config \
# -t us-central1-docker.pkg.dev/<tenant>/antithesis/config:latest .
# docker push us-central1-docker.pkg.dev/<tenant>/antithesis/config:latest

FROM scratch
COPY docker/docker-compose.yaml /docker-compose.yaml
66 changes: 66 additions & 0 deletions docker-antithesis/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Ignore unnecessary files during Docker build

# Git
.git/
.gitignore

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
*.egg

# Virtual environments
.venv/
venv/
ENV/
env/

# Testing artifacts
run_workdir/
.artifacts/
.cli_coverage/
.reports/
allure-results/
allure-results.tar.xz
testrun-report.*
*.log
*.json.log

# IDE
.idea/
.vscode/
*.swp
*.swo
*~

# Nix
result
result-*

# Documentation
docs/_build/
*.md

# Temporary files
*.tmp
*.bak
.DS_Store

# Scripts output
scripts/destination/
scripts/destination_working/

# Coverage
.coverage
htmlcov/
cli_coverage.json
requirements_coverage.json

# CI specific
.bin/
101 changes: 101 additions & 0 deletions docker-antithesis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Docker setup for cardano-node-tests (Antithesis)

This directory contains the driver image and compose files for submitting
`cardano-node-tests` to Antithesis.

## How it works

Antithesis environments have **no internet access** at runtime, so all
dependencies are baked into the image at build time:

- `Dockerfile` — builds the driver image. At build time it:
1. Pre-builds `cardano-node`, `cardano-submit-api`, `cardano-cli`, and
`bech32` from `NODE_REV` into `/opt/cardano/` via `nix build`.
2. Pre-warms the `testenv` dev shell and creates the Python venv at
`/opt/tests-venv/` with all project dependencies installed.
3. Pre-warms the `base` dev shell so the `regression.sh` shebang resolves
from the local nix store without network access.
4. Installs `antithesis_run.sh` as the Antithesis test driver at
`/opt/antithesis/test/v1/quickstart/singleton_driver_regression.sh`.

- `antithesis_run.sh` — container entrypoint that:
1. Forces nix into offline mode (`offline = true`).
2. Exports `CARDANO_PREBUILT_DIR=/opt/cardano` and `_VENV_DIR=/opt/tests-venv`
so `regression.sh` skips all downloads and uses the pre-built artefacts.
3. In multi-container mode (when `NODE_HOST` is set):
- Polls `NODE_HOST:NODE_PORT` until the node cluster reports ready.
- Starts a local `cardano-submit-api` in the driver container so that
submit-api tests can reach it via `localhost`.
- Starts a TCP proxy forwarding `localhost:<pool1_port>` →
`NODE_HOST:<pool1_port>` so `cardano-cli ping` tests work.
- Starts a local HTTP file server for anchor URLs used by governance
tests (`cardano-cli transaction build` fetches anchor hashes via HTTP).
4. Emits the Antithesis `setup_complete` lifecycle signal.
5. Hands off to `.github/regression.sh`.

- `Dockerfile.config` — builds the Antithesis config image (`FROM scratch`)
containing only `docker-compose.yaml`.

- `docker-compose.yaml` — two services: `node` (cardano-node cluster) and
`driver` (pytest). Both share a `cluster-state` Docker volume so the
driver accesses the node sockets without going over the network. An HTTP
health check on port 8090 provides cross-container traffic that satisfies
the Antithesis "Containers joined the Antithesis network" property.

## Workflow

### 1. Build and push the driver image

```bash
docker build -f docker-antithesis/Dockerfile \
--build-arg GIT_REVISION=$(git rev-parse HEAD) \
--build-arg NODE_REV=master \
-t ghcr.io/saratomaz/cardano-node-tests-antithesis:latest .

docker push ghcr.io/saratomaz/cardano-node-tests-antithesis:latest
```

`NODE_REV` is locked at build time — the same binaries are used every run
regardless of what is on the `master` branch when the container starts.

### 2. Build and push the config image

```bash
docker build -f docker-antithesis/Dockerfile.config \
-t us-central1-docker.pkg.dev/<tenant>/antithesis/config:latest .

docker push us-central1-docker.pkg.dev/<tenant>/antithesis/config:latest
```

### 3. Validate locally (internet-connected build, isolated network at runtime)

```bash
docker compose -f docker-antithesis/docker-compose.yaml config
docker compose -f docker-antithesis/docker-compose.yaml up --build \
--abort-on-container-exit --exit-code-from driver
```

To fully simulate the Antithesis no-internet constraint, run inside an
isolated network namespace on Linux:

```bash
unshare -n docker compose -f docker-antithesis/docker-compose.yaml up
```

## Environment variables

`NODE_REV` is baked into the image at build time and must **not** be set at
runtime. All other variables are passed through docker-compose as before.

| Variable | Default | Description |
|--------------------|----------------|------------------------------------------------|
| `CARDANO_CLI_REV` | (built-in) | cardano-cli revision, empty = use node's |
| `DBSYNC_REV` | (disabled) | db-sync revision, empty = disabled |
| `RUN_TARGET` | `tests` | `tests`, `testpr`, or `testnets` |
| `MARKEXPR` | `smoke` | pytest marker expression |
| `SESSION_TIMEOUT` | `1h` | wall-clock limit passed to `timeout(1)` |
| `TESTNET_VARIANT` | `conway_fast` | cluster variant for `prepare_cluster_scripts` |
| `CLUSTERS_COUNT` | `1` | number of local cluster instances |
| `CLUSTER_ERA` | | e.g. `conway` |
| `PROTOCOL_VERSION` | | e.g. `11` |
| `UTXO_BACKEND` | | e.g. `disk`, `mem` |
Loading
Loading