Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
simulation: Introduce cargo test runner
✗ 0/1 checks passed ade wants to merge 11 commits into master · opened 1 month ago

Enable writing rust based tests that run over a network topology CUE file.

Some checks failed — 0 passed, 1 failed View logs ↗
23 files changed +1477 -82 caee776c d2f776ca
modified Cargo.lock
@@ -973,6 +973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
 "log",
+
 "regex",
]

[[package]]
@@ -984,6 +985,7 @@ dependencies = [
 "anstream 0.6.21",
 "anstyle",
 "env_filter",
+
 "jiff",
 "log",
]

@@ -1144,6 +1146,100 @@ dependencies = [
]

[[package]]
+
name = "futures"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+
dependencies = [
+
 "futures-channel",
+
 "futures-core",
+
 "futures-executor",
+
 "futures-io",
+
 "futures-sink",
+
 "futures-task",
+
 "futures-util",
+
]
+

+
[[package]]
+
name = "futures-channel"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+
dependencies = [
+
 "futures-core",
+
 "futures-sink",
+
]
+

+
[[package]]
+
name = "futures-core"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+

+
[[package]]
+
name = "futures-executor"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+
dependencies = [
+
 "futures-core",
+
 "futures-task",
+
 "futures-util",
+
]
+

+
[[package]]
+
name = "futures-io"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+

+
[[package]]
+
name = "futures-macro"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.117",
+
]
+

+
[[package]]
+
name = "futures-sink"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+

+
[[package]]
+
name = "futures-task"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+

+
[[package]]
+
name = "futures-timer"
+
version = "3.0.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+

+
[[package]]
+
name = "futures-util"
+
version = "0.3.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+
dependencies = [
+
 "futures-channel",
+
 "futures-core",
+
 "futures-io",
+
 "futures-macro",
+
 "futures-sink",
+
 "futures-task",
+
 "memchr",
+
 "pin-project-lite",
+
 "slab",
+
]
+

+
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1868,6 +1964,12 @@ dependencies = [
]

[[package]]
+
name = "glob"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+

+
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2082,6 +2184,12 @@ dependencies = [
]

[[package]]
+
name = "indenter"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+

+
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3328,6 +3436,18 @@ dependencies = [
]

[[package]]
+
name = "radicle-simulation"
+
version = "0.1.0"
+
dependencies = [
+
 "env_logger",
+
 "log",
+
 "rstest",
+
 "ruast",
+
 "serde_json",
+
 "uuid",
+
]
+

+
[[package]]
name = "radicle-std-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3540,6 +3660,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"

[[package]]
+
name = "relative-path"
+
version = "1.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+

+
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3571,6 +3697,44 @@ dependencies = [
]

[[package]]
+
name = "rstest"
+
version = "0.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d5316d2a1479eeef1ea21e7f9ddc67c191d497abc8fc3ba2467857abbb68330"
+
dependencies = [
+
 "futures",
+
 "futures-timer",
+
 "rstest_macros",
+
 "rustc_version",
+
]
+

+
[[package]]
+
name = "rstest_macros"
+
version = "0.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "04a9df72cc1f67020b0d63ad9bfe4a323e459ea7eb68e03bd9824db49f9a4c25"
+
dependencies = [
+
 "cfg-if",
+
 "glob",
+
 "proc-macro2",
+
 "quote",
+
 "regex",
+
 "relative-path",
+
 "rustc_version",
+
 "syn 2.0.117",
+
 "unicode-ident",
+
]
+

+
[[package]]
+
name = "ruast"
+
version = "0.0.23"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d1eba4ae58b527c97246d526b90973ef18b61e98b6529fe6a33593e69009788"
+
dependencies = [
+
 "indenter",
+
]
+

+
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3955,6 +4119,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"

[[package]]
+
name = "slab"
+
version = "0.4.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+

+
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -1,5 +1,6 @@
[workspace]
-
members = ["crates/*"]
+
members = ["crates/*", "simulation/radicle-simulation"]
+
default-members = ["crates/*"]
resolver = "2"

[workspace.package]
modified justfile
@@ -149,4 +149,4 @@ check-hooks:
[group('pre-push')]
test-rust:
    @echo "{{CHECK}}Cargo test...{{NORMAL}}"
-
    @{{cargo_cmd}} nextest run --workspace --all-features --no-fail-fast
+
    @{{cargo_cmd}} nextest run --workspace --all-features --no-fail-fast --exclude radicle-simulation
added scripts/just/create-cluster.sh
@@ -0,0 +1,19 @@
+
#! /usr/bin/env bash
+
set -e
+

+
CLUSTERS_DIR=$1
+
CLUSTER_NAME=$2
+
PROVISIONER=$3
+

+
if [ ! -d "$CLUSTERS_DIR/$CLUSTER_NAME" ]
+
then
+
    echo "${CHECK}Creating Talos cluster '$CLUSTER_NAME' using $PROVISIONER...${NORMAL}"
+
    mkdir -p "$CLUSTERS_DIR"
+
    if [ "$PROVISIONER" = "qemu" ]; then
+
        sudo --preserve-env=HOME,PATH talosctl cluster create --name="$CLUSTER_NAME" "$PROVISIONER" --config-patch-controlplanes '{"cluster": {"allowSchedulingOnControlPlanes": true}}'
+
    else
+
        talosctl cluster create --name="$CLUSTER_NAME" "$PROVISIONER" --config-patch-controlplanes '{"cluster": {"allowSchedulingOnControlPlanes": true}}'
+
    fi
+
else
+
    echo "${SUCCESS}Cluster '$CLUSTER_NAME' already exists.${NORMAL}"
+
fi
added scripts/just/destroy-cluster.sh
@@ -0,0 +1,34 @@
+
#! /usr/bin/env bash
+
set -e
+

+
CLUSTER_NAME=$1
+
PROVISIONER=$2
+

+
echo ""
+
echo -n "Are you sure you want to destroy the cluster and remove kubeconfig entries? [y/N] "
+
read -r answer
+
if [ "${answer:-N}" != "y" ]
+
then
+
    echo "Aborted."
+
    exit 1
+
fi
+

+
echo "${CHECK}Destroying talos cluster '$CLUSTER_NAME'...${NORMAL}"
+
if [ "$PROVISIONER" = "qemu" ]
+
then
+
    sudo --preserve-env=HOME,PATH talosctl cluster destroy --name "$CLUSTER_NAME" --provisioner "$PROVISIONER"
+
else
+
    talosctl cluster destroy --name "$CLUSTER_NAME" --provisioner "$PROVISIONER"
+
fi
+

+
echo "${CHECK}Removing kube config entries...${NORMAL}"
+
CONTEXT=$(kubectl config current-context 2>/dev/null || echo "")
+
if [ -n "$CONTEXT" ]
+
then
+
    CLUSTER=$(echo "$CONTEXT" | cut -d '@' -f 2)
+
    kubectl config delete-context "$CONTEXT" || true
+
    kubectl config delete-cluster "$CLUSTER" || true
+
    kubectl config unset "users.$CONTEXT" || true
+
fi
+
echo "${WARN}Make sure you remove the '$CLUSTER_NAME' entry from: ~/.talos/config${NORMAL}"
+
echo "${SUCCESS}Cluster destroyed.${NORMAL}"
added scripts/just/ensure-ovmf.sh
@@ -0,0 +1,11 @@
+
#! /usr/bin/env bash
+
set -e
+
if [ -z "${OVMF_FD_PATH:-}" ]; then
+
    # Not on NixOS / not using the devshell — assume OVMF is installed normally
+
    exit 0
+
fi
+
if [ ! -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
+
    echo "{{CHECK}}Symlinking OVMF firmware from Nix store into /usr/share/OVMF...{{NORMAL}}"
+
    sudo mkdir -p /usr/share/OVMF
+
    sudo ln -sf "$OVMF_FD_PATH"/* /usr/share/OVMF/
+
fi
added scripts/just/update-image-tags.sh
@@ -0,0 +1,39 @@
+
#! /usr/bin/env bash
+
set -e
+

+
VALUES_FILE="$1"
+

+
if [ -z "$VALUES_FILE" ]; then
+
  echo "${ERROR}Usage: $0 <path-to-values.cue>${NORMAL}" >&2
+
  exit 1
+
fi
+

+
REPO="radicle_garden/radicle-node"
+
API_URL="https://quay.io/api/v1/repository/$REPO/tag/?limit=100"
+

+
if [ ! -f "$VALUES_FILE" ]; then
+
  echo "${ERROR}Error: $VALUES_FILE not found.${NORMAL}" >&2
+
  exit 1
+
fi
+

+
echo "${CHECK}Fetching tags from quay.io for $REPO...${NORMAL}"
+

+
# Fetch tags, extract names, ignore empty lines, sort versions, and remove duplicates
+
TAGS=$(curl -sL "$API_URL" | jq -r '.tags[].name' | grep -v '^$' | sort -V | uniq)
+

+
if [ -z "$TAGS" ]; then
+
  echo "${ERROR}Error: No tags found or failed to fetch tags.${NORMAL}" >&2
+
  exit 1
+
fi
+

+
# Format tags into a CUE enum: "tag1" | "tag2" | string | *"latest"
+
# We append `| string | *"latest"` to allow custom local builds
+
ENUM=$(echo "$TAGS" | awk '{printf "\"%s\" | ", $0} END {print "string | *\"latest\""}')
+

+
echo "${CHECK}Injecting tags into $VALUES_FILE...${NORMAL}"
+

+
# Use a temporary file for cross-platform sed compatibility (works on both macOS and Linux)
+
sed -e "s@version:.*@version: $ENUM@" "$VALUES_FILE" > "${VALUES_FILE}.tmp"
+
mv "${VALUES_FILE}.tmp" "$VALUES_FILE"
+

+
echo "${SUCCESS}Successfully updated $VALUES_FILE${NORMAL}"
added scripts/just/vendor-timoni-dependencies.sh
@@ -0,0 +1,18 @@
+
#! /usr/bin/env bash
+
set -e
+

+
RADICLE_NODE_MODULE=$1
+
MODULE_PKG=$2
+
MODULE_GEN=$3
+

+
cd "$RADICLE_NODE_MODULE"
+
if [ ! -d "$MODULE_PKG" ]
+
then
+
    echo "${CHECK}Fetching Timoni pkg files...${NORMAL}"
+
    timoni artifact pull oci://ghcr.io/stefanprodan/timoni/schemas -o cue.mod/pkg
+
fi
+
if [ ! -d "$MODULE_GEN" ]
+
then
+
    echo "${CHECK}Fetching Timoni k8s gen files...${NORMAL}"
+
    timoni mod vendor k8s
+
fi
modified simulation/README.md
@@ -33,6 +33,9 @@ networking.firewall.trustedInterfaces = ["talos+"];
The environment is managed entirely via `just`. From the `simulation` directory, you can run:

```shell
+
# Run all tests
+
$ just test
+

# Start the complete simulation (creates cluster, configures K8s, and deploys the network)
$ just start

@@ -101,6 +104,57 @@ values: {

When you run `just start-network`, Timoni reads this file, merges it with the module definitions in `modules/radicle-node`, and deploys the resulting pods to Kubernetes.

+
## Writing Tests
+

+
The `radicle-simulation` crate provides an ergonomic Rust framework for writing tests against these CUE topologies. Here is how to introduce a new test suite:
+

+
### Create the Topology
+
Create a new `.cue` file in the `instances/` directory (e.g., `instances/my_network.cue`). See [## Defining a Topology]
+

+
When you run `cargo test` (or `cargo check`), the `build.rs` script automatically detects this file, parses the topology, and generates a Rust bindings file (`my_network_bindings.rs`) in Cargo's `OUT_DIR`. These bindings contain strongly-typed accessor functions for the nodes defined in your CUE file (e.g., `peer_v1_8_0()`, `peer_relative(offset, index)`).
+

+
### Create the Test Suite Entrypoint
+
In `radicle-simulation/tests/`, create a main entrypoint for your suite (e.g., `my_suite_main.rs`). Use the `setup_network!` macro to link your CUE file to the test suite:
+

+
```rust
+
use radicle_simulation::setup_network;
+

+
// This macro does two things:
+
// 1. Includes the generated `my_network_bindings.rs` into a `network` module.
+
// 2. Generates a `require_network()` function that provisions the cluster.
+
setup_network!("my_network");
+

+
// Declare your actual test modules here
+
mod my_suite;
+
```
+

+
### Write the Tests
+
In your test modules (e.g., `radicle-simulation/tests/my_suite/mod.rs`), you can now write your tests.
+

+
**NOTE:** Every test *must* call `require_network()` and hold onto the returned guard. This ensures the network is provisioned before the test runs and prevents it from being torn down while the test is executing.
+

+
```rust
+
use crate::network;
+
use crate::require_network;
+

+
#[test]
+
fn test_nodes_can_sync() -> Result<(), String> {
+
    // Ensure the network is running and hold the guard
+
    let _guard = require_network();
+

+
    // Grab node handles using the auto-generated bindings
+
    // `peer_relative(0, 0)` gets the 0th replica of the latest versioned peer.
+
    let alice_node = network::peer_relative(0, 0).as_alice()?;
+
    let bob_node = network::peer_relative(-1, 0).as_bob()?;
+

+
    // Interact with the nodes
+
    let repo = alice_node.init_test_repo("my-repo", "A test repo", 1)?;
+
    bob_node.clone_repo(&repo.rid, "bob-repo")?;
+

+
    Ok(())
+
}
+
```
+

## Helpful Commands

**Execute a command inside a node:**
@@ -189,6 +243,6 @@ See the [Goals] section for more info.
  1. [X] `radicle-node` timoni module.
  2. [ ] `radicle-node` custom container builder.
  3. [X] `instances` topology definition files.
-
  4. [ ] `sim-tests` rust crate.
+
  4. [X] `radicle-simulation` rust crate.
  5. [X] `justfile` orchestration.
  6. [ ] `observability` definition files.
added simulation/instances/basic_cross_version_network.cue
@@ -0,0 +1,90 @@
+
@if(!debug)
+

+
package main
+

+
values: {
+
	topology: {
+
		"bootstrap-v1-6-1": {
+
			role:          "bootstrap"
+
			version:       "1.6.1"
+
			replicas:      1
+
			nodeIdSeed:    "bootstrap-0"
+
			radicleConfig: #BaseBootstrapSeedConfig
+
		}
+

+
		"bootstrap-v1-7-0": {
+
			role:          "bootstrap"
+
			version:       "1.7.0"
+
			replicas:      1
+
			nodeIdSeed:    "bootstrap-1"
+
			radicleConfig: #BaseBootstrapSeedConfig
+
		}
+

+
		"peer-v1-5-0": {
+
			role:          "peer"
+
			version:       "1.5.0"
+
			replicas:      1
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
		"peer-v1-6-0": {
+
			role:          "peer"
+
			version:       "1.6.0"
+
			replicas:      1
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
		"peer-v1-6-1": {
+
			role:          "peer"
+
			version:       "1.6.1"
+
			replicas:      1
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
		"peer-v1-7-0": {
+
			role:          "peer"
+
			version:       "1.7.0"
+
			replicas:      2
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
		"peer-v1-7-1": {
+
			role:          "peer"
+
			version:       "1.7.1"
+
			replicas:      2
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
		"peer-v1-8-0": {
+
			role:          "peer"
+
			version:       "1.8.0"
+
			replicas:      1
+
			radicleConfig: #BasePeerConfig & {
+
				preferredSeeds: [
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-0"], name: "bootstrap-v1-6-1"}).out,
+
					(#SeedAddress & {nid: #BootstrapNIDs["bootstrap-1"], name: "bootstrap-v1-7-0"}).out,
+
				]
+
			}
+
		}
+
	}
+
}
added simulation/instances/schema.cue
@@ -0,0 +1,85 @@
+
@if(!debug)
+

+
package main
+

+
//
+
// Pre-calculated NIDs.
+
//
+
#BootstrapNIDs: {
+
	"bootstrap-0": "z6MkhJ3cwzpAoNjFnJXWETSPHcDyw2HuBVEhgkyTfbjQHY1B"
+
	"bootstrap-1": "z6MkjcaeSHhQVJU1UeXpnHHZ6mp67zDfQYNMDotHGxbrk7Nj"
+
	"bootstrap-2": "z6MkjNGhuJvdp2noidRMLqco4jFnNNSWzCxSZH5nJV1pGrwQ"
+
	"bootstrap-3": "z6MkpEsXUMSnmyfwdEVkAKijTxGy9WKmNoHWpoxxLM6bbz9M"
+
}
+

+
//
+
// Shared configs
+
//
+
#SeedAddress: {
+
	nid:   string
+
	name:  string
+
	role:  string | *"bootstrap"
+
	index: int | *0
+
	out:   "\(nid)@\(name)-\(index).\(role).default.svc.cluster.local:8776"
+
}
+

+
#BaseBootstrapSeedConfig: {
+
	node: {
+
		listen: ["0.0.0.0:8776"]
+
		seedingPolicy: {
+
			default: "allow"
+
			scope:   "all"
+
		}
+
		...
+
	}
+
	...
+
}
+

+
#BasePeerConfig: {
+
	node: {
+
		listen: []
+
		peers: type: "dynamic"
+
		connect: []
+
		externalAddresses: [] // Explicitly override the default to be empty
+
		log:                  "INFO"
+
		relay:                "auto"
+
		limits: {
+
			routingMaxSize:   1000
+
			routingMaxAge:    604800
+
			gossipMaxAge:     1209600
+
			fetchConcurrency: 1
+
			maxOpenFiles:     4096
+
			rate: {
+
				inbound: {fillRate: 5.0, capacity: 1024}
+
				outbound: {fillRate: 10.0, capacity: 2048}
+
			}
+
			connection: {inbound: 128, outbound: 16}
+
			fetchPackReceive: "500.0 MiB"
+
		}
+
		seedingPolicy: default: "block"
+
		...
+
	}
+
	...
+
}
+

+
//
+
// Topology Constraints
+
//
+
values: {
+
	topology: {
+
		//
+
		// Naming conventions so bindings can be generated automatically
+
		// See: [`radicle-simulations/build.rs`]
+
		//
+

+
		// Prefix enforcement `bootstrap-`, `peer-`, `seed-`
+
		[=~"^(bootstrap|peer|seed)-[a-zA-Z0-9-]+$"]: {
+
			role:     string
+
			replicas: int | *1
+
		}
+
		// Peer version enforcement
+
		[=~"^peer-v[0-9]+-[0-9]+-[0-9]+$"]: {
+
			role: "peer"
+
		}
+
	}
+
}
modified simulation/justfile
@@ -15,6 +15,10 @@ HINT := "💡 " + BOLD
default:
    @just --list

+
[group('test')]
+
test: setup vendor-timoni-dependencies
+
    @cargo test -p radicle-simulation
+

# Setup and start the complete simulation environment
[group('start')]
[group('setup')]
@@ -31,19 +35,7 @@ setup: configure-cluster
# Create the Talos cluster if it doesn't exist
[private]
create-cluster: (verify-tool "talosctl") ensure-ovmf
-
    #!/usr/bin/env bash
-
    set -e
-
    if [ ! -d "{{clusters_dir}}/{{cluster_name}}" ]; then
-
        echo "{{CHECK}}Creating Talos cluster '{{cluster_name}}' using {{provisioner}}...{{NORMAL}}"
-
        mkdir -p "{{clusters_dir}}"
-
        if [ "{{provisioner}}" = "qemu" ]; then
-
            sudo --preserve-env=HOME,PATH talosctl cluster create --name={{cluster_name}} {{provisioner}} --config-patch-controlplanes '{"cluster": {"allowSchedulingOnControlPlanes": true}}'
-
        else
-
            talosctl cluster create --name={{cluster_name}} {{provisioner}} --config-patch-controlplanes '{"cluster": {"allowSchedulingOnControlPlanes": true}}'
-
        fi
-
    else
-
        echo "{{SUCCESS}}Cluster '{{cluster_name}}' already exists.{{NORMAL}}"
-
    fi
+
    @CHECK="{{CHECK}}" SUCCESS="{{SUCCESS}}" NORMAL="{{NORMAL}}" ../scripts/just/create-cluster.sh "{{clusters_dir}}" "{{cluster_name}}" "{{provisioner}}"

# Configure the Kubernetes cluster
[private]
@@ -67,17 +59,7 @@ start-network: (verify-tool "timoni") vendor-timoni-dependencies
# Vendor Timoni dependencies
[private]
vendor-timoni-dependencies: (verify-tool "timoni")
-
    #!/usr/bin/env bash
-
    set -e
-
    cd {{radicle_node_module}}
-
    if [ ! -d "{{module_pkg}}" ]; then
-
        echo "{{CHECK}}Fetching Timoni pkg files...{{NORMAL}}"
-
         timoni artifact pull oci://ghcr.io/stefanprodan/timoni/schemas -o cue.mod/pkg
-
    fi
-
    if [ ! -d "{{module_gen}}" ]; then
-
        echo "{{CHECK}}Fetching Timoni k8s gen files...{{NORMAL}}"
-
        timoni mod vendor k8s
-
    fi
+
    @CHECK="{{CHECK}}" NORMAL="{{NORMAL}}" ../scripts/just/vendor-timoni-dependencies.sh "{{radicle_node_module}}" "{{module_pkg}}" "{{module_gen}}"

# Show cluster status
[group('inspect')]
@@ -107,62 +89,21 @@ delete-pvc: (verify-tool "kubectl")
# Destroy the Talos cluster and clean up kubeconfig
[group('delete')]
destroy: (verify-tool "kubectl") (verify-tool "talosctl") show-cluster
-
    #!/usr/bin/env bash
-
    set -e
-
    echo ""
-
    echo -n "Are you sure you want to destroy the cluster and remove kubeconfig entries? [y/N] "
-
    read answer
-
    if [ "${answer:-N}" != "y" ]; then
-
        echo "Aborted."
-
        exit 1
-
    fi
-

-
    echo "{{CHECK}}Destroying talos cluster '{{cluster_name}}'...{{NORMAL}}"
-
    if [ "{{provisioner}}" = "qemu" ]; then
-
        sudo --preserve-env=HOME,PATH talosctl cluster destroy --name {{cluster_name}} --provisioner {{provisioner}}
-
    else
-
        talosctl cluster destroy --name {{cluster_name}} --provisioner {{provisioner}}
-
    fi
-

-
    echo "{{CHECK}}Removing kube config entries...{{NORMAL}}"
-
    CONTEXT=$(kubectl config current-context 2>/dev/null || echo "")
-
    if [ -n "$CONTEXT" ]; then
-
        CLUSTER=$(echo "$CONTEXT" | cut -d '@' -f 2)
-
        kubectl config delete-context "$CONTEXT" || true
-
        kubectl config delete-cluster "$CLUSTER" || true
-
        kubectl config unset "users.$CONTEXT" || true
-
    fi
-
    echo "{{WARN}}Make sure you remove the '{{cluster_name}}' entry from: ~/.talos/config{{NORMAL}}"
-
    echo "{{SUCCESS}}Cluster destroyed.{{NORMAL}}"
+
    @CHECK="{{CHECK}}" WARN="{{WARN}}" SUCCESS="{{SUCCESS}}" NORMAL="{{NORMAL}}" ../scripts/just/destroy-cluster.sh "{{cluster_name}}" "{{provisioner}}"
+

+
# Update image tags in a CUE values file
+
[group('setup')]
+
update-image-tags values_file: (verify-tool "jq") (verify-tool "curl")
+
    @CHECK="{{CHECK}}" SUCCESS="{{SUCCESS}}" ERROR="{{ERROR}}" NORMAL="{{NORMAL}}" ../scripts/just/update-image-tags.sh "{{values_file}}"

# Check if required tools are in PATH.
[private]
verify-tool tool package_name="":
-
    #!/usr/bin/env bash
-
    set -e
-
    if ! command -v {{tool}} >/dev/null 2>&1; then
-
        PKG="{{package_name}}"
-
        if [ -z "$PKG" ]; then
-
            PKG="{{tool}}"
-
        fi
-
        echo "{{ERROR}}Missing required tool: {{tool + NORMAL}}"
-
        echo "{{HINT}}Use your systems package manager to install '$PKG'.{{NORMAL}}"
-
        exit 1
-
    fi
+
    @ERROR="{{ERROR}}" NORMAL="{{NORMAL}}" HINT="{{HINT}}" ../scripts/just/verify-tool.sh "{{tool}}" "{{package_name}}"

# Ensure OVMF firmware is discoverable by talosctl.
# On NixOS, OVMF lives in the Nix store — not in /usr/share/OVMF where
# talosctl expects it. We symlink the firmware files into place.
[private]
ensure-ovmf:
-
    #!/usr/bin/env bash
-
    set -e
-
    if [ -z "${OVMF_FD_PATH:-}" ]; then
-
        # Not on NixOS / not using the devshell — assume OVMF is installed normally
-
        exit 0
-
    fi
-
    if [ ! -f /usr/share/OVMF/OVMF_CODE.fd ]; then
-
        echo "{{CHECK}}Symlinking OVMF firmware from Nix store into /usr/share/OVMF...{{NORMAL}}"
-
        sudo mkdir -p /usr/share/OVMF
-
        sudo ln -sf "$OVMF_FD_PATH"/* /usr/share/OVMF/
-
    fi
+
    @CHECK="{{CHECK}}" NORMAL="{{NORMAL}}" ../scripts/just/ensure-ovmf.sh
modified simulation/modules/radicle-node/values.cue
@@ -16,10 +16,4 @@ values: {
		version: "1.2.0" | "1.4.0" | "1.5.0" | "1.5.0-" | "1.5.0-amd64" | "1.5.0-arm64" | "1.6.0" | "1.6.1" | "1.7.0" | "1.7.1" | "1.8.0" | "latest" | "main" | "production" | "sqlite-patch" | string | *"latest"
		...
	}
-

-
	// Provide a default node so that a basic install works out-of-the-box
-
	topology: "default-node": {
-
		role: "seed"
-
		replicas: 1
-
	}
}
added simulation/radicle-simulation/Cargo.toml
@@ -0,0 +1,23 @@
+
[package]
+
name = "radicle-simulation"
+
version = "0.1.0"
+
build = "build.rs"
+
homepage.workspace = true
+
repository.workspace = true
+
license.workspace = true
+
edition.workspace = true
+
rust-version.workspace = true
+

+
[dependencies]
+
uuid = { version = "1.8.0", features = ["v4"] }
+
log = "0.4"
+
env_logger = "0.11"
+

+
[dev-dependencies]
+
rstest = "0.19.0"
+
serde_json = "1.0"
+
ruast = "0.0.23"
+

+
[build-dependencies]
+
serde_json = "1.0"
+
ruast = "0.0.23"
added simulation/radicle-simulation/build.rs
@@ -0,0 +1,384 @@
+
use std::env;
+
use std::fs;
+
use std::path::Path as StdPath;
+
use std::process::Command;
+

+
// Include the shared constants
+
include!("src/constants.rs");
+

+
// Helper to parse "peer-v1-5-0" into a tuple (1, 5, 0) for sorting
+
fn parse_version(name: &str) -> (u32, u32, u32) {
+
    let parts: Vec<&str> = name.split('-').collect();
+
    if parts.len() >= 4 && parts[0] == "peer" && parts[1].starts_with('v') {
+
        let major = parts[1][1..].parse().unwrap_or(0);
+
        let minor = parts[2].parse().unwrap_or(0);
+
        let patch = parts[3].parse().unwrap_or(0);
+
        (major, minor, patch)
+
    } else {
+
        (0, 0, 0)
+
    }
+
}
+

+
// Helper to generate the `radicle_simulation::node::Node` path
+
fn node_type_path() -> ruast::Path {
+
    ruast::Path::new(vec![
+
        ruast::PathSegment::simple("radicle_simulation"),
+
        ruast::PathSegment::simple("node"),
+
        ruast::PathSegment::simple("Node"),
+
    ])
+
}
+

+
// Helper to generate the `radicle_simulation::node::Node::new` path
+
fn node_new_path() -> ruast::Path {
+
    ruast::Path::new(vec![
+
        ruast::PathSegment::simple("radicle_simulation"),
+
        ruast::PathSegment::simple("node"),
+
        ruast::PathSegment::simple("Node"),
+
        ruast::PathSegment::simple("new"),
+
    ])
+
}
+

+
// Helper to build the AST for `format!("{}-{}", node_name, index)`
+
fn build_format_macro(node_name: &str) -> ruast::Expr {
+
    let mut ts = ruast::TokenStream::new();
+
    ts.extend(ruast::TokenStream::from(ruast::Expr::new(ruast::Lit::str(
+
        format!("{}-{{}}", node_name),
+
    ))));
+
    ts.push(ruast::Token::Comma);
+
    ts.extend(ruast::TokenStream::from(ruast::Expr::new(
+
        ruast::Path::single("index"),
+
    )));
+

+
    ruast::Expr::new(ruast::MacCall::new(
+
        ruast::Path::single("format"),
+
        ruast::DelimArgs::parenthesis(ts),
+
    ))
+
}
+

+
// Generates the AST for an individual node function
+
//   `pub fn <node_name>([index: usize]) -> radicle_simulation::node::Node`
+
fn generate_node_function(node_name: &str, replicas: u64) -> ruast::Item {
+
    let fn_name = node_name.replace("-", "_");
+
    let mut base_fn = ruast::Fn::empty(fn_name);
+

+
    base_fn
+
        .fn_decl
+
        .set_output(ruast::Type::Path(node_type_path()));
+

+
    if replicas == 1 {
+
        base_fn.body = Some(ruast::Block::from(ruast::Expr::new(node_new_path()).call(
+
            vec![ruast::Expr::new(ruast::Lit::str(format!(
+
                "{}-0",
+
                node_name
+
            )))],
+
        )));
+
    } else {
+
        base_fn.fn_decl.add_input(ruast::Param::ident(
+
            "index",
+
            ruast::Type::Path(ruast::Path::single("usize")),
+
        ));
+

+
        let format_mac = build_format_macro(node_name);
+

+
        base_fn.body = Some(ruast::Block::from(
+
            ruast::Expr::new(node_new_path()).call(vec![format_mac]),
+
        ));
+
    }
+

+
    ruast::Item::public(base_fn)
+
}
+

+
// Generates the AST for the `peer_relative` function
+
//   `pub fn peer_relative(offset: isize, index: usize) -> radicle_simulation::node::Node`
+
fn generate_peer_relative_function(versioned_peers: &[(String, (u32, u32, u32))]) -> ruast::Item {
+
    let len = versioned_peers.len() as isize;
+
    let mut peer_relative_fn = ruast::Fn::empty("peer_relative");
+

+
    peer_relative_fn.fn_decl.add_input(ruast::Param::ident(
+
        "offset",
+
        ruast::Type::Path(ruast::Path::single("isize")),
+
    ));
+
    peer_relative_fn.fn_decl.add_input(ruast::Param::ident(
+
        "index",
+
        ruast::Type::Path(ruast::Path::single("usize")),
+
    ));
+
    peer_relative_fn
+
        .fn_decl
+
        .set_output(ruast::Type::Path(node_type_path()));
+

+
    if len > 0 {
+
        let match_arms = versioned_peers
+
            .iter()
+
            .enumerate()
+
            .map(|(i, (node_name, _))| {
+
                let offset = (i as isize) - len + 1;
+
                let format_mac = build_format_macro(node_name);
+

+
                ruast::Arm::new(
+
                    ruast::Pat::Lit(ruast::Expr::new(ruast::Lit::int(offset.to_string()))),
+
                    None,
+
                    ruast::Expr::new(node_new_path()).call(vec![format_mac]),
+
                )
+
            })
+
            .chain(std::iter::once({
+
                // Build `panic!("Invalid relative offset. Available offsets: ...")`
+
                let mut ts = ruast::TokenStream::new();
+
                ts.extend(ruast::TokenStream::from(ruast::Expr::new(ruast::Lit::str(
+
                    format!(
+
                        "Invalid relative offset. Available offsets: {}..=0",
+
                        1 - len
+
                    ),
+
                ))));
+

+
                let panic_mac = ruast::Expr::new(ruast::MacCall::new(
+
                    ruast::Path::single("panic"),
+
                    ruast::DelimArgs::parenthesis(ts),
+
                ));
+

+
                ruast::Arm::new(ruast::Pat::Wild, None, panic_mac)
+
            }))
+
            .collect::<Vec<_>>();
+

+
        peer_relative_fn.body = Some(ruast::Block::from(ruast::Expr::new(ruast::Match::new(
+
            ruast::Expr::new(ruast::Path::single("offset")),
+
            match_arms,
+
        ))));
+
    } else {
+
        // Build `panic!("No versioned peers available")`
+
        let mut ts = ruast::TokenStream::new();
+
        ts.extend(ruast::TokenStream::from(ruast::Expr::new(ruast::Lit::str(
+
            "No versioned peers available",
+
        ))));
+

+
        let panic_mac = ruast::Expr::new(ruast::MacCall::new(
+
            ruast::Path::single("panic"),
+
            ruast::DelimArgs::parenthesis(ts),
+
        ));
+

+
        peer_relative_fn.body = Some(ruast::Block::from(panic_mac));
+
    };
+

+
    ruast::Item::public(peer_relative_fn)
+
}
+

+
/// Pure function to generate Rust bindings from a JSON string using `ruast`.
+
fn generate_bindings(json_str: &str) -> Result<String, String> {
+
    let topology: serde_json::Value =
+
        serde_json::from_str(json_str).map_err(|e| format!("Invalid JSON: {}", e))?;
+

+
    let obj = topology.as_object().cloned().unwrap_or_default();
+

+
    let mut keys: Vec<String> = obj.keys().cloned().collect();
+
    keys.sort();
+

+
    // Generate the individual node functions
+
    let node_items = keys.iter().map(|node_name| {
+
        let config = &obj[node_name];
+
        let replicas = config.get("replicas").and_then(|r| r.as_u64()).unwrap_or(1);
+
        generate_node_function(node_name, replicas)
+
    });
+

+
    // Extract and sort versioned peers
+
    let mut versioned_peers: Vec<(String, (u32, u32, u32))> = keys
+
        .iter()
+
        .filter(|name| name.starts_with("peer-v"))
+
        .map(|name| {
+
            let version = parse_version(name);
+
            (name.clone(), version)
+
        })
+
        .collect();
+

+
    versioned_peers.sort_by_key(|k| k.1);
+

+
    // Generate the `peer_relative` function
+
    let peer_relative_fn = generate_peer_relative_function(&versioned_peers);
+

+
    // Assemble the final crate AST
+
    let mut krate = ruast::Crate::new();
+

+
    for item in node_items {
+
        krate.add_item(item);
+
    }
+
    krate.add_item(peer_relative_fn);
+

+
    Ok(krate.to_string())
+
}
+

+
fn is_valid_cue_instance(path: &StdPath) -> bool {
+
    let is_cue = path.extension().and_then(|s| s.to_str()) == Some("cue");
+
    let is_not_schema = path.file_name().and_then(|s| s.to_str()) != Some(SCHEMA_FILE);
+
    is_cue && is_not_schema
+
}
+

+
#[allow(dead_code)]
+
fn main() {
+
    // Tell Cargo to recompile if anything in the instances directory changes.
+
    //
+
    // This may not trigger because of the top-level `Cargo.toml` containing
+
    // `default-members` that doesn't include `radicle-simulation` (so that we
+
    // don't run the simulation tests with a regular `cargo test`).
+
    //
+
    // You instead can use `$ cargo check -p radicle-simulation`
+
    println!("cargo:rerun-if-changed={}", INSTANCES_DIR);
+

+
    let out_dir = env::var_os("OUT_DIR").unwrap();
+
    let out_path = StdPath::new(&out_dir);
+

+
    let entries = fs::read_dir(INSTANCES_DIR).expect("Failed to read instances directory");
+

+
    for entry in entries {
+
        let entry = entry.expect("Failed to read directory entry");
+
        let path = entry.path();
+

+
        if path.is_file() && is_valid_cue_instance(&path) {
+
            let file_stem = path.file_stem().unwrap().to_str().unwrap();
+
            let cue_file = path.to_str().unwrap();
+

+
            // Export the topology block from CUE to JSON
+
            let output = Command::new("cue")
+
                .args([
+
                    "export",
+
                    cue_file,
+
                    SCHEMA_CUE_PATH,
+
                    "-e",
+
                    CUE_TOPOLOGY_PATH,
+
                    "--out",
+
                    "json",
+
                ])
+
                .output()
+
                .unwrap_or_else(|_| {
+
                    panic!(
+
                        "Failed to run `cue export` on {}. Ensure the CUE CLI is installed.",
+
                        cue_file
+
                    )
+
                });
+

+
            if !output.status.success() {
+
                panic!(
+
                    "Failed to export {}:\n{}",
+
                    cue_file,
+
                    String::from_utf8_lossy(&output.stderr)
+
                );
+
            }
+

+
            let json_str = String::from_utf8_lossy(&output.stdout);
+

+
            match generate_bindings(&json_str) {
+
                Ok(generated_code) => {
+
                    let dest_path = out_path.join(format!("{}_bindings.rs", file_stem));
+
                    fs::write(&dest_path, generated_code).unwrap();
+
                }
+
                Err(e) => {
+
                    println!(
+
                        "cargo:warning=Failed to generate bindings for {}: {}",
+
                        cue_file, e
+
                    );
+
                }
+
            }
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[test]
+
    fn test_parse_version() {
+
        assert_eq!(parse_version("peer-v1-5-0"), (1, 5, 0));
+
        assert_eq!(parse_version("peer-v1-6-1"), (1, 6, 1));
+
        assert_eq!(parse_version("peer-v2-0-0"), (2, 0, 0));
+

+
        assert_eq!(parse_version("bootstrap-v1-6-1"), (0, 0, 0));
+
        assert_eq!(parse_version("peer-latest"), (0, 0, 0));
+
        assert_eq!(parse_version("random-string"), (0, 0, 0));
+
    }
+

+
    #[test]
+
    fn test_generate_bindings_empty() {
+
        let json = "{}";
+
        let code = generate_bindings(json).unwrap();
+
        assert!(code.contains("No versioned peers available"));
+
    }
+

+
    #[test]
+
    fn test_generate_bindings_single_replica() {
+
        let json = r#"{
+
            "peer-v1-5-0": {
+
                "replicas": 1
+
            }
+
        }"#;
+
        let code = generate_bindings(json).unwrap();
+
        let code_no_space = code.replace(" ", "");
+

+
        assert!(code_no_space.contains("pubfnpeer_v1_5_0()->radicle_simulation::node::Node"));
+
        assert!(code_no_space.contains("radicle_simulation::node::Node::new(\"peer-v1-5-0-0\")"));
+
        assert!(
+
            code_no_space.contains(
+
                "0=>radicle_simulation::node::Node::new(format!(\"peer-v1-5-0-{}\",index))"
+
            )
+
        );
+
    }
+

+
    #[test]
+
    fn test_generate_bindings_multi_replica() {
+
        let json = r#"{
+
            "peer-v1-7-0": {
+
                "replicas": 3
+
            }
+
        }"#;
+
        let code = generate_bindings(json).unwrap();
+
        let code_no_space = code.replace(" ", "");
+

+
        assert!(
+
            code_no_space.contains("pubfnpeer_v1_7_0(index:usize)->radicle_simulation::node::Node")
+
        );
+
        assert!(
+
            code_no_space
+
                .contains("radicle_simulation::node::Node::new(format!(\"peer-v1-7-0-{}\",index))")
+
        );
+
    }
+

+
    #[test]
+
    fn test_generate_bindings_relative_offsets() {
+
        let json = r#"{
+
            "peer-v1-7-0": { "replicas": 1 },
+
            "peer-v1-5-0": { "replicas": 1 },
+
            "peer-v1-6-1": { "replicas": 1 }
+
        }"#;
+
        let code = generate_bindings(json).unwrap();
+
        let code_no_space = code.replace(" ", "");
+

+
        assert!(code_no_space.contains(
+
            "-2=>radicle_simulation::node::Node::new(format!(\"peer-v1-5-0-{}\",index))"
+
        ));
+
        assert!(code_no_space.contains(
+
            "-1=>radicle_simulation::node::Node::new(format!(\"peer-v1-6-1-{}\",index))"
+
        ));
+
        assert!(
+
            code_no_space.contains(
+
                "0=>radicle_simulation::node::Node::new(format!(\"peer-v1-7-0-{}\",index))"
+
            )
+
        );
+

+
        assert!(code.contains("Available offsets: -2..=0"));
+
    }
+

+
    #[test]
+
    fn test_is_valid_cue_instance() {
+
        assert!(is_valid_cue_instance(StdPath::new("network.cue")));
+
        assert!(is_valid_cue_instance(StdPath::new("phase1_network.cue")));
+
        assert!(is_valid_cue_instance(StdPath::new(
+
            "/some/path/network.cue"
+
        )));
+

+
        assert!(!is_valid_cue_instance(StdPath::new("schema.cue")));
+
        assert!(!is_valid_cue_instance(StdPath::new(
+
            "../instances/schema.cue"
+
        )));
+

+
        assert!(!is_valid_cue_instance(StdPath::new("network.json")));
+
        assert!(!is_valid_cue_instance(StdPath::new("schema.json")));
+
        assert!(!is_valid_cue_instance(StdPath::new("README.md")));
+
    }
+
}
added simulation/radicle-simulation/src/constants.rs
@@ -0,0 +1,37 @@
+
// Shared constants for the Radicle simulation testing framework.
+
//
+
// These constants are used by both the main test framework (`src/`) and the
+
// build script (`build.rs`). Because `build.rs` is compiled separately, it
+
// includes this file directly via the `include!` macro.
+

+
macro_rules! instances_dir {
+
    () => {
+
        "../instances"
+
    };
+
}
+

+
macro_rules! schema_file {
+
    () => {
+
        "schema.cue"
+
    };
+
}
+

+
/// The directory containing the CUE network topology instances.
+
pub const INSTANCES_DIR: &str = instances_dir!();
+

+
/// Schema filename
+
pub const SCHEMA_FILE: &str = schema_file!();
+

+
/// The path to the shared CUE schema file.
+
pub const SCHEMA_CUE_PATH: &str = concat!(instances_dir!(), "/", schema_file!());
+

+
/// The CUE path used to extract the topology map during export.
+
pub const CUE_TOPOLOGY_PATH: &str = "values.topology";
+

+
/// The path to the Timoni module used to deploy the network.
+
#[allow(unused)]
+
pub const TIMONI_MODULE_PATH: &str = "../modules/radicle-node";
+

+
/// The name of the Timoni instance deployed to the Kubernetes cluster.
+
#[allow(unused)]
+
pub const TIMONI_INSTANCE_NAME: &str = "radicle-network";
added simulation/radicle-simulation/src/lib.rs
@@ -0,0 +1,36 @@
+
//! Radicle Simulation Testing Framework
+
//!
+
//! This crate provides a high-level, ergonomic framework for writing integration
+
//! and simulation tests for Radicle networks. It abstracts away the complexities
+
//! of Kubernetes pod execution, identity management, and Radicle CLI interactions.
+

+
pub mod constants;
+
pub mod network;
+
pub mod node;
+

+
/// Sets up the network module and `require_network` helper for a given CUE instance.
+
///
+
/// This macro dynamically includes the auto-generated Rust bindings for the specified
+
/// CUE network topology file (generated by `build.rs`). It also generates a `require_network`
+
/// function that tests can call to ensure the network is provisioned and ready before executing.
+
#[macro_export]
+
macro_rules! setup_network {
+
    ($name:expr) => {
+
        pub mod network {
+
            // Include the auto-generated bindings
+
            include!(concat!(env!("OUT_DIR"), "/", $name, "_bindings.rs"));
+
        }
+

+
        /// Ensures the simulated network is provisioned and ready for testing.
+
        ///
+
        /// This function initializes the test logger and applies the CUE network topology.
+
        /// It returns a reference-counted `NetworkGuard`. The network will remain active
+
        /// as long as at least one test holds a guard, and will be automatically torn down
+
        /// when the final guard is dropped (unless `PRESERVE_NETWORK=1` is set).
+
        pub fn require_network() -> std::sync::Arc<$crate::network::NetworkGuard> {
+
            // TODO(Ade): Doesn't feel right here, but is ergonomic
+
            let _ = env_logger::builder().is_test(true).try_init();
+
            $crate::network::apply_network(concat!("../instances/", $name, ".cue"))
+
        }
+
    };
+
}
added simulation/radicle-simulation/src/network.rs
@@ -0,0 +1,136 @@
+
//! Network lifecycle management for the Radicle simulation environment.
+

+
use crate::constants::*;
+
use crate::node::Node;
+
use std::io::Write;
+
use std::process::{Command, Stdio};
+
use std::str;
+
use std::sync::{Arc, Mutex, Weak};
+

+
/// A guard that ensures the network is torn down when tests finish.
+
pub struct NetworkGuard {
+
    cue_path: String,
+
}
+

+
impl Drop for NetworkGuard {
+
    fn drop(&mut self) {
+
        if let Ok(val) = std::env::var("PRESERVE_NETWORK")
+
            && (val == "1" || val.eq_ignore_ascii_case("true"))
+
        {
+
            println!(
+
                "⏭️ PRESERVE_NETWORK is set. Skipping cleanup for {}.",
+
                self.cue_path
+
            );
+
            return;
+
        }
+

+
        println!("🔄 Tearing down network topology from {}...", self.cue_path);
+
        let status = Command::new("timoni")
+
            .args(["delete", TIMONI_INSTANCE_NAME])
+
            .status();
+

+
        match status {
+
            Ok(s) if s.success() => println!("✅ Network torn down successfully."),
+
            _ => eprintln!("⚠️ Warning: Failed to cleanly tear down network."),
+
        }
+
    }
+
}
+

+
static NETWORK_GUARD: Mutex<Weak<NetworkGuard>> = Mutex::new(Weak::new());
+

+
/// Applies the specified CUE network topology to the Kubernetes cluster.
+
///
+
/// This function evaluates the CUE files, pipes the resulting JSON to Timoni,
+
/// and waits for all pods and Radicle nodes to become fully responsive.
+
pub fn apply_network(cue_path: &str) -> Arc<NetworkGuard> {
+
    let mut weak_guard = NETWORK_GUARD.lock().unwrap();
+

+
    // If the network is already running, just return a clone of the Arc
+
    if let Some(arc) = weak_guard.upgrade() {
+
        return arc;
+
    }
+

+
    println!("🔄 Applying network topology from {}...", cue_path);
+

+
    // Merge and evaluate CUE files into a single JSON stream
+
    let cue_export = Command::new("cue")
+
        .args(["export", SCHEMA_CUE_PATH, cue_path, "--out", "json"])
+
        .output()
+
        .expect("Failed to execute cue export");
+

+
    if !cue_export.status.success() {
+
        panic!(
+
            "CUE export failed:\n{}",
+
            String::from_utf8_lossy(&cue_export.stderr)
+
        );
+
    }
+

+
    // Pipe the evaluated JSON directly into Timoni
+
    let mut timoni = Command::new("timoni")
+
        .args([
+
            "apply",
+
            TIMONI_INSTANCE_NAME,
+
            TIMONI_MODULE_PATH,
+
            "--values",
+
            "-", // The '-' tells Timoni to read values from stdin
+
        ])
+
        .stdin(Stdio::piped())
+
        .spawn()
+
        .expect("Failed to spawn timoni");
+

+
    // Write the JSON to Timoni's stdin
+
    if let Some(mut stdin) = timoni.stdin.take() {
+
        stdin
+
            .write_all(&cue_export.stdout)
+
            .expect("Failed to write to timoni stdin");
+
    }
+

+
    let status = timoni.wait().expect("Failed to wait on timoni");
+
    assert!(status.success(), "Timoni apply failed for {}", cue_path);
+

+
    // Wait for pods to be Ready
+
    println!("🔄 Waiting for pods to be ready...");
+
    let wait_status = Command::new("kubectl")
+
        .args([
+
            "wait",
+
            "--for=condition=Ready",
+
            "pod",
+
            "-l",
+
            "app=radicle-node",
+
            "--timeout=300s",
+
        ])
+
        .status()
+
        .expect("Failed to wait for pods");
+
    assert!(wait_status.success(), "Pods did not become ready in time");
+

+
    // Wait for Radicle nodes to be fully responsive
+
    println!("🔄 Waiting for Radicle nodes to be fully responsive...");
+
    let output = Command::new("kubectl")
+
        .args([
+
            "get",
+
            "pods",
+
            "-l",
+
            "app=radicle-node",
+
            "-o",
+
            "jsonpath={.items[*].metadata.name}",
+
        ])
+
        .output()
+
        .expect("Failed to execute kubectl get pods");
+

+
    let stdout = str::from_utf8(&output.stdout).unwrap_or("");
+
    let pods: Vec<&str> = stdout.split_whitespace().collect();
+

+
    for pod_name in pods {
+
        let node = Node::new(pod_name);
+
        node.wait_until_responsive()
+
            .expect("Node did not become responsive");
+
    }
+
    println!("✅ Network {} is ready!", cue_path);
+

+
    let arc = Arc::new(NetworkGuard {
+
        cue_path: cue_path.to_string(),
+
    });
+
    *weak_guard = Arc::downgrade(&arc);
+

+
    arc
+
}
added simulation/radicle-simulation/src/node.rs
@@ -0,0 +1,275 @@
+
//! Node and Repository abstractions for the Radicle simulation environment.
+
//!
+
//! This module provides the core types used to interact with Radicle nodes
+
//! running inside the Kubernetes cluster. It includes executors for running
+
//! commands, persona builders for identity management, and high-level wrappers
+
//! for Radicle CLI operations (issues, patches, syncing).
+

+
use std::process::Command;
+
use std::str;
+
use std::thread;
+
use std::time::Duration;
+
use uuid::Uuid;
+

+
/// Escapes single quotes for safe interpolation inside a shell single-quoted string.
+
fn escape_sh(s: &str) -> String {
+
    s.replace('\'', "'\\''")
+
}
+

+
/// Trait defining how commands are executed within a Radicle node's environment.
+
pub trait NodeExecutor {
+
    /// Executes a command on the specified node and returns the combined stdout/stderr.
+
    fn exec(&self, node_name: &str, cmd: &[&str]) -> Result<String, String>;
+
}
+

+
/// Default executor implementation that uses the `kubectl exec` CLI command.
+
///
+
/// This executor shells out to the local `kubectl` binary to run commands inside
+
/// the `node` container of the target Kubernetes pod.
+
#[derive(Clone, Debug)]
+
pub struct KubectlExecutor;
+

+
impl NodeExecutor for KubectlExecutor {
+
    fn exec(&self, node_name: &str, cmd: &[&str]) -> Result<String, String> {
+
        let mut args = vec!["exec", "-i", node_name, "-c", "node", "--"];
+
        args.extend_from_slice(cmd);
+

+
        let output = Command::new("kubectl")
+
            .args(&args)
+
            .output()
+
            .map_err(|e| format!("Failed to execute kubectl: {}", e))?;
+

+
        let stdout = str::from_utf8(&output.stdout)
+
            .unwrap_or("")
+
            .trim()
+
            .to_string();
+
        let stderr = str::from_utf8(&output.stderr)
+
            .unwrap_or("")
+
            .trim()
+
            .to_string();
+

+
        if output.status.success() {
+
            // Git commands often write success messages to stderr, so we combine them
+
            let mut combined = stdout;
+
            if !stderr.is_empty() {
+
                combined.push('\n');
+
                combined.push_str(&stderr);
+
            }
+
            Ok(combined.trim().to_string())
+
        } else {
+
            let mut combined = stderr;
+
            if !stdout.is_empty() {
+
                combined.push('\n');
+
                combined.push_str(&stdout);
+
            }
+
            Err(format!(
+
                "Command failed: {}\nStderr: {}",
+
                args.join(" "),
+
                combined.trim()
+
            ))
+
        }
+
    }
+
}
+

+
/// Represents a Radicle node running in the simulated network.
+
///
+
/// This struct provides methods to interact with the node's CLI, manage its Git
+
/// identity (persona), and initialize or clone repositories.
+
#[derive(Clone, Debug)]
+
pub struct Node<E = KubectlExecutor> {
+
    /// Kubernetes pod name of the node.
+
    pub name: String,
+
    /// The executor used to run commands on this node.
+
    pub executor: E,
+
    /// Optional Git identity (Name, Email) assigned to this node.
+
    pub persona: Option<(String, String)>,
+
}
+

+
impl Node<KubectlExecutor> {
+
    /// Creates a new `Node` instance using the default `KubectlExecutor`.
+
    pub fn new(name: impl Into<String>) -> Self {
+
        Self {
+
            name: name.into(),
+
            executor: KubectlExecutor,
+
            persona: None,
+
        }
+
    }
+
}
+

+
impl<E: NodeExecutor + Clone> Node<E> {
+
    /// Internal helper to set the Git identity on the node and store it in the struct.
+
    pub fn with_persona(mut self, name: &str, email: &str) -> Result<Self, String> {
+
        self.setup_identity(name, email)?;
+
        self.persona = Some((name.to_string(), email.to_string()));
+
        Ok(self)
+
    }
+

+
    /// Configures the node with the standard "Alice" test persona.
+
    pub fn as_alice(self) -> Result<Self, String> {
+
        self.with_persona("Alice", "alice@radicle.local")
+
    }
+

+
    /// Executes a command inside the node container via the configured executor.
+
    ///
+
    /// This method automatically logs the command being executed (at the `INFO` level)
+
    /// and its output or failure (at the `DEBUG` or `ERROR` levels).
+
    pub fn exec(&self, cmd: &[&str]) -> Result<String, String> {
+
        let identity = if let Some((name, _)) = &self.persona {
+
            format!("{}@{}", name, self.name)
+
        } else {
+
            self.name.clone()
+
        };
+

+
        log::info!("[{}] $ {}", identity, cmd.join(" "));
+

+
        let result = self.executor.exec(&self.name, cmd);
+

+
        match &result {
+
            Ok(output) if !output.trim().is_empty() => {
+
                log::debug!("[{}] Output:\n{}", identity, output.trim());
+
            }
+
            Err(err) => {
+
                log::error!("[{}] Failed:\n{}", identity, err);
+
            }
+
            _ => {}
+
        }
+

+
        result
+
    }
+

+
    /// Convenience wrapper for executing raw shell scripts inside the node.
+
    pub fn exec_sh(&self, script: &str) -> Result<String, String> {
+
        self.exec(&["sh", "-c", script])
+
    }
+

+
    /// Configures the global Git user name and email on the node.
+
    ///
+
    /// Uses a retry loop to prevent parallel tests from failing due to `gitconfig` file locks.
+
    pub fn setup_identity(&self, name: &str, email: &str) -> Result<(), String> {
+
        let name_esc = escape_sh(name);
+
        let email_esc = escape_sh(email);
+

+
        let mut retries = 10;
+
        while retries > 0 {
+
            if self
+
                .exec_sh(&format!("git config --global user.name '{name_esc}'"))
+
                .is_ok()
+
            {
+
                break;
+
            }
+
            retries -= 1;
+
            thread::sleep(Duration::from_millis(100));
+
        }
+
        if retries == 0 {
+
            return Err("Failed to set git config user.name".to_string());
+
        }
+

+
        let mut retries = 10;
+
        while retries > 0 {
+
            if self
+
                .exec_sh(&format!("git config --global user.email '{email_esc}'"))
+
                .is_ok()
+
            {
+
                break;
+
            }
+
            retries -= 1;
+
            thread::sleep(Duration::from_millis(100));
+
        }
+
        if retries == 0 {
+
            return Err("Failed to set git config user.email".to_string());
+
        }
+

+
        Ok(())
+
    }
+

+
    /// Initializes a new Radicle repository on this node.
+
    ///
+
    /// This method automatically appends a UUID to the repository name to ensure
+
    /// parallel tests do not collide. It initializes a Git repository, creates the
+
    /// specified number of commits, and initializes it as a Radicle project.
+
    pub fn init_test_repo(
+
        &self,
+
        base_name: &str,
+
        desc: &str,
+
        commits: u32,
+
    ) -> Result<Repository<E>, String> {
+
        let (author, email) = self
+
            .persona
+
            .as_ref()
+
            .ok_or("Persona not set! Call .as_alice() (or similar) before creating a repo.")?;
+

+
        let uuid = Uuid::new_v4()
+
            .to_string()
+
            .chars()
+
            .take(6)
+
            .collect::<String>();
+
        let repo_name = format!("{}-{}-{}", base_name, author, uuid);
+

+
        let author_esc = escape_sh(author);
+
        let email_esc = escape_sh(email);
+
        let desc_esc = escape_sh(desc);
+

+
        let script = format!(
+
            "mkdir -p {repo_name} && \
+
            cd {repo_name} && \
+
            git init && \
+
            git config user.name '{author_esc}' && \
+
            git config user.email '{email_esc}' && \
+
            echo 'commit 1' > file_1.txt && \
+
            git add file_1.txt && \
+
            git commit -m 'Commit 1' && \
+
            rad init --name {repo_name} --description '{desc_esc}' --public --no-confirm"
+
        );
+
        self.exec_sh(&script)?;
+

+
        if commits > 1 {
+
            for i in 2..=commits {
+
                let commit_script = format!(
+
                    "cd {repo_name} && \
+
                    echo 'commit {i}' > file_{i}.txt && \
+
                    git add file_{i}.txt && \
+
                    git commit -m 'Commit {i}' && \
+
                    git push rad master"
+
                );
+
                self.exec_sh(&commit_script)?;
+
            }
+
        }
+

+
        let rid = self.exec_sh(&format!("cd {} && rad . 2>/dev/null", repo_name))?;
+

+
        Ok(Repository {
+
            node: self.clone(),
+
            name: repo_name,
+
            rid,
+
        })
+
    }
+

+
    /// Waits until the Radicle node API is fully responsive.
+
    pub fn wait_until_responsive(&self) -> Result<(), String> {
+
        let mut retries = 30;
+
        while retries > 0 {
+
            if self.exec(&["rad", "node", "status"]).is_ok() {
+
                return Ok(());
+
            }
+
            retries -= 1;
+
            thread::sleep(Duration::from_secs(1));
+
        }
+
        Err(format!("Node {} did not become responsive", self.name))
+
    }
+
}
+

+
/// Represents a Radicle repository residing on a specific node.
+
///
+
/// This struct provides ergonomic, high-level methods for interacting with
+
/// the repository, such as syncing, managing issues, and handling patches.
+
/// All commands executed through this struct are automatically run within
+
/// the repository's directory on the node.
+
#[derive(Clone, Debug)]
+
pub struct Repository<E = KubectlExecutor> {
+
    /// The node where this repository resides.
+
    pub node: Node<E>,
+
    /// The local directory name of the repository.
+
    pub name: String,
+
    /// The Radicle ID (RID) of the repository.
+
    pub rid: String,
+
}
added simulation/radicle-simulation/tests/build_tests.rs
@@ -0,0 +1,5 @@
+
//
+
// Load build.rs as a module so the tests can be ran
+
//
+
#[path = "../build.rs"]
+
mod build;
added simulation/radicle-simulation/tests/cross_version_suite/mod.rs
@@ -0,0 +1 @@
+
pub mod repository_creation;
added simulation/radicle-simulation/tests/cross_version_suite/repository_creation.rs
@@ -0,0 +1,35 @@
+
use crate::network;
+
use crate::require_network;
+
use radicle_simulation::node::Node;
+
use rstest::rstest;
+

+
// TODO(Ade): The `negX` numbering is horrible... need a better naming convention.
+
#[rstest]
+
#[case::v_neg4_single_commit(network::peer_relative(-4, 0), 1)]
+
#[case::v_neg4_multiple_commits(network::peer_relative(-4, 0), 3)]
+
#[case::v_neg3_single_commit(network::peer_relative(-3, 0), 1)]
+
#[case::v_neg3_multiple_commits(network::peer_relative(-3, 0), 3)]
+
#[case::v_neg2_single_commit(network::peer_relative(-2, 0), 1)]
+
#[case::v_neg2_multiple_commits(network::peer_relative(-2, 0), 3)]
+
#[case::v_neg1_single_commit(network::peer_relative(-1, 0), 1)]
+
#[case::v_neg1_multiple_commits(network::peer_relative(-1, 0), 3)]
+
#[case::v_current_single_commit(network::peer_relative(0, 0), 1)]
+
#[case::v_current_multiple_commits(network::peer_relative(0, 0), 3)]
+
fn initializes_valid_repositories_with_single_and_multiple_commits(
+
    #[case] mut node: Node,
+
    #[case] commits: u32,
+
) -> Result<(), String> {
+
    let _guard = require_network();
+

+
    node = node.as_alice()?;
+

+
    let repo = node.init_test_repo("Repo", "Test Repo", commits)?;
+

+
    assert!(
+
        repo.rid.starts_with("rad:"),
+
        "Expected valid RID, got: {}",
+
        repo.rid
+
    );
+

+
    Ok(())
+
}
added simulation/radicle-simulation/tests/cross_version_suite_main.rs
@@ -0,0 +1,7 @@
+
use radicle_simulation::setup_network;
+

+
// Sets up the network once for the whole suite.
+
// It generates `crate::network` and `crate::require_network`.
+
setup_network!("basic_cross_version_network");
+

+
mod cross_version_suite;