Skip to content

Commit 1bacb59

Browse files
committed
2 parents 827b5d2 + e70bbdb commit 1bacb59

2 files changed

Lines changed: 100 additions & 47 deletions

File tree

test/docker_tests/test_docker_compose_scenarios.py

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ def _discover_host_addresses() -> tuple[str, ...]:
124124
HOST_ADDRESS_CANDIDATES = _discover_host_addresses()
125125
LAST_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+
127130
pytestmark = [pytest.mark.docker, pytest.mark.compose]
128131

129132
IMAGE = 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-
755788
def test_host_network_compose(tmp_path: pathlib.Path) -> None:
756789
"""Test host networking mode using docker compose.
757790

test/docker_tests/test_docker_compose_unit.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,46 @@ def test_run_docker_compose_returns_output(monkeypatch, tmp_path):
1212
compose_file = tmp_path / "docker-compose.yml"
1313
compose_file.write_text("services: {}")
1414

15-
# Prepare a sequence of CompletedProcess objects to be returned by fake `run`
16-
cps = [
17-
subprocess.CompletedProcess([], 0, stdout="down-initial\n", stderr=""),
18-
subprocess.CompletedProcess(["up"], 0, stdout="up-out\n", stderr=""),
19-
subprocess.CompletedProcess(["logs"], 0, stdout="log-out\n", stderr=""),
20-
# ps_proc: return valid container entries
21-
subprocess.CompletedProcess(["ps"], 0, stdout="test-container Running 0\n", stderr=""),
22-
subprocess.CompletedProcess([], 0, stdout="down-final\n", stderr=""),
23-
]
24-
25-
def fake_run(*_, **__):
26-
try:
27-
return cps.pop(0)
28-
except IndexError:
29-
# Safety: return a harmless CompletedProcess
30-
return subprocess.CompletedProcess([], 0, stdout="", stderr="")
15+
# Track calls to identify what's being run
16+
call_log = []
17+
18+
def fake_run(cmd, *args, **kwargs):
19+
"""Return appropriate fake responses based on the command being run."""
20+
cmd_str = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
21+
call_log.append(cmd_str)
22+
23+
# Identify the command type and return appropriate response
24+
if "down" in cmd_str:
25+
return subprocess.CompletedProcess(cmd, 0, stdout="down-out\n", stderr="")
26+
elif "volume prune" in cmd_str:
27+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
28+
elif "container prune" in cmd_str:
29+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
30+
elif "ps" in cmd_str:
31+
# Return valid container entries with "Startup pre-checks" won't be here
32+
# but we need valid ps output
33+
return subprocess.CompletedProcess(cmd, 0, stdout="test-container Running 0\n", stderr="")
34+
elif "logs" in cmd_str:
35+
# Include "Startup pre-checks" so the retry logic exits early
36+
return subprocess.CompletedProcess(cmd, 0, stdout="log-out\nStartup pre-checks\n", stderr="")
37+
elif "up" in cmd_str:
38+
return subprocess.CompletedProcess(cmd, 0, stdout="up-out\n", stderr="")
39+
else:
40+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
3141

3242
# Monkeypatch subprocess.run used inside the module
3343
monkeypatch.setattr(mod.subprocess, "run", fake_run)
3444

45+
# Also patch subprocess.Popen for the audit stream (returns immediately terminating proc)
46+
class FakePopen:
47+
def __init__(self, *args, **kwargs):
48+
pass
49+
50+
def terminate(self):
51+
pass
52+
53+
monkeypatch.setattr(mod.subprocess, "Popen", FakePopen)
54+
3555
# Call under test
3656
result = mod._run_docker_compose(compose_file, "proj-test", timeout=1, detached=False)
3757

0 commit comments

Comments
 (0)