1212import json
1313import os
1414import secrets
15+ import shutil
1516import signal
1617import socket
1718import subprocess
@@ -107,7 +108,9 @@ def submit(
107108 raise RuntimeError (f"client returned invalid submission JSON: { output !r} " ) from error
108109
109110
110- def configure_runner (runner : Path , root : Path , server_url : str , token : str ) -> None :
111+ def configure_runner (
112+ runner : Path , root : Path , server_url : str , token : str , name : str
113+ ) -> None :
111114 env = os .environ .copy ()
112115 env ["PRELOOP_SYSTEM_TOKEN" ] = token
113116 command_output (
@@ -121,12 +124,12 @@ def configure_runner(runner: Path, root: Path, server_url: str, token: str) -> N
121124 "--token" ,
122125 "t" ,
123126 "--name" ,
124- "runner-light-local" ,
127+ name ,
125128 "--unattended" ,
126129 "--replace" ,
127130 "--no-externals" ,
128131 "--labels" ,
129- "self-hosted,Linux,X64 " ,
132+ "self-hosted,mitm " ,
130133 ],
131134 env = env ,
132135 )
@@ -175,11 +178,31 @@ def scenario_workflow(scenario: Path) -> tuple[Path, int]:
175178 return workflow , int (metadata .get ("duration_seconds_max" , 300 ))
176179
177180
181+ def prepare_scenario_workspace (
182+ scenario : Path , temp_root : Path
183+ ) -> tuple [Path , Path , int ]:
184+ """Expose fixture-local reusable workflows through the client convention."""
185+ workflow , duration = scenario_workflow (scenario )
186+ workflows = scenario / "workflows"
187+ if not workflows .is_dir ():
188+ return workflow , scenario , duration
189+
190+ workspace = temp_root / "workspaces" / scenario .name
191+ shutil .copytree (scenario , workspace )
192+ github_workflows = workspace / ".github" / "workflows"
193+ github_workflows .mkdir (parents = True , exist_ok = True )
194+ for reusable in workflows .iterdir ():
195+ if reusable .is_file ():
196+ shutil .copy2 (reusable , github_workflows / reusable .name )
197+
198+ return workspace / workflow .relative_to (scenario ), workspace , duration
199+
200+
178201def wait_for_run (
179202 server_url : str ,
180203 token : str ,
181204 run_id : str ,
182- runner : subprocess .Popen [str ],
205+ runners : list [ subprocess .Popen [str ] ],
183206 timeout_seconds : int ,
184207) -> dict [str , Any ]:
185208 deadline = time .monotonic () + timeout_seconds
@@ -188,10 +211,11 @@ def wait_for_run(
188211 last = http_json (f"{ server_url } /api/v1/runs/{ run_id } " , token )
189212 if last .get ("conclusion" ) is not None :
190213 return last
191- if runner .poll () is not None :
192- raise RuntimeError (
193- f"runner exited with { runner .returncode } before run { run_id } completed"
194- )
214+ for runner in runners :
215+ if runner .poll () is not None :
216+ raise RuntimeError (
217+ f"runner exited with { runner .returncode } before run { run_id } completed"
218+ )
195219 time .sleep (1 )
196220 raise RuntimeError (
197221 f"run { run_id } did not complete within { timeout_seconds } s; last={ last } "
@@ -223,14 +247,23 @@ def official_workflows(path: Path) -> set[str]:
223247 return names
224248
225249
226- def scenario_names (root : Path , official : Path ) -> list [str ]:
250+ def scenario_names (
251+ root : Path , official : Path , exclude_prefixes : list [str ]
252+ ) -> list [str ]:
227253 import tomllib
228254
229- names = official_workflows (official )
255+ names = {
256+ name
257+ for name in official_workflows (official )
258+ if not any (name .startswith (prefix ) for prefix in exclude_prefixes )
259+ }
230260 # Include every checked-in scenario that has exactly one runnable
231261 # submit_workflow step. Other manifests are setup-only fixtures and cannot
232262 # be submitted by this harness.
233263 for manifest in root .glob ("*/scenario.toml" ):
264+ name = manifest .parent .name
265+ if any (name .startswith (prefix ) for prefix in exclude_prefixes ):
266+ continue
234267 metadata = tomllib .loads (manifest .read_text ())
235268 steps = metadata .get ("steps" , [])
236269 submit_steps = [
@@ -239,24 +272,39 @@ def scenario_names(root: Path, official: Path) -> list[str]:
239272 if isinstance (step , dict ) and step .get ("kind" ) == "submit_workflow"
240273 ]
241274 if len (submit_steps ) == 1 :
242- names .add (manifest . parent . name )
275+ names .add (name )
243276 return sorted (names )
244-
245277def main () -> int :
246278 parser = argparse .ArgumentParser (description = __doc__ )
247279 parser .add_argument ("--server-binary" , type = Path , required = True )
248280 parser .add_argument ("--runner-binary" , type = Path , required = True )
249281 parser .add_argument ("--client-binary" , type = Path , required = True )
250- parser .add_argument ("--scenarios-root" , type = Path , default = Path ("experiments/mitm/scenarios" ))
282+ parser .add_argument (
283+ "--scenarios-root" ,
284+ type = Path ,
285+ default = Path ("experiments/mitm/scenarios" ),
286+ )
251287 parser .add_argument ("--official" , type = Path , required = True )
252288 parser .add_argument ("--output" , type = Path , required = True )
253289 parser .add_argument ("--scenario" , action = "append" , dest = "scenarios" )
290+ parser .add_argument (
291+ "--exclude-prefix" ,
292+ action = "append" ,
293+ default = [],
294+ help = "exclude discovered scenarios whose name starts with this prefix" ,
295+ )
254296 args = parser .parse_args ()
255297
256298 if args .scenarios :
257- scenarios = args .scenarios
299+ scenarios = [
300+ name
301+ for name in args .scenarios
302+ if not any (name .startswith (prefix ) for prefix in args .exclude_prefix )
303+ ]
258304 else :
259- scenarios = scenario_names (args .scenarios_root , args .official )
305+ scenarios = scenario_names (
306+ args .scenarios_root , args .official , args .exclude_prefix
307+ )
260308 missing = [
261309 name
262310 for name in scenarios
@@ -280,9 +328,18 @@ def main() -> int:
280328 temp_root = Path (temp )
281329 server_state = temp_root / "server-state"
282330 server_log = temp_root / "server.log"
283- runner_root = temp_root / "runner"
284- runner_log = temp_root / "runner.log"
331+ runner_roots = [ temp_root / f "runner- { index } " for index in range ( 4 )]
332+ runner_logs = [ temp_root / f "runner- { index } .log" for index in range ( 4 )]
285333 env = os .environ .copy ()
334+ # The cpane runner exports these for jobs executed inside smolVMs.
335+ # This harness runs the runner directly on the host, so inheriting the
336+ # guest bridge origin makes it try to bind cpane's fixed 9090 port.
337+ for key in (
338+ "PRELOOP_CONTROL_ORIGIN" ,
339+ "PRELOOP_CONTROL_SOCKET" ,
340+ "PRELOOP_CONTROL_UPSTREAM" ,
341+ ):
342+ env .pop (key , None )
286343 env .update (
287344 {
288345 "PRELOOP_PUBLIC_URL" : server_url ,
@@ -307,29 +364,42 @@ def main() -> int:
307364 stderr = subprocess .STDOUT ,
308365 text = True ,
309366 )
310- runner : subprocess .Popen [str ] | None = None
367+ runners : list [ subprocess .Popen [str ]] = []
311368 try :
312369 wait_for_health (server_url , server , server_log )
313- configure_runner (args .runner_binary , runner_root , server_url , token )
314- with runner_log .open ("w" ) as log_file :
315- runner = subprocess .Popen (
316- [str (args .runner_binary ), "--runner-root" , str (runner_root ), "run" ],
317- env = env ,
318- stdout = log_file ,
319- stderr = subprocess .STDOUT ,
320- text = True ,
370+ for index , (root , log_path ) in enumerate (zip (runner_roots , runner_logs )):
371+ configure_runner (
372+ args .runner_binary ,
373+ root ,
374+ server_url ,
375+ token ,
376+ f"runner-light-local-{ index } " ,
321377 )
378+ with log_path .open ("w" ) as log_file :
379+ runners .append (
380+ subprocess .Popen (
381+ [str (args .runner_binary ), "--runner-root" , str (root ), "run" ],
382+ env = env ,
383+ stdout = log_file ,
384+ stderr = subprocess .STDOUT ,
385+ text = True ,
386+ )
387+ )
322388
323389 records : list [dict [str , Any ]] = []
324390 for scenario_name in scenarios :
325391 scenario = args .scenarios_root / scenario_name
326- workflow , duration = scenario_workflow (scenario )
327- run_id = submit (args .client_binary , server_url , token , workflow , scenario )
392+ workflow , workspace , duration = prepare_scenario_workspace (
393+ scenario , temp_root
394+ )
395+ run_id = submit (
396+ args .client_binary , server_url , token , workflow , workspace
397+ )
328398 run = wait_for_run (
329399 server_url ,
330400 token ,
331401 run_id ,
332- runner ,
402+ runners ,
333403 max (180 , duration + 60 ),
334404 )
335405 result = semantic_result (run )
@@ -346,12 +416,13 @@ def main() -> int:
346416 args .output .write_text ("\n " .join (json .dumps (record ) for record in records ) + "\n " )
347417 except Exception :
348418 print (f"runner-light: failure artifacts at { temp_root } " , file = sys .stderr )
349- for log in (server_log , runner_log ):
419+ for log in (server_log , * runner_logs ):
350420 if log .exists ():
351421 print (log .read_text (errors = "replace" ), file = sys .stderr )
352422 raise
353423 finally :
354- stop_process (runner )
424+ for runner in runners :
425+ stop_process (runner )
355426 stop_process (server )
356427
357428 print (f"runner-light: wrote { len (scenarios )} records to { args .output } " )
0 commit comments