@@ -124,6 +124,9 @@ def _discover_host_addresses() -> tuple[str, ...]:
124124HOST_ADDRESS_CANDIDATES = _discover_host_addresses ()
125125LAST_PORT_SUCCESSES : dict [int , str ] = {}
126126
127+ # Test project name prefixes to clean up before runs
128+ _TEST_PROJECT_PREFIXES = ("netalertx-missing" , "netalertx-normal" , "netalertx-custom" , "netalertx-host" , "netalertx-ram" , "netalertx-dataloss" )
129+
127130pytestmark = [pytest .mark .docker , pytest .mark .compose ]
128131
129132IMAGE = os .environ .get ("NETALERTX_TEST_IMAGE" , "netalertx-test" )
@@ -400,6 +403,9 @@ def _run_docker_compose(
400403 post_up : Callable [[], None ] | None = None ,
401404) -> subprocess .CompletedProcess :
402405 """Run docker compose up and capture output."""
406+ # Clear global port tracking to prevent cross-test state pollution
407+ LAST_PORT_SUCCESSES .clear ()
408+
403409 cmd = [
404410 "docker" , "compose" ,
405411 "-f" , str (compose_file ),
@@ -441,7 +447,7 @@ def _run_docker_compose(
441447
442448 # Ensure no stale containers from previous runs; always clean before starting.
443449 subprocess .run (
444- cmd + ["down" , "-v" ],
450+ cmd + ["down" , "-v" , "--remove-orphans" ],
445451 cwd = compose_file .parent ,
446452 stdout = sys .stdout ,
447453 stderr = sys .stderr ,
@@ -450,6 +456,17 @@ def _run_docker_compose(
450456 env = env ,
451457 )
452458
459+ # Also clean up any orphaned containers from previous test runs with similar project prefixes
460+ for prefix in _TEST_PROJECT_PREFIXES :
461+ if project_name .startswith (prefix ):
462+ subprocess .run (
463+ ["docker" , "container" , "prune" , "-f" , "--filter" , f"label=com.docker.compose.project={ project_name } " ],
464+ stdout = subprocess .DEVNULL ,
465+ stderr = subprocess .DEVNULL ,
466+ check = False ,
467+ )
468+ break
469+
453470 def _run_with_conflict_retry (run_cmd : list [str ], run_timeout : int ) -> subprocess .CompletedProcess :
454471 retry_conflict = True
455472 while True :
@@ -484,6 +501,41 @@ def _run_with_conflict_retry(run_cmd: list[str], run_timeout: int) -> subprocess
484501 post_up_exc : BaseException | None = None
485502 skip_exc : Skipped | None = None
486503
504+ def _collect_logs_with_retry (max_attempts : int = 3 , wait_between : float = 2.0 ) -> subprocess .CompletedProcess :
505+ """Collect logs with retry to handle timing races where container hasn't flushed output yet."""
506+ logs_cmd = cmd + ["logs" ]
507+ best_result : subprocess .CompletedProcess | None = None
508+ # Initialize with a safe default in case loop doesn't run
509+ logs_result = subprocess .CompletedProcess (logs_cmd , 1 , stdout = "" , stderr = "No log attempts made" )
510+ for attempt in range (max_attempts ):
511+ print (f"Running logs cmd (attempt { attempt + 1 } /{ max_attempts } ): { logs_cmd } " )
512+ logs_result = subprocess .run (
513+ logs_cmd ,
514+ cwd = compose_file .parent ,
515+ stdout = subprocess .PIPE ,
516+ stderr = subprocess .PIPE ,
517+ text = True ,
518+ timeout = timeout + 5 ,
519+ check = False ,
520+ env = env ,
521+ )
522+ print (logs_result .stdout ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
523+ print (logs_result .stderr ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
524+
525+ combined = (logs_result .stdout or "" ) + (logs_result .stderr or "" )
526+ # Keep the result with the most content
527+ if best_result is None or len (combined ) > len ((best_result .stdout or "" ) + (best_result .stderr or "" )):
528+ best_result = logs_result
529+
530+ # If we see the expected startup marker, logs are complete
531+ if "Startup pre-checks" in combined or "NETALERTX_CHECK_ONLY" in combined :
532+ break
533+
534+ if attempt < max_attempts - 1 :
535+ time .sleep (wait_between )
536+
537+ return best_result or logs_result
538+
487539 try :
488540 if detached :
489541 up_result = _run_with_conflict_retry (up_cmd , timeout )
@@ -496,20 +548,7 @@ def _run_with_conflict_retry(run_cmd: list[str], run_timeout: int) -> subprocess
496548 except BaseException as exc : # noqa: BLE001 - bubble the root cause through the result payload
497549 post_up_exc = exc
498550
499- logs_cmd = cmd + ["logs" ]
500- print (f"Running logs cmd: { logs_cmd } " )
501- logs_result = subprocess .run (
502- logs_cmd ,
503- cwd = compose_file .parent ,
504- stdout = subprocess .PIPE ,
505- stderr = subprocess .PIPE ,
506- text = True ,
507- timeout = timeout ,
508- check = False ,
509- env = env ,
510- )
511- print (logs_result .stdout ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
512- print (logs_result .stderr ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
551+ logs_result = _collect_logs_with_retry ()
513552
514553 result = subprocess .CompletedProcess (
515554 up_cmd ,
@@ -520,20 +559,7 @@ def _run_with_conflict_retry(run_cmd: list[str], run_timeout: int) -> subprocess
520559 else :
521560 up_result = _run_with_conflict_retry (up_cmd , timeout + 10 )
522561
523- logs_cmd = cmd + ["logs" ]
524- print (f"Running logs cmd: { logs_cmd } " )
525- logs_result = subprocess .run (
526- logs_cmd ,
527- cwd = compose_file .parent ,
528- stdout = subprocess .PIPE ,
529- stderr = subprocess .PIPE ,
530- text = True ,
531- timeout = timeout + 10 ,
532- check = False ,
533- env = env ,
534- )
535- print (logs_result .stdout ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
536- print (logs_result .stderr ) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI.
562+ logs_result = _collect_logs_with_retry ()
537563
538564 result = subprocess .CompletedProcess (
539565 up_cmd ,
@@ -640,7 +666,7 @@ def _run_with_conflict_retry(run_cmd: list[str], run_timeout: int) -> subprocess
640666 # additional attributes (`output`, `post_up_error`, etc.). Overwriting it
641667 # caused callers to see a CompletedProcess without `output` -> AttributeError.
642668 subprocess .run (
643- ["docker" , "compose" , "-f" , str (compose_file ), "-p" , project_name , "down" , "-v" ],
669+ ["docker" , "compose" , "-f" , str (compose_file ), "-p" , project_name , "down" , "-v" , "--remove-orphans" ],
644670 cwd = compose_file .parent ,
645671 stdout = sys .stdout ,
646672 stderr = sys .stderr ,
@@ -649,6 +675,14 @@ def _run_with_conflict_retry(run_cmd: list[str], run_timeout: int) -> subprocess
649675 env = env ,
650676 )
651677
678+ # Prune any dangling volumes from this project to prevent state leakage
679+ subprocess .run (
680+ ["docker" , "volume" , "prune" , "-f" , "--filter" , f"label=com.docker.compose.project={ project_name } " ],
681+ stdout = subprocess .DEVNULL ,
682+ stderr = subprocess .DEVNULL ,
683+ check = False ,
684+ )
685+
652686 for proc in audit_streams :
653687 try :
654688 proc .terminate ()
@@ -751,7 +785,6 @@ def _wait_for_unwritable_failure() -> None:
751785 assert "unable to write to /tmp/nginx/active-config/netalertx.conf" in lowered_output
752786
753787
754-
755788def test_host_network_compose (tmp_path : pathlib .Path ) -> None :
756789 """Test host networking mode using docker compose.
757790
0 commit comments