#!/usr/bin/env python3 """ Run integration-e2e inside the shadow network simulator. This can be helpful vs running it directly for several reasons. * shadow simulates time, and can collapse idle time. This speeds up the network bootstrapping step in particular. * shadow tries to be deterministic. There are some gaps, but in general there *should* be less nondeterministic flakiness under shadow than when running natively. """ import atexit import argparse import shadowtools.config as scfg import shadowtools.shadow_exec as shadow_exec import os import pathlib import subprocess import sys import yaml from pathlib import Path from chutney import TorNet from typing import Final import common from config import Config assert __name__ == "__main__", "Can't determine _SCRIPT_DIR" _SCRIPT_DIR = Path(sys.argv[0]).parent.resolve() def _shadow_config_path(n: TorNet.Network) -> Path: return n.dir.joinpath("shadow.yaml") def _integration_e2e(args: argparse.Namespace) -> None: """Run the test (from inside shadow)""" config = Config.load_json(Path(_SCRIPT_DIR).joinpath("arti.run.json")) config.export_env() # Try to ensure we tear down the network on exit, including failure, ctrl-c, etc. atexit.register(lambda: subprocess.check_call([_SCRIPT_DIR.joinpath("teardown")])) # bootstrap the network subprocess.check_call([config.chutney, "bootstrap"]) # test the network subprocess.check_call([_SCRIPT_DIR.joinpath("test"), "-v"]) def gen_shadow_config(*, seed: int, controller_hostname: str) -> scfg.Config: """ Generate a shadow config file, as a string, for the given parameters. """ current_script = os.path.abspath(__file__) env = { common.RUNNING_IN_SHADOW_ENV: "yes", # re-export PATH. The test scripts assume that # usual shell utilities are on it. "PATH": os.getenv("PATH", ""), } return scfg.Config( general=scfg.General( stop_time="10m", model_unblocked_syscall_latency=True, seed=seed, ), network=scfg.Network( graph=scfg.Graph(type="1_gbit_switch"), ), experimental=scfg.Experimental( # shadow only actually increments simulated time (and potentially # switches threads) if this much time would have been consumed by an # unbroken sequence of unblocked syscalls. Using a relatively large # value here (vs the default 1us) makes the simulation scheduling # more stable and predictable; e.g. adding additional logging to # debug an issue is less likely to make the issue disappear. # # The primary tradeoffs are: # * Larger values can result in managed processes measuring elapsed # time where not much happens as *zero*, which may not be handled # gracefully. e.g. in c-tor, using values of 1 ms or more here can # result in a flood of warnings "compute_drain_rate(): Bug: # Computing stream drain rate with zero time delta". # * Time will move forward at a larger granularity when unblocked syscall # latency is applied. 10ms is still small enough though that this # shouldn't be terribly strange; e.g. larger time jumps are likely # to be observed on over-loaded systems with normal preemptive # scheduling. # * when the simulation does hit a # busy loop, it may spend a bit longer "spinning" before moving # time forward, potentially causing the simulation to take a bit # longer to run. (if it would have otherwise timed out earlier than 10ms) max_unapplied_cpu_latency="100us", # In CI, attempting to pin to particular CPU cores may result in # conflicts with other instances of shadow trying to do the same # thing. use_cpu_pinning=False, # Likewise, shadow's default behavior of spin-looping is bad # behavior in a shared environment. use_worker_spinning=False, ), hosts={ controller_hostname: scfg.Host( network_node_id=0, processes=[ scfg.Process( path="sh", args=f"-c '{current_script} integration-e2e 2>&1'", environment=env, # Give the web server below a little time to start. start_time="5s", ) ], ), common.TEST_DOMAIN: scfg.Host( network_node_id=0, processes=[ scfg.Process( path="python3", args="-m http.server 80", start_time=0, expected_final_state="running", ) ], ), }, ) def _configure_and_run_shadow(args: argparse.Namespace) -> None: toplevel = pathlib.Path( os.fsdecode( subprocess.check_output("git rev-parse --show-toplevel", shell=True) ).strip() ) os.chdir(toplevel) # configure this test subprocess.check_call([_SCRIPT_DIR.joinpath("setup")]) config = Config.load_json(Path(_SCRIPT_DIR).joinpath("arti.run.json")) config.export_env() # initialize the network subprocess.check_call( [_SCRIPT_DIR.joinpath("init")], env=( os.environ | dict( # AF_UNIX sockets aren't supported in shadow CHUTNEY_ENABLE_CONTROLSOCKET="no", # ipv6 isn't supported in shadow CHUTNEY_DISABLE_IPV6="yes", # sandboxing isn't supported in shadow CHUTNEY_TOR_SANDBOX="no", ) ), ) # Load the network we just initialized network = TorNet.Network.from_data_dir() # Write out shadow config. We could just pipe it directly to the shadow # process below, but writing it out is useful for debugging. controller_hostname: Final = "host" shadow_config = gen_shadow_config( seed=args.seed, controller_hostname=controller_hostname ) with _shadow_config_path(network).open("w") as f: f.write(yaml.safe_dump(shadow_config)) shadow_exec.run_shadow_watching_process( watch_host=controller_hostname, shadow_bin=Path("shadow"), dstdir=network.dir, shadow_config_path=_shadow_config_path(network), ) def main() -> None: parser = argparse.ArgumentParser( prog="integration-e2e-shadow", description="Runs integration-e2e inside a shadow simulation", ) parser.add_argument( "-s", "--seed", type=int, default=1, help="Simulation PRNG seed" ) parser.set_defaults(func=_configure_and_run_shadow) subparsers = parser.add_subparsers() integration_e2e_parser = subparsers.add_parser( "integration-e2e", help=( "Run the e2e integration test, from inside shadow." " Intended only for this script to recursively launch itself inside shadow." ), ) integration_e2e_parser.set_defaults(func=_integration_e2e) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()