Files
Johnathan Corgan 9b7a27f219 Claim the chaos network range instead of deriving it, ending the collision
Two concurrent ci-local runs could not both run chaos. Each child's subnet came
from its position in the suite list, so both runs walked 10.30.0 through
10.30.12 and requested identical ranges; whichever reached the daemon first won
and the other died with a pool overlap. A run index or hashed offset would only
make that unlikely, and it is precisely the failure being removed.

The simulator now claims its range: attempt-create on a candidate, advance on
docker's own overlap error, fail loudly on anything else. Docker's address pool
becomes the arbiter, so an overlap is impossible rather than improbable. The
claim lives in the sim rather than in ci-local because only the process that
creates the network can advance on conflict, and because it fixes the bare
chaos.sh path too, which a ci-local-only fix would have left broken.

The generated compose now declares the network external over the claimed one,
and teardown releases the range from both paths, including the setup-failed
path where a run that fell over after claiming still holds one.

Ordering matters here and is not obvious: node IPs derive from the subnet
during topology generation, and traffic shaping keys its filters on those
addresses, so the claim happens before the topology exists. Claiming later
would give a network on one range and filters on another, which does not fail
at bring-up and instead leaves the shaping matching nothing.

--subnet survives as an explicit pin for when a known range is wanted, and now
fails loudly if that range is taken rather than silently overlapping. ci-local
no longer passes it.

Validated by running two instances of the same scenario deliberately
concurrently: they claimed 10.30.1.0/24 and 10.30.0.0/24, both exited 0 with
their assertions passing, and both released their networks. The negative
control holds too: with a squatter on a pinned range the run aborts with a
message naming the range rather than proceeding.
2026-07-23 17:09:07 +00:00

81 lines
2.5 KiB
Python

"""CLI entry point: python -m sim <scenario.yaml>"""
import argparse
import logging
import sys
from .runner import SimRunner
from .scenario import load_scenario
def main():
parser = argparse.ArgumentParser(
prog="sim",
description="FIPS stochastic network simulation",
)
parser.add_argument("scenario", help="Path to scenario YAML file")
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable debug logging"
)
parser.add_argument(
"--seed", type=int, default=None,
help="Override scenario seed",
)
parser.add_argument(
"--duration", type=int, default=None,
help="Override scenario duration in seconds",
)
parser.add_argument(
"--subnet", type=str, default=None,
help="Pin the topology subnet CIDR (e.g. 10.30.0.0/24) instead of "
"claiming a free one. The sim normally claims a range so "
"concurrent runs cannot collide; pin only when you need a known "
"range, and expect a hard failure if it is already taken.",
)
args = parser.parse_args()
level = logging.DEBUG if args.verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
try:
scenario = load_scenario(args.scenario)
except (FileNotFoundError, ValueError) as e:
print(f"Error loading scenario: {e}", file=sys.stderr)
sys.exit(1)
# Apply CLI overrides
if args.seed is not None:
scenario.seed = args.seed
if args.duration is not None:
if args.duration < 1:
print("Error: --duration must be >= 1", file=sys.stderr)
sys.exit(1)
scenario.duration_secs = args.duration
if args.subnet is not None:
# Pinning opts out of claiming. The runner still *creates* the network,
# so a range already in use fails loudly here rather than silently
# overlapping a concurrent run.
scenario.topology.pinned_subnet = args.subnet
runner = SimRunner(scenario)
result = runner.run()
# Liveness before content. A simulation that raised on its way up, or part
# way through, has no panic count or assertion outcome worth reporting, and
# saying so is more useful than whatever partial content it did produce.
if runner.aborted:
sys.exit(4)
if result and result.panics:
sys.exit(2)
if runner.assertions_failed:
sys.exit(3)
sys.exit(0)
if __name__ == "__main__":
main()