mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Merge branch 'maint'
Carries today's maint batch: the local CI image-scoping work, which gives each run its own build context and test image tag instead of writing the shared mutable one, and the retirement of the bloom-storm chaos scenario. Both apply unchanged here — the compose files, suite lists and matrix legs they touch are identical on the two lines, so no branch adaptation was needed. Parity stays symmetric across runners at 21 legs a side on this branch and 24 on maint, the gap being the three rekey Docker suites maint keeps by design.
This commit is contained in:
@@ -65,6 +65,8 @@ jobs:
|
||||
run: python3 testing/check-log-strings.py
|
||||
- name: Check no tested function's exit status is a log call's
|
||||
run: python3 testing/check-trailing-log.py
|
||||
- name: Check nothing resolves the shared mutable test image
|
||||
run: bash testing/check-image-scoping.sh
|
||||
# Hermetic: synthetic ping functions, no containers, ~45s. Lives beside
|
||||
# the other two so both runners gate on it identically — putting it in
|
||||
# only one would create exactly the drift check-ci-parity.sh exists to
|
||||
@@ -438,9 +440,6 @@ jobs:
|
||||
- suite: congestion-stress
|
||||
type: chaos
|
||||
scenario: congestion-stress
|
||||
- suite: bloom-storm
|
||||
type: chaos
|
||||
scenario: bloom-storm
|
||||
# ── Sidecar deployment ──────────────────────────────────────────
|
||||
- suite: sidecar
|
||||
type: sidecar
|
||||
|
||||
@@ -33,6 +33,11 @@ __pycache__/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Per-run build contexts created by testing/ci-local.sh. Its teardown normally
|
||||
# removes them, but the CI worker's SIGKILL runs no trap, so one can survive a
|
||||
# preempted run; ci-cleanup.sh sweeps the survivors.
|
||||
/testing/docker-*/
|
||||
|
||||
# Runtime artifacts from running fips in-tree during local testing.
|
||||
# Root-anchored so legitimately-tracked fips.yaml under packaging/ and
|
||||
# examples/ stays included.
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! Ethernet transport integration tests.
|
||||
//!
|
||||
//! Tests that the Ethernet transport works end-to-end using veth pairs.
|
||||
//! All tests require root or CAP_NET_RAW and are marked `#[ignore]`.
|
||||
|
||||
use super::*;
|
||||
use crate::config::EthernetConfig;
|
||||
use crate::transport::ethernet::EthernetTransport;
|
||||
use crate::transport::{TransportAddr, TransportHandle, TransportId, packet_channel};
|
||||
use spanning_tree::{TestNode, cleanup_nodes, drain_all_packets, initiate_handshake};
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// Atomic counter for unique veth names across tests.
|
||||
static VETH_COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// RAII wrapper for a veth pair.
|
||||
///
|
||||
/// Creates a pair of connected virtual Ethernet interfaces. Destroying
|
||||
/// one end automatically destroys the other.
|
||||
struct VethPair {
|
||||
name_a: String,
|
||||
name_b: String,
|
||||
}
|
||||
|
||||
impl VethPair {
|
||||
/// Create a new veth pair with unique interface names.
|
||||
///
|
||||
/// Names are kept under 15 chars (IFNAMSIZ limit). Format: `ftXXa`/`ftXXb`
|
||||
/// where XX is an atomic counter combined with PID for cross-process uniqueness.
|
||||
fn create() -> Self {
|
||||
let id = VETH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let pid = std::process::id() % 10000;
|
||||
let name_a = format!("ft{}{}a", pid, id);
|
||||
let name_b = format!("ft{}{}b", pid, id);
|
||||
|
||||
assert!(name_a.len() <= 15, "veth name too long: {}", name_a);
|
||||
assert!(name_b.len() <= 15, "veth name too long: {}", name_b);
|
||||
|
||||
// Create veth pair
|
||||
let status = Command::new("ip")
|
||||
.args([
|
||||
"link", "add", &name_a, "type", "veth", "peer", "name", &name_b,
|
||||
])
|
||||
.status()
|
||||
.expect("failed to run 'ip link add'");
|
||||
assert!(status.success(), "failed to create veth pair");
|
||||
|
||||
// Bring both ends up
|
||||
let status = Command::new("ip")
|
||||
.args(["link", "set", &name_a, "up"])
|
||||
.status()
|
||||
.expect("failed to run 'ip link set up'");
|
||||
assert!(status.success(), "failed to bring up {}", name_a);
|
||||
|
||||
let status = Command::new("ip")
|
||||
.args(["link", "set", &name_b, "up"])
|
||||
.status()
|
||||
.expect("failed to run 'ip link set up'");
|
||||
assert!(status.success(), "failed to bring up {}", name_b);
|
||||
|
||||
VethPair { name_a, name_b }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VethPair {
|
||||
fn drop(&mut self) {
|
||||
// Deleting one end destroys both
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "delete", &self.name_a])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a test node with a live Ethernet transport on the given interface.
|
||||
///
|
||||
/// Parallel to `make_test_node()` in spanning_tree.rs but uses
|
||||
/// EthernetTransport instead of UDP.
|
||||
async fn make_test_node_ethernet(interface: &str) -> TestNode {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
|
||||
let config = EthernetConfig {
|
||||
interface: interface.to_string(),
|
||||
listen: Some(false),
|
||||
announce: Some(false),
|
||||
accept_connections: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (packet_tx, packet_rx) = packet_channel(256);
|
||||
let mut transport = EthernetTransport::new(transport_id, None, config, packet_tx);
|
||||
transport.start_async().await.unwrap();
|
||||
|
||||
let mac = transport
|
||||
.local_mac()
|
||||
.expect("transport should have MAC after start");
|
||||
let addr = TransportAddr::from_bytes(&mac);
|
||||
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Ethernet(transport));
|
||||
|
||||
TestNode {
|
||||
node,
|
||||
transport_id,
|
||||
packet_rx: spanning_tree::bridge_to_unbounded(packet_rx),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two nodes on a veth pair complete a Noise handshake and establish peering.
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_ethernet_two_node_handshake() {
|
||||
let veth = VethPair::create();
|
||||
|
||||
let mut nodes = vec![
|
||||
make_test_node_ethernet(&veth.name_a).await,
|
||||
make_test_node_ethernet(&veth.name_b).await,
|
||||
];
|
||||
|
||||
// Initiate handshake from node 0 to node 1
|
||||
initiate_handshake(&mut nodes, 0, 1).await;
|
||||
|
||||
// Drain all packets (handshake + tree announce)
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0, "should have processed packets");
|
||||
|
||||
// Verify bidirectional peering
|
||||
let addr_0 = *nodes[0].node.node_addr();
|
||||
let addr_1 = *nodes[1].node.node_addr();
|
||||
assert!(
|
||||
nodes[0].node.get_peer(&addr_1).is_some(),
|
||||
"node 0 should have node 1 as peer"
|
||||
);
|
||||
assert!(
|
||||
nodes[1].node.get_peer(&addr_0).is_some(),
|
||||
"node 1 should have node 0 as peer"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Two Ethernet nodes converge to a correct spanning tree (2-node tree).
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_ethernet_data_exchange() {
|
||||
use spanning_tree::verify_tree_convergence;
|
||||
|
||||
let veth = VethPair::create();
|
||||
|
||||
let mut nodes = vec![
|
||||
make_test_node_ethernet(&veth.name_a).await,
|
||||
make_test_node_ethernet(&veth.name_b).await,
|
||||
];
|
||||
|
||||
initiate_handshake(&mut nodes, 0, 1).await;
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0);
|
||||
|
||||
// Verify spanning tree convergence
|
||||
verify_tree_convergence(&nodes);
|
||||
|
||||
// The root should be the node with the smallest NodeAddr
|
||||
let expected_root = std::cmp::min(*nodes[0].node.node_addr(), *nodes[1].node.node_addr());
|
||||
assert_eq!(*nodes[0].node.tree_state().root(), expected_root);
|
||||
assert_eq!(*nodes[1].node.tree_state().root(), expected_root);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Mixed transport: 2 Ethernet nodes + 2 UDP nodes coexist.
|
||||
///
|
||||
/// Each transport forms its own connected component. Validates that
|
||||
/// `process_available_packets()` handles heterogeneous transport types.
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_mixed_transport_coexistence() {
|
||||
use spanning_tree::{make_test_node, verify_tree_convergence_components};
|
||||
|
||||
let veth = VethPair::create();
|
||||
|
||||
// Create 2 Ethernet nodes and 2 UDP nodes
|
||||
let eth_0 = make_test_node_ethernet(&veth.name_a).await;
|
||||
let eth_1 = make_test_node_ethernet(&veth.name_b).await;
|
||||
let udp_0 = make_test_node().await;
|
||||
let udp_1 = make_test_node().await;
|
||||
|
||||
let mut nodes = vec![eth_0, eth_1, udp_0, udp_1];
|
||||
|
||||
// Handshake within each component
|
||||
initiate_handshake(&mut nodes, 0, 1).await; // Ethernet pair
|
||||
initiate_handshake(&mut nodes, 2, 3).await; // UDP pair
|
||||
|
||||
// Drain all packets across both transports
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0);
|
||||
|
||||
// Verify each component converges independently
|
||||
verify_tree_convergence_components(&nodes, &[vec![0, 1], vec![2, 3]]);
|
||||
|
||||
// Ethernet component has its own root
|
||||
let eth_root = std::cmp::min(*nodes[0].node.node_addr(), *nodes[1].node.node_addr());
|
||||
assert_eq!(*nodes[0].node.tree_state().root(), eth_root);
|
||||
assert_eq!(*nodes[1].node.tree_state().root(), eth_root);
|
||||
|
||||
// UDP component has its own root
|
||||
let udp_root = std::cmp::min(*nodes[2].node.node_addr(), *nodes[3].node.node_addr());
|
||||
assert_eq!(*nodes[2].node.tree_state().root(), udp_root);
|
||||
assert_eq!(*nodes[3].node.tree_state().root(), udp_root);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
@@ -15,8 +15,6 @@ mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
mod establish_chartests;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ethernet;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
mod heartbeat;
|
||||
|
||||
+15
-4
@@ -16,8 +16,6 @@ configurations.
|
||||
| ----------- | ----- | --------- | -------------------------------- |
|
||||
| mesh | 5 | UDP | Sparse mesh, 6 links, multi-hop |
|
||||
| chain | 5 | UDP | Linear chain, max 4-hop paths |
|
||||
| mesh-public | 5+1 | UDP | Mesh with external public node |
|
||||
| tcp-chain | 3 | TCP | Linear chain over TCP (port 8443) |
|
||||
| rekey | 5 | UDP | Rekey integration test topology |
|
||||
|
||||
### [tor/](tor/) -- Tor Transport Integration
|
||||
@@ -95,8 +93,21 @@ flight) never collide:
|
||||
- **Compose projects** are named `fipsci_<run-id>_<suite>`, so
|
||||
container, network, and volume names are all prefixed per run.
|
||||
- **Build images** are tagged `fips-test:<run-id>` and
|
||||
`fips-test-app:<run-id>` (exported as `FIPS_TEST_IMAGE` /
|
||||
`FIPS_TEST_APP_IMAGE` for the compose consumers).
|
||||
`fips-test-app:<run-id>`, exported as `FIPS_TEST_IMAGE` /
|
||||
`FIPS_TEST_APP_IMAGE`, and **every** compose file and suite script reads
|
||||
those. The run does not write `fips-test:latest` at all: a bridge back to
|
||||
that shared mutable name would let a consumer that had been missed keep
|
||||
working while resolving whichever concurrent run wrote the tag last.
|
||||
`:latest` stays the hand-build name, produced by
|
||||
`testing/scripts/build.sh`, and remains the default every consumer falls
|
||||
back to when the variables are unset.
|
||||
- **The build context** is a per-run copy at `testing/docker-<run-id>/`,
|
||||
exported as `FIPS_BUILD_CONTEXT`. It is absolute because compose resolves
|
||||
a relative build context against the compose file's own directory rather
|
||||
than the working directory. `testing/docker/` is the hand-run context and
|
||||
a CI run does not write to it. Without this, two runs race on the contents
|
||||
of one directory and either can build a correctly-per-run-tagged image
|
||||
from the other's binaries.
|
||||
- Each parallel chaos child gets a unique, non-overlapping `/24` in
|
||||
`10.30.x` (via the sim `--subnet` override). `10.30.x` sits outside
|
||||
Docker's default address pool and the fixed-subnet suites' `172.x`
|
||||
|
||||
@@ -19,8 +19,12 @@ networks:
|
||||
|
||||
x-fips-common: &fips-common
|
||||
build:
|
||||
context: ../docker
|
||||
image: fips-test:latest
|
||||
# The harness scopes its build context per run and passes it here; the
|
||||
# shared directory is the hand-run default. Compose resolves a relative
|
||||
# value against THIS file's directory, so the harness must export an
|
||||
# absolute path.
|
||||
context: ${FIPS_BUILD_CONTEXT:-../docker}
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
entrypoint: ["/usr/local/bin/entrypoint.sh"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
@@ -185,7 +185,15 @@ log "Generating ACL allowlist fixtures"
|
||||
|
||||
log "Starting ACL allowlist harness"
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
# --build only on the hand path. Under a harness, --skip-build means the caller
|
||||
# has already built the image this compose file names, and rebuilding it here
|
||||
# would overwrite that image from whatever the shared build context happens to
|
||||
# hold — which is how a suite ends up certifying binaries it was never given.
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
fi
|
||||
|
||||
log "Waiting for expected peer convergence"
|
||||
wait_for_peers_exact fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 3 40
|
||||
|
||||
@@ -67,19 +67,12 @@ Explicit topologies exercising non-UDP transports.
|
||||
| ------------- | ----- | -------------- | ----- | -------- | ----- | ---------- | ------------------------------------------ |
|
||||
| ethernet-only | 4 | Ethernet | Ring | 90s | yes | -- | AF_PACKET transport with beacon discovery |
|
||||
| ethernet-mesh | 6 | UDP + Ethernet | Mesh | 120s | yes | yes | Mixed UDP/Ethernet, netem mutation + flaps |
|
||||
| tcp-only | 4 | TCP | Ring | 90s | yes | -- | TCP transport with static peer config |
|
||||
| tcp-chain | 4 | TCP | Chain | 90s | yes | -- | TCP multi-hop routing through chain |
|
||||
| tcp-mesh | 6 | UDP + TCP | Mesh | 120s | yes | yes | Mixed UDP/TCP, netem mutation + flaps |
|
||||
|
||||
- **ethernet-only**: 4-node ring on raw Ethernet (AF_PACKET). Peers discovered
|
||||
via beacons, not static config. Minimal netem (1-5ms delay).
|
||||
- **ethernet-mesh**: Mirrors `tcp-mesh` topology but with Ethernet instead of
|
||||
TCP. UDP edges use static config; Ethernet edges use beacon discovery.
|
||||
- **tcp-only**: 4-node ring using TCP on port 8443. Tests connect-on-send,
|
||||
FMP framing over TCP, and reconnection. Netem enabled (1-10ms delay, 0-1%
|
||||
loss).
|
||||
- **tcp-chain**: 4-node linear chain, all TCP. Tests multi-hop routing over
|
||||
TCP-only mesh.
|
||||
- **tcp-mesh**: 6-node mesh with 4 UDP and 3 TCP edges. Both transports use
|
||||
static peer config. Netem mutation (30% fraction, every 20-40s) and link
|
||||
flaps (1 link max, 10-20s down).
|
||||
|
||||
@@ -75,12 +75,37 @@ bandwidth:
|
||||
#
|
||||
# This one is calibrated rather than derived, because a scenario that
|
||||
# stops and starts nodes on purpose does not hold a single spanning
|
||||
# tree. The six provably-completed archived runs end with two or three
|
||||
# distinct roots, seven or eight of ten nodes parented, and fifteen to
|
||||
# twenty sessions. The floors below sit one step outside those ranges
|
||||
# so an unlucky run does not go red, which leaves them catching a mesh
|
||||
# that collapsed rather than one that churned. Tighten them only
|
||||
# against a larger sample than six runs.
|
||||
# tree.
|
||||
#
|
||||
# Calibrated 2026-07-26 against fourteen runs that recorded a baseline
|
||||
# verdict, read from their assertions.txt files. Every one used this
|
||||
# file's fixed seed 42 and therefore an identical chaos schedule, so the
|
||||
# spread below is container timing rather than differing scenarios:
|
||||
#
|
||||
# distinct roots 1 2 3 4 5 -> 2 3 5 3 1 runs
|
||||
# nodes parented 9 8 7 6 5 -> the exact complement, in all 14
|
||||
# sessions 12 to 20, minimum 12
|
||||
#
|
||||
# The previous ceiling of 4 roots sat at roughly the 93rd percentile of
|
||||
# that distribution: one run in fourteen exceeded it and four sat at or
|
||||
# above it, so it reddened a share of runs whatever the daemon did. It
|
||||
# was set from six runs whose observed maximum was 3, which is how a
|
||||
# threshold one step outside a small sample ends up inside the real one.
|
||||
#
|
||||
# The ceiling is now 6, one step beyond the observed maximum of 5, and
|
||||
# the parented floor is its complement at 4. That still fails a mesh
|
||||
# that collapsed — seven or more of ten nodes islanded — which is the
|
||||
# only thing this assertion was ever meant to catch. Do not read a pass
|
||||
# as convergence: four of the six gating scenarios assert a floor of
|
||||
# this kind, and it means the mesh formed and nobody errored.
|
||||
#
|
||||
# min_sessions stays at 10 against an observed minimum of 12. It has
|
||||
# never fired and there is no evidence it is mis-set, but the margin is
|
||||
# thin and a future failure there should be read as calibration before
|
||||
# it is read as a defect.
|
||||
#
|
||||
# Tighten any of these only against a larger sample, and against one
|
||||
# gathered at the invocation CI actually runs.
|
||||
#
|
||||
# READ THIS BEFORE RETUNING: the numbers above describe the invocation CI
|
||||
# gates on, which is not this file's own defaults. ci-local runs it as
|
||||
@@ -98,8 +123,8 @@ bandwidth:
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 10
|
||||
max_roots: 4
|
||||
min_nodes_parented: 6
|
||||
max_roots: 6
|
||||
min_nodes_parented: 4
|
||||
min_sessions: 10
|
||||
|
||||
logging:
|
||||
|
||||
@@ -50,10 +50,11 @@ traffic:
|
||||
# A 4-node mesh forms a spanning tree: one root and three nodes
|
||||
# with a parent. All six provably-completed archived runs show exactly
|
||||
# that, so these are the shape of a converged mesh rather than a
|
||||
# tolerance fitted to observations. This is currently the only
|
||||
# assertion covering Ethernet transport anywhere in CI: the three
|
||||
# tests in src/node/tests/ethernet.rs are #[ignore]d for requiring
|
||||
# CAP_NET_RAW and neither runner passes --ignored.
|
||||
# tolerance fitted to observations. This is the only assertion covering
|
||||
# Ethernet transport anywhere in CI, and it covers the control plane
|
||||
# only: traffic is disabled above, so no datagram crosses an Ethernet
|
||||
# link in any test. Framing, the length field that trims NIC minimum-
|
||||
# frame padding, and AEAD over Ethernet are all unexercised as a result.
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 4
|
||||
|
||||
@@ -125,7 +125,11 @@ if ! docker info &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOCKER_DIR="$CHAOS_DIR/../docker"
|
||||
# A harness that scopes its build context per run passes it in
|
||||
# FIPS_BUILD_CONTEXT and stops writing to the shared directory, so checking the
|
||||
# shared one would either fail on a clean checkout or, worse, pass while
|
||||
# reading a stale binary that is not the one under test.
|
||||
DOCKER_DIR="${FIPS_BUILD_CONTEXT:-$CHAOS_DIR/../docker}"
|
||||
if [ ! -f "$DOCKER_DIR/fips" ]; then
|
||||
echo "Error: FIPS binary not found at $DOCKER_DIR/fips" >&2
|
||||
echo "Run testing/scripts/build.sh first" >&2
|
||||
|
||||
@@ -10,8 +10,13 @@ from .scenario import Scenario
|
||||
from .topology import SimTopology
|
||||
|
||||
# Image name for the pre-built FIPS test image.
|
||||
# The runner builds this once before starting containers.
|
||||
FIPS_SIM_IMAGE = "fips-test:latest"
|
||||
#
|
||||
# A harness that has already built an image passes it in FIPS_TEST_IMAGE, and
|
||||
# it is then the caller's image: the runner uses it and must not rebuild it.
|
||||
# Unset means a bare run, where the shared tag is the right name and the runner
|
||||
# still builds it. Read at import, which is safe because the simulation always
|
||||
# starts as a child process with the environment already set.
|
||||
FIPS_SIM_IMAGE = os.environ.get("FIPS_TEST_IMAGE", "fips-test:latest")
|
||||
|
||||
# Jinja2 template for the compose file.
|
||||
# Uses a pre-built image instead of per-service build to support large topologies.
|
||||
|
||||
@@ -268,10 +268,30 @@ class SimRunner:
|
||||
)
|
||||
log.info("Wrote %s", self.compose_file)
|
||||
|
||||
# 4. Build the test image once (avoids per-service build at scale)
|
||||
log.info("Building Docker image...")
|
||||
# 4. Obtain the test image (once, rather than per-service at scale).
|
||||
#
|
||||
# Building it is right for a bare run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the image belongs to the caller, and every
|
||||
# scenario of a parallel run would otherwise rebuild it into one shared
|
||||
# name from one shared context — so assert it exists and fail loudly if
|
||||
# it does not, rather than manufacture a substitute nobody asked for.
|
||||
from .compose import FIPS_SIM_IMAGE
|
||||
docker_dir = os.path.join(os.path.dirname(__file__), "..", "..", "docker")
|
||||
if os.environ.get("FIPS_TEST_IMAGE"):
|
||||
log.info("Using caller-supplied image %s", FIPS_SIM_IMAGE)
|
||||
probe = subprocess.run(
|
||||
["docker", "image", "inspect", FIPS_SIM_IMAGE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"FIPS_TEST_IMAGE names {FIPS_SIM_IMAGE}, which is not present. "
|
||||
"The harness that set it is expected to have built it."
|
||||
)
|
||||
else:
|
||||
log.info("Building Docker image...")
|
||||
docker_dir = os.environ.get("FIPS_BUILD_CONTEXT") or os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "docker"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", FIPS_SIM_IMAGE, docker_dir],
|
||||
check=True,
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
# ── Test-image scoping guard ────────────────────────────────────────────────
|
||||
# A local CI run builds fips-test:<run-id> and hands it to every suite through
|
||||
# FIPS_TEST_IMAGE / FIPS_TEST_APP_IMAGE. It does NOT write fips-test:latest,
|
||||
# deliberately: while a bridge back to that shared mutable name existed, a
|
||||
# consumer that named it directly kept working while resolving whichever
|
||||
# concurrent run wrote the tag last, and the verdict was then recorded against
|
||||
# a commit whose binaries had not run. Nothing in the harness compares a
|
||||
# running container's binary against the commit under test, so that failure is
|
||||
# silent and leaves no artifact.
|
||||
#
|
||||
# The bridge is gone, so a consumer that names the shared tag now fails loudly
|
||||
# at run time. This guard is the static half: it stops one being reintroduced,
|
||||
# because the reintroduction is invisible on any host where a hand build has
|
||||
# left an fips-test:latest lying around.
|
||||
#
|
||||
# What counts as a violation: a reference to the shared tag that is neither a
|
||||
# comment, nor a documented default of the ${FIPS_TEST_IMAGE:-...} form, nor in
|
||||
# a file on the allowlist below.
|
||||
#
|
||||
# Exit 0 = clean. Exit 1 = an unexpected reference. Exit 2 = the guard could
|
||||
# not run; never treated as a pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Files permitted to name the shared tag outright, each for a stated reason.
|
||||
# Every one of these is a hand-run path: none is reachable from ci-local.sh
|
||||
# with FIPS_TEST_IMAGE set.
|
||||
#
|
||||
# scripts/build.sh the developer build; :latest IS its product
|
||||
# sidecar/scripts/test-sidecar.sh hand build, behind its --skip-build guard,
|
||||
# which ci-local always passes
|
||||
# static/scripts/iperf-compare-refs.sh hand-run A/B against two refs
|
||||
# ci-cleanup.sh last-resort name for the image ip(8) runs in,
|
||||
# after the caller's and the run's have failed
|
||||
# check-image-scoping.sh this guard, which has to name what it looks
|
||||
# for; it resolves no image
|
||||
ALLOWED=(
|
||||
"scripts/build.sh"
|
||||
"sidecar/scripts/test-sidecar.sh"
|
||||
"static/scripts/iperf-compare-refs.sh"
|
||||
"ci-cleanup.sh"
|
||||
"check-image-scoping.sh"
|
||||
)
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "check-image-scoping: git not available, cannot sweep" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$SCRIPT_DIR" ]]; then
|
||||
echo "check-image-scoping: $SCRIPT_DIR missing" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Tracked files only, and no documentation: prose naming the tag is describing
|
||||
# it, not resolving it.
|
||||
mapfile -t files < <(git -C "$SCRIPT_DIR/.." ls-files -- testing/ | grep -vE '\.md$')
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "check-image-scoping: no tracked files under testing/, refusing to pass" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
violations=0
|
||||
for f in "${files[@]}"; do
|
||||
rel="${f#testing/}"
|
||||
skip=0
|
||||
for a in "${ALLOWED[@]}"; do
|
||||
[[ "$rel" == "$a" ]] && skip=1 && break
|
||||
done
|
||||
[[ $skip -eq 1 ]] && continue
|
||||
[[ -f "$SCRIPT_DIR/../$f" ]] || continue
|
||||
|
||||
while IFS= read -r hit; do
|
||||
n="${hit%%:*}"
|
||||
text="${hit#*:}"
|
||||
# A comment line is describing the tag, not resolving it. Shell, python
|
||||
# and yaml all use #; nothing under testing/ uses // for comments.
|
||||
[[ "$text" =~ ^[[:space:]]*# ]] && continue
|
||||
# The documented indirection: the shared tag as a FALLBACK, which is
|
||||
# what a bare hand run is supposed to get.
|
||||
[[ "$text" == *'${FIPS_TEST_IMAGE:-fips-test:latest}'* ]] && continue
|
||||
[[ "$text" == *'${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}'* ]] && continue
|
||||
[[ "$text" == *'os.environ.get("FIPS_TEST_IMAGE", "fips-test:latest")'* ]] && continue
|
||||
echo "FAIL $f:$n names the shared test image directly:"
|
||||
echo " $text"
|
||||
violations=$((violations + 1))
|
||||
done < <(grep -n 'fips-test:latest\|fips-test-app:latest' "$SCRIPT_DIR/../$f" 2>/dev/null)
|
||||
done
|
||||
|
||||
if [[ $violations -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "check-image-scoping: $violations reference(s) to the shared mutable test image."
|
||||
echo "Read FIPS_TEST_IMAGE (default \${FIPS_TEST_IMAGE:-fips-test:latest}) instead."
|
||||
echo "A run that resolves the shared tag can execute a concurrent run's binaries"
|
||||
echo "and record the verdict against this commit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check-image-scoping: no unscoped references to the shared test image"
|
||||
exit 0
|
||||
+48
-5
@@ -66,9 +66,15 @@ RUN_ID="" # broad default: every CI run
|
||||
IMAGES=""
|
||||
VETH_SUFFIXES="" # empty AND no --run-id: every simulation veth name
|
||||
# ip(8) runs inside this image, the same way the simulation creates the
|
||||
# interfaces, so the reap works wherever the simulation does. The chaos
|
||||
# simulation builds it, and it carries iproute2.
|
||||
VETH_IMAGE="fips-test:latest"
|
||||
# interfaces, so the reap works wherever the simulation does. Any fips test
|
||||
# image will do; it is wanted only for its iproute2.
|
||||
#
|
||||
# Empty here and resolved after the argument loop, because the resolution has
|
||||
# to consider --veth-image. The old default of fips-test:latest is no longer
|
||||
# safe on its own: ci-local.sh does not write that tag, so on a host that has
|
||||
# only ever run the harness it need not exist at all, and the reap this script
|
||||
# advertises as the remedy for orphaned interfaces would be a permanent no-op.
|
||||
VETH_IMAGE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -77,11 +83,26 @@ while [[ $# -gt 0 ]]; do
|
||||
--run-id) RUN_ID="$2"; shift 2 ;;
|
||||
--images) IMAGES="$2"; shift 2 ;;
|
||||
--veth-suffixes) VETH_SUFFIXES="$2"; shift 2 ;;
|
||||
--veth-image) VETH_IMAGE="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,/^set /{ /^set /d; s/^# \?//; p }' "$0"; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the image to run ip(8) in: the caller's choice, then the run's own
|
||||
# image, then any surviving fips test image. That last fallback is what keeps
|
||||
# an unscoped `ci-local.sh --reap` working — it execs this script from inside
|
||||
# its own argument loop, before the run identity is exported, so it can pass
|
||||
# neither. The empty case is handled at the point of use, which already warns
|
||||
# and skips rather than failing the sweep.
|
||||
if [[ -z "$VETH_IMAGE" ]]; then
|
||||
VETH_IMAGE="${FIPS_TEST_IMAGE:-}"
|
||||
fi
|
||||
if [[ -z "$VETH_IMAGE" ]] && command -v docker >/dev/null 2>&1; then
|
||||
VETH_IMAGE="$(timeout 10 docker image ls --format '{{.Repository}}:{{.Tag}}' fips-test 2>/dev/null | head -n1)"
|
||||
fi
|
||||
[[ -z "$VETH_IMAGE" ]] && VETH_IMAGE="fips-test:latest"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
# No docker, nothing to reap.
|
||||
exit 0
|
||||
@@ -217,8 +238,8 @@ reap_veths() {
|
||||
[[ -z "$pattern" ]] && return 0
|
||||
# Without the image there is no way to run ip(8). Orphans can outlive it —
|
||||
# `docker image prune -a`, a build host that prunes between runs, or a run
|
||||
# aborted before ci-local.sh retags :latest all remove it while interfaces
|
||||
# are still up — so this is a real skip, not "nothing was ever run here".
|
||||
# whose per-run image was already reaped all remove it while interfaces are
|
||||
# still up — so this is a real skip, not "nothing was ever run here".
|
||||
if ! docker image inspect "$VETH_IMAGE" >/dev/null 2>&1; then
|
||||
veth_warn "image $VETH_IMAGE not present, cannot run ip(8)"
|
||||
return 0
|
||||
@@ -247,6 +268,27 @@ reap_images() {
|
||||
timeout "$TMO" docker rmi -f "${imgs[@]}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Per-run build contexts left in the working tree. ci-local.sh removes its own
|
||||
# from the EXIT trap, but the CI worker sends SIGKILL after SIGTERM and a
|
||||
# SIGKILL runs no trap, so a preempted run can leave an 18 MB directory behind
|
||||
# with nothing else that would ever notice it.
|
||||
#
|
||||
# Scoped mode takes only the named run's. Broad mode cannot tell a live run's
|
||||
# context from an abandoned one by name, so it goes by age instead: a run lasts
|
||||
# well under an hour, and a day is far outside that.
|
||||
reap_build_contexts() {
|
||||
local dir
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
dir="$SCRIPT_DIR/docker-$RUN_ID"
|
||||
[[ -d "$dir" ]] && rm -rf "$dir"
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r dir; do
|
||||
[[ -n "$dir" ]] && rm -rf "$dir"
|
||||
done < <(find "$SCRIPT_DIR" -maxdepth 1 -type d -name 'docker-*' -mtime +0 2>/dev/null)
|
||||
return 0
|
||||
}
|
||||
|
||||
# Order matters: containers reference networks/volumes, so drop them first, and
|
||||
# the veth sweep needs an image to run ip(8) in, so it precedes the image reap.
|
||||
reap_containers
|
||||
@@ -254,5 +296,6 @@ reap_networks
|
||||
reap_volumes
|
||||
reap_veths
|
||||
reap_images
|
||||
reap_build_contexts
|
||||
|
||||
exit 0
|
||||
|
||||
+74
-13
@@ -31,7 +31,6 @@
|
||||
# nat-lan, nostr-publish-consume, stun-faults,
|
||||
# chaos-churn-mixed-10, chaos-ethernet-mesh,
|
||||
# chaos-ethernet-only, chaos-tcp-mesh, chaos-congestion-stress,
|
||||
# chaos-bloom-storm,
|
||||
# sidecar, dns-resolver, deb-install
|
||||
#
|
||||
# Opt-in (require --with-tor; depend on live Tor network):
|
||||
@@ -152,7 +151,6 @@ CHAOS_SUITES=(
|
||||
"ethernet-only ethernet-only"
|
||||
"tcp-mesh tcp-mesh"
|
||||
"congestion-stress congestion-stress"
|
||||
"bloom-storm bloom-storm"
|
||||
)
|
||||
# Scenarios retired 2026-07-23 because their subject was a pure decision the
|
||||
# Docker harness could not test reliably, now covered by sans-IO unit tests:
|
||||
@@ -175,6 +173,24 @@ CHAOS_SUITES=(
|
||||
# plus end-to-end datagram delivery (src/node/tests/forwarding.rs). Real-
|
||||
# UDP convergence smoke still runs via static-mesh and the other chaos
|
||||
# scenarios' baseline assertions, so no Docker coverage is lost.
|
||||
#
|
||||
# Retired 2026-07-26 for a third reason — the guard's power was never
|
||||
# established, and unlike the two blocks above this one DOES lose coverage:
|
||||
# - bloom-storm: guarded the regression in 0caef2a (fixed by 4cdf382), where
|
||||
# a mid-chain tree update changing neither root nor depth leaked downstream
|
||||
# as a sustained bloom announce storm. It was never once run against that
|
||||
# regressed binary; its ceiling was inferred from a separate post-mortem
|
||||
# harness that no longer exists in the tree. On the only regressed
|
||||
# measurement that survives, the tail node's rate scales to ~7 sends per
|
||||
# 30s, well under the scenario's ceiling of 40 — so it is not established
|
||||
# that the assertion could ever have fired on its own bug class. The
|
||||
# ceiling is also uniform per node, calibrated against the flap target
|
||||
# (legitimately ~24) while the storm's actual signature is at the tail,
|
||||
# which sits at 0 on fixed code. COVERAGE GAP: nothing now exercises
|
||||
# downstream containment of a mid-chain ancestor swap. The scenario, its
|
||||
# README, the link_swap sim primitive and the mesh-lab dispatch all stay
|
||||
# on disk and it remains runnable by hand via
|
||||
# testing/chaos/scripts/chaos.sh bloom-storm.
|
||||
GATEWAY_SUITES=(gateway)
|
||||
SIDECAR_SUITES=(sidecar)
|
||||
FIREWALL_SUITES=(firewall)
|
||||
@@ -288,8 +304,9 @@ record() {
|
||||
#
|
||||
# This script may be preempted (a CI worker sends SIGTERM, waits ~30s, then
|
||||
# SIGKILL) so it can restart on a newer tip. To make that safe:
|
||||
# * every docker resource is namespaced to THIS run (compose project prefix
|
||||
# + per-run image tags) so a restart never collides with a dying run;
|
||||
# * every docker resource is namespaced to THIS run (compose project prefix,
|
||||
# per-run image tags, per-run build context) so a restart never collides
|
||||
# with a dying run, and neither does a concurrent one;
|
||||
# * a trap tears down everything this run created on signal/exit, bounded by
|
||||
# `timeout` so a stuck `down` cannot wedge the trap (SIGKILL is the backstop).
|
||||
|
||||
@@ -326,6 +343,14 @@ CI_LABEL_RUN="com.corganlabs.fips-ci.run=${CI_RUN_ID}"
|
||||
export FIPS_CI_RUN_ID="$CI_RUN_ID"
|
||||
export FIPS_TEST_IMAGE="$CI_IMAGE_TEST"
|
||||
export FIPS_TEST_APP_IMAGE="$CI_IMAGE_APP"
|
||||
# The build context is this run's too. testing/docker/ is a single directory in
|
||||
# the working tree, so two runs racing on its CONTENTS produce a per-run-tagged
|
||||
# image built from the other run's binaries — scoping the tag alone does not
|
||||
# close that. Absolute, and it has to be: compose interpolates this into
|
||||
# build.context but resolves a relative result against the compose FILE's
|
||||
# directory, not the working directory.
|
||||
CI_BUILD_CONTEXT="$SCRIPT_DIR/docker-${CI_RUN_ID}"
|
||||
export FIPS_BUILD_CONTEXT="$CI_BUILD_CONTEXT"
|
||||
# Docker container names are GLOBAL — a compose project name does not scope
|
||||
# them — so the suite compose files append this suffix to every explicit
|
||||
# container_name, and the suite scripts append it wherever they address a
|
||||
@@ -413,6 +438,15 @@ ci_teardown() {
|
||||
rm -rf "$SCRIPT_DIR/static/generated-configs${CI_RUN_NAME_SUFFIX}"
|
||||
rm -rf "$SCRIPT_DIR/firewall/generated-configs${CI_RUN_NAME_SUFFIX}"
|
||||
fi
|
||||
|
||||
# 4. This run's build context. Unlike the generated configs it is removed
|
||||
# on a red run too: it holds the binaries and the Dockerfiles, both
|
||||
# reproducible from the commit, so it is never the evidence of a
|
||||
# failure. Guarded on the path having been derived at all, so an early
|
||||
# exit cannot turn this into `rm -rf $SCRIPT_DIR/docker-`.
|
||||
if [[ -n "${CI_BUILD_CONTEXT:-}" && "$CI_BUILD_CONTEXT" != "$SCRIPT_DIR/docker-" ]]; then
|
||||
rm -rf "$CI_BUILD_CONTEXT"
|
||||
fi
|
||||
}
|
||||
|
||||
on_signal() {
|
||||
@@ -786,24 +820,39 @@ run_tor_directory() {
|
||||
run_integration() {
|
||||
stage "Stage 3: Integration Tests"
|
||||
|
||||
# Install binaries to shared docker context
|
||||
# Populate THIS run's build context, then install the binaries into it.
|
||||
# Everything but the binaries is copied from the tracked context directory;
|
||||
# the binaries are installed fresh, and a previous run's are deliberately
|
||||
# not carried over, since inheriting them is the failure this scoping
|
||||
# exists to prevent.
|
||||
info "Preparing build context $CI_BUILD_CONTEXT"
|
||||
rm -rf "$CI_BUILD_CONTEXT"
|
||||
mkdir -p "$CI_BUILD_CONTEXT" || { record "docker-build" 1; return; }
|
||||
local _f
|
||||
for _f in "$SCRIPT_DIR"/docker/*; do
|
||||
case "$(basename "$_f")" in
|
||||
fips|fipsctl|fipstop|fips-gateway) continue ;;
|
||||
esac
|
||||
cp -a "$_f" "$CI_BUILD_CONTEXT/" || { record "docker-build" 1; return; }
|
||||
done
|
||||
|
||||
info "Installing release binaries"
|
||||
install_binaries testing/docker
|
||||
install_binaries "$CI_BUILD_CONTEXT"
|
||||
|
||||
# Build unified test image once (used by all harnesses). Tag per-run
|
||||
# (fips-test:${run}) so a build killed mid-flight never wedges the next
|
||||
# run's rebuild, and concurrent runs never clobber each other's image.
|
||||
# Then retag :latest for the compose files / harness scripts that still
|
||||
# reference fips-test:latest directly; the retag happens only after BOTH
|
||||
# builds succeed, so :latest never points at a half-built image.
|
||||
info "Building $CI_IMAGE_TEST Docker image"
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" --label "$CI_LABEL_RUN" testing/docker --quiet \
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" --label "$CI_LABEL_RUN" "$CI_BUILD_CONTEXT" --quiet \
|
||||
|| { record "docker-build" 1; return; }
|
||||
docker build -t "$CI_IMAGE_APP" --label "$CI_LABEL" --label "$CI_LABEL_RUN" \
|
||||
-f testing/docker/Dockerfile.app testing/docker --quiet \
|
||||
-f "$CI_BUILD_CONTEXT/Dockerfile.app" "$CI_BUILD_CONTEXT" --quiet \
|
||||
|| { record "docker-build-app" 1; return; }
|
||||
docker tag "$CI_IMAGE_TEST" fips-test:latest
|
||||
docker tag "$CI_IMAGE_APP" fips-test-app:latest
|
||||
# Deliberately NOT retagged to fips-test:latest. Every consumer reads
|
||||
# FIPS_TEST_IMAGE, and a bridge back to the shared mutable name would let a
|
||||
# consumer that does not keep working silently — resolving whichever
|
||||
# concurrent run wrote the tag last, and recording that run's binaries under
|
||||
# this run's commit. Without the bridge a missed consumer fails loudly.
|
||||
|
||||
# Single suite mode
|
||||
if [[ -n "$ONLY_SUITE" ]]; then
|
||||
@@ -1011,6 +1060,17 @@ run_ci_parity() {
|
||||
record "ci-parity" $rc
|
||||
}
|
||||
|
||||
# Nothing may resolve the shared mutable test image. This run does not write
|
||||
# fips-test:latest, so a consumer naming it fails loudly here and now — but only
|
||||
# on a host with no hand-built copy lying around, which is not a property to
|
||||
# rely on. This is the static half of that.
|
||||
run_image_scoping() {
|
||||
local rc=0
|
||||
info "[image-scoping] Checking that nothing names the shared test image"
|
||||
"$SCRIPT_DIR/check-image-scoping.sh" || rc=$?
|
||||
record "image-scoping" $rc
|
||||
}
|
||||
|
||||
# Every daemon log string a test matches on must still be emitted by src/.
|
||||
# A stale one does not fail — it stops observing, and an expect-zero assertion
|
||||
# built on it then passes for the wrong reason.
|
||||
@@ -1064,6 +1124,7 @@ main() {
|
||||
run_ci_parity
|
||||
run_log_strings
|
||||
run_trailing_log
|
||||
run_image_scoping
|
||||
run_wait_converge
|
||||
|
||||
if [[ "$TEST_ONLY" == true ]]; then
|
||||
|
||||
@@ -19,8 +19,12 @@ networks:
|
||||
|
||||
x-fips-common: &fips-common
|
||||
build:
|
||||
context: ../docker
|
||||
image: fips-test:latest
|
||||
# The harness scopes its build context per run and passes it here; the
|
||||
# shared directory is the hand-run default. Compose resolves a relative
|
||||
# value against THIS file's directory, so the harness must export an
|
||||
# absolute path.
|
||||
context: ${FIPS_BUILD_CONTEXT:-../docker}
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
entrypoint: ["/usr/local/bin/entrypoint.sh"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
@@ -191,7 +191,15 @@ log "Generating firewall fixtures"
|
||||
|
||||
log "Starting firewall harness"
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
# --build only on the hand path. Under a harness, --skip-build means the caller
|
||||
# has already built the image this compose file names, and rebuilding it here
|
||||
# would overwrite that image from whatever the shared build context happens to
|
||||
# hold — which is how a suite ends up certifying binaries it was never given.
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
fi
|
||||
|
||||
log "Waiting for fips0 on both nodes"
|
||||
wait_for_fips0 "$CONTAINER_A" 40
|
||||
|
||||
@@ -148,8 +148,11 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
# Session establishment
|
||||
if "Session established" in line:
|
||||
result.sessions_established.append((source, line))
|
||||
# Peer promotion
|
||||
if "Peer promoted to active" in line or "Outbound handshake completed" in line:
|
||||
# Peer promotion. Match only the info-level string. "Outbound handshake
|
||||
# completed" is emitted from handle_msg2 on the same call path for the
|
||||
# same promotion, so matching it too double-counted every outbound
|
||||
# promotion in any run at debug level.
|
||||
if "Peer promoted to active" in line:
|
||||
result.peers_promoted.append((source, line))
|
||||
# Peer removal
|
||||
if "Peer removed" in line:
|
||||
|
||||
@@ -135,9 +135,10 @@ require_docker() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "ERROR: fips-test:latest not present" >&2
|
||||
echo "Build it once with: bash testing/ci-local.sh --build-only" >&2
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if ! docker image inspect "$img" >/dev/null 2>&1; then
|
||||
echo "ERROR: $img not present" >&2
|
||||
echo "Build it once with: bash testing/scripts/build.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ volumes:
|
||||
relay-data:
|
||||
|
||||
x-fips-common: &fips-common
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
@@ -325,7 +325,7 @@ services:
|
||||
ipv4_address: 172.31.10.51
|
||||
|
||||
stun-fault-shim:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
profiles: ["stun-faults"]
|
||||
container_name: fips-nat-stun-fault-shim${FIPS_CI_NAME_SUFFIX:-}
|
||||
depends_on:
|
||||
|
||||
@@ -262,10 +262,21 @@ dump_lan_diagnostics() {
|
||||
trap 'echo ""; echo "NAT test interrupted"; cleanup; exit 130' INT TERM
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
require_docker_daemon() {
|
||||
|
||||
@@ -49,10 +49,21 @@ require_docker_daemon() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
dump_diagnostics() {
|
||||
|
||||
@@ -60,10 +60,21 @@ require_docker_daemon() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
dump_diagnostics() {
|
||||
|
||||
@@ -14,7 +14,7 @@ networks:
|
||||
|
||||
services:
|
||||
fips:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
hostname: fips-sidecar
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
ipv4_address: ${FIPS_IPV4:-172.20.1.20}
|
||||
|
||||
app:
|
||||
image: fips-test-app:latest
|
||||
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
network_mode: "service:fips"
|
||||
|
||||
@@ -115,7 +115,7 @@ trap cleanup EXIT
|
||||
|
||||
if [[ "${1:-}" != "--skip-build" ]]; then
|
||||
log "Building test images..."
|
||||
DOCKER_DIR="$(cd "$SIDECAR_DIR/../docker" && pwd)"
|
||||
DOCKER_DIR="${FIPS_BUILD_CONTEXT:-$(cd "$SIDECAR_DIR/../docker" && pwd)}"
|
||||
docker build -t fips-test:latest "$DOCKER_DIR"
|
||||
docker build -t fips-test-app:latest -f "$DOCKER_DIR/Dockerfile.app" "$DOCKER_DIR"
|
||||
fi
|
||||
|
||||
@@ -119,19 +119,13 @@ testing/static/
|
||||
│ └── topologies/
|
||||
│ ├── mesh.yaml # Mesh topology definition
|
||||
│ ├── chain.yaml # Chain topology definition
|
||||
│ ├── mesh-public.yaml # Mesh + external public node
|
||||
│ ├── tcp-chain.yaml # TCP chain (3 nodes, port 8443)
|
||||
│ └── rekey.yaml # Rekey integration test (5 nodes)
|
||||
├── generated-configs/ # Auto-generated, run-scoped (gitignored)
|
||||
│ ├── npubs.env # NPUB_A=..., NPUB_B=..., etc.
|
||||
│ ├── mesh/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ ├── mesh-public/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ ├── chain/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ └── tcp-chain/
|
||||
│ ├── node-a.yaml ... node-c.yaml
|
||||
│ └── chain/
|
||||
│ ├── node-a.yaml ... node-e.yaml
|
||||
├── scripts/
|
||||
│ ├── build.sh # Build binary + generate configs
|
||||
│ ├── generate-configs.sh # Generate node configs from topology
|
||||
@@ -219,7 +213,7 @@ each mesh needs unique node identities to avoid key conflicts. The optional
|
||||
|
||||
# Or generate configs directly
|
||||
./testing/static/scripts/generate-configs.sh mesh my-mesh-1
|
||||
./testing/static/scripts/generate-configs.sh mesh-public my-mesh-1
|
||||
./testing/static/scripts/generate-configs.sh chain my-mesh-1
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Mesh Topology Definition
|
||||
#
|
||||
# Five nodes with 6 bidirectional UDP links forming a sparse, fully connected
|
||||
# graph. Not all nodes are direct peers — non-adjacent pairs require
|
||||
# discovery-driven multi-hop routing to establish end-to-end sessions.
|
||||
|
||||
nodes:
|
||||
a:
|
||||
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
docker_ip: "172.20.0.10"
|
||||
peers: [d, e, pub]
|
||||
|
||||
b:
|
||||
nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
|
||||
npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
docker_ip: "172.20.0.11"
|
||||
peers: [c, pub]
|
||||
|
||||
c:
|
||||
nsec: "c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0"
|
||||
npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
docker_ip: "172.20.0.12"
|
||||
peers: [b, d, e, pub]
|
||||
|
||||
d:
|
||||
nsec: "d102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fd0"
|
||||
npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
docker_ip: "172.20.0.13"
|
||||
peers: [a, c, e]
|
||||
|
||||
e:
|
||||
nsec: "e102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fe0"
|
||||
npub: "npub1wf8akf8lu2zdkjkmwhl75pqvven654mpv4sz2x2tprl5265mgrzq8nhak4"
|
||||
docker_ip: "172.20.0.14"
|
||||
peers: [a, c, d]
|
||||
|
||||
# External/public node (not a Docker container)
|
||||
pub:
|
||||
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
external_ip: "test-us01.fips.network"
|
||||
peers: [a, b, c]
|
||||
|
||||
# Spanning Tree Structure (rooted at node A):
|
||||
# - A — D (tree edge, D's parent is A)
|
||||
# - A — E (tree edge, E's parent is A)
|
||||
# - C — D (tree edge, C's parent is D)
|
||||
# - B — C (tree edge, B's parent is C)
|
||||
# - D — E (non-tree link)
|
||||
# - C — E (non-tree link)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# TCP Chain Topology Definition
|
||||
#
|
||||
# Three nodes with TCP links forming a linear chain. Tests basic TCP
|
||||
# transport connectivity, spanning tree convergence, and multi-hop
|
||||
# routing over TCP. All peer connections use TCP on port 443.
|
||||
#
|
||||
# default_transport: tcp tells the config generator to use TCP
|
||||
# transport and port 443 instead of the default UDP/2121.
|
||||
|
||||
default_transport: tcp
|
||||
|
||||
nodes:
|
||||
a:
|
||||
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
docker_ip: "172.20.0.10"
|
||||
peers: [b]
|
||||
|
||||
b:
|
||||
nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
|
||||
npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
docker_ip: "172.20.0.11"
|
||||
peers: [a, c]
|
||||
|
||||
c:
|
||||
nsec: "c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0"
|
||||
npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
docker_ip: "172.20.0.12"
|
||||
peers: [b]
|
||||
@@ -98,62 +98,6 @@ services:
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
# ── Mesh-public topology (mesh + external public node) ────────
|
||||
pub-a:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh-public/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
pub-b:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh-public/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
pub-c:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh-public/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
pub-d:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh-public/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
pub-e:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh-public/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
# ── Chain topology (A-B-C-D-E) ────────────────────────────────
|
||||
chain-a:
|
||||
<<: *fips-common
|
||||
@@ -421,40 +365,6 @@ services:
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
# ── TCP chain topology (A-B-C) ───────────────────────────────
|
||||
tcp-a:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/tcp-chain/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
tcp-b:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/tcp-chain/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
tcp-c:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/tcp-chain/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
|
||||
# ── Gateway integration test (gateway + server + non-FIPS client) ─
|
||||
gw-gateway:
|
||||
<<: *fips-common
|
||||
|
||||
@@ -137,7 +137,7 @@ ping_path() {
|
||||
# steady state, which is what real-world FIPS deployments see. To
|
||||
# force on-wire multihop, isolate nodes on distinct docker networks.
|
||||
case "$PROFILE" in
|
||||
mesh|mesh-public)
|
||||
mesh)
|
||||
# Static-peer paths only — without mDNS / a Nostr relay in the
|
||||
# test container set, non-adjacent pairs (A↔B, A↔C) can't
|
||||
# establish direct UDP and the bench would either fail
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generate FIPS node configuration files from template and topology definition.
|
||||
#
|
||||
# Usage: ./generate-configs.sh <topology> [mesh-name]
|
||||
# topology: mesh, mesh-public, chain, etc.
|
||||
# topology: mesh, chain, etc.
|
||||
# mesh-name: optional; when given, docker node identities are derived
|
||||
# deterministically via sha256(mesh-name|node-id)
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ echo ""
|
||||
# nodes — labels DO NOT encode hop count, since peer discovery
|
||||
# converges every same-subnet pair onto a direct UDP path within a
|
||||
# few ticks regardless of the static `peers:` list.
|
||||
if [ "$PROFILE" = "mesh" ] || [ "$PROFILE" = "mesh-public" ]; then
|
||||
if [ "$PROFILE" = "mesh" ]; then
|
||||
echo "Topology (static \`peers:\` from mesh.yaml):"
|
||||
echo " A peers with: D, E"
|
||||
echo " B peers with: C"
|
||||
@@ -109,7 +109,7 @@ echo ""
|
||||
echo "Waiting ${SETTLE_SECONDS}s for mesh convergence..."
|
||||
sleep "$SETTLE_SECONDS"
|
||||
|
||||
if [ "$PROFILE" = "mesh" ] || [ "$PROFILE" = "mesh-public" ]; then
|
||||
if [ "$PROFILE" = "mesh" ]; then
|
||||
echo ""
|
||||
echo "Testing mesh topology paths:"
|
||||
iperf_test node-d node-a "$NPUB_D" "A→D"
|
||||
|
||||
@@ -93,7 +93,7 @@ if [ "$PROFILE" = "chain" ]; then
|
||||
wait_for_peers fips-node-c${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-d${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-e${FIPS_CI_NAME_SUFFIX:-} 1 20 || true
|
||||
elif [ "$PROFILE" = "mesh" ] || [ "$PROFILE" = "mesh-public" ]; then
|
||||
elif [ "$PROFILE" = "mesh" ]; then
|
||||
# Mesh: check all nodes reach their configured peer counts
|
||||
wait_for_peers fips-node-a${FIPS_CI_NAME_SUFFIX:-} 2 20 || true
|
||||
wait_for_peers fips-node-b${FIPS_CI_NAME_SUFFIX:-} 1 20 || true
|
||||
@@ -105,7 +105,7 @@ else
|
||||
# section below is guarded by these same profile names, so the script
|
||||
# ran no assertion at all and exited 0. A typo in the caller's profile
|
||||
# argument produced a green run that tested nothing.
|
||||
echo "ERROR: unknown profile '$PROFILE' (expected: chain, mesh, mesh-public)" >&2
|
||||
echo "ERROR: unknown profile '$PROFILE' (expected: chain, mesh)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Wait for full pairwise connectivity, progress-aware: the actual pings
|
||||
@@ -119,7 +119,7 @@ wait_until_connected ping_all_quiet 45 15 || true
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
if [ "$PROFILE" = "mesh" ] || [ "$PROFILE" = "mesh-public" ]; then
|
||||
if [ "$PROFILE" = "mesh" ]; then
|
||||
# Sparse mesh topology: A-B, B-C, C-D, D-E, E-A, A-D
|
||||
# Test all 20 directed pairs (5 nodes × 4 targets each)
|
||||
echo ""
|
||||
|
||||
@@ -16,7 +16,7 @@ networks:
|
||||
|
||||
services:
|
||||
fips-a:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
dir-test:
|
||||
|
||||
fips-b:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
tor-test:
|
||||
|
||||
fips-a:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
tor-test:
|
||||
|
||||
fips-b:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
|
||||
Reference in New Issue
Block a user