fix: run molecule pairs sequentially within each CI runner
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (2) (pull_request) Failing after 3m8s
CI / molecule-tests (0) (pull_request) Failing after 3m15s
CI / molecule-tests (1) (pull_request) Failing after 3m18s

Parallel molecule execution within a single runner caused conflicts
(shared temp directories, Docker network collisions). Rewrote
molecule_ci_guard.py to run pairs sequentially while still polling
the Gitea API for cross-runner cancellation.

Each pair now gets its own subprocess with proper environment setup
(MOLECULE_PLATFORM_NAME/IMAGE/COMMAND), and output streams directly
to CI logs for debugging.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Emil Simeonov
2026-06-20 23:53:13 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 6ef38631fc
commit c271293b2d
2 changed files with 261 additions and 105 deletions
+91 -38
View File
@@ -1,20 +1,29 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run molecule tests while polling Gitea for other runner failures. """Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe).
A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
Usage: Usage:
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ... python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access. REPO_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
OWNER, REPO Repository owner/name. GITEA_REPOSITORY Repository in "owner/repo" format.
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import os import os
import signal import signal
import subprocess # nosec B404 import subprocess # nosec B404
@@ -51,7 +60,6 @@ def any_other_runner_failed(
name = job.get("name", "") name = job.get("name", "")
if not name.startswith(current_job_name): if not name.startswith(current_job_name):
continue continue
# Skip the current runner instance
if name == f"{current_job_name} ({current_index})" or name == current_job_name: if name == f"{current_job_name} ({current_index})" or name == current_job_name:
continue continue
if job.get("conclusion") == "failure": if job.get("conclusion") == "failure":
@@ -87,10 +95,32 @@ def poll_for_other_failures(
stop_event.wait(POLL_INTERVAL) stop_event.wait(POLL_INTERVAL)
def build_molecule_cmd(scenario: str) -> list[str]:
"""Build the molecule command for a scenario."""
cmd = ["molecule", "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
return cmd
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair."""
scenario, platform_name, platform_image, platform_command = pair.split("|")
env = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
if platform_command:
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
return env
@click.command() @click.command()
@click.argument("pairs", nargs=-1, required=True) @click.argument("pairs", nargs=-1, required=True)
def cli(pairs: tuple[str, ...]) -> None: def cli(pairs: tuple[str, ...]) -> None:
"""Run molecule pairs and stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("REPO_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0")) run_id = int(os.environ.get("RUN_ID", "0"))
@@ -109,21 +139,11 @@ def cli(pairs: tuple[str, ...]) -> None:
) )
repo_root = Path(__file__).resolve().parent.parent repo_root = Path(__file__).resolve().parent.parent
script_path = repo_root / "scripts" / "run_molecule_parallel.py" role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
env = os.environ.copy() base_env = os.environ.copy()
env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock") base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
env.setdefault("ANSIBLE_INJECT_INVOCATION", "1") base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
click.echo(_("Starting molecule runner with {count} pairs.", count=len(pairs)))
process = subprocess.Popen( # nosec B603
[sys.executable, str(script_path), *pairs],
env=env,
stdout=None,
stderr=None,
preexec_fn=os.setsid,
)
stop_event = threading.Event() stop_event = threading.Event()
failed_event = threading.Event() failed_event = threading.Event()
@@ -147,26 +167,59 @@ def cli(pairs: tuple[str, ...]) -> None:
poller.start() poller.start()
try: try:
while process.poll() is None: for pair in pairs:
if failed_event.is_set(): if failed_event.is_set():
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
sys.exit(1) sys.exit(1)
time.sleep(1)
scenario = pair.split("|")[0]
platform_name = pair.split("|")[1]
click.echo(
_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)
)
cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env)
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
preexec_fn=os.setsid,
)
try:
while process.poll() is None:
if failed_event.is_set():
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
sys.exit(1)
rc = process.returncode
if rc != 0:
click.echo(
_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)
)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed."))
finally: finally:
stop_event.set() stop_event.set()
sys.exit(process.returncode) sys.exit(0)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+170 -67
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os import os
import subprocess # nosec B404 import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -11,6 +12,8 @@ import requests
from scripts.molecule_ci_guard import ( from scripts.molecule_ci_guard import (
any_other_runner_failed, any_other_runner_failed,
build_env_for_pair,
build_molecule_cmd,
cli, cli,
get_running_jobs, get_running_jobs,
poll_for_other_failures, poll_for_other_failures,
@@ -65,6 +68,32 @@ class TestAnyOtherRunnerFailed:
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
class TestBuildMoleculeCmd:
def test_default_scenario(self) -> None:
assert build_molecule_cmd("default") == ["molecule", "test"]
def test_named_scenario(self) -> None:
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
class TestBuildEnvForPair:
def test_with_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
def test_without_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_without_command_removes_existing(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
class TestPollForOtherFailures: class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None: def test_sets_failed_event_when_other_runner_fails(self) -> None:
stop_event = MagicMock() stop_event = MagicMock()
@@ -122,13 +151,12 @@ class TestPollForOtherFailures:
class TestCli: class TestCli:
def test_runs_without_api_env(self) -> None: def test_all_pass(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
env = {"PATH": os.environ.get("PATH", "")}
with ( with (
patch.dict(os.environ, env, clear=True),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
): ):
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = 0 proc.poll.return_value = 0
@@ -138,8 +166,26 @@ class TestCli:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_exits_when_other_runner_fails(self) -> None: def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "FAILED" in result.output
def test_exits_before_starting_when_already_failed(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
with ( with (
@@ -156,19 +202,85 @@ class TestCli:
}, },
clear=True, clear=True,
), ),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("os.killpg") as mock_killpg, patch("time.sleep"),
patch("os.getpgid") as mock_getpgid,
): ):
mock_getpgid.return_value = 123
mock_get_jobs.return_value = [ mock_get_jobs.return_value = [
{"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"}, {"name": "molecule-tests (1)", "conclusion": "failure"},
] ]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "Another molecule runner failed" in result.output
def test_keyboard_interrupt_kills_process(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep", side_effect=KeyboardInterrupt),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = None proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc mock_popen.return_value = proc
runner = CliRunner() runner = CliRunner()
@@ -179,6 +291,8 @@ class TestCli:
def test_default_owner_repo_fallback(self) -> None: def test_default_owner_repo_fallback(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
real_sleep = time.sleep
with ( with (
patch.dict( patch.dict(
os.environ, os.environ,
@@ -193,8 +307,10 @@ class TestCli:
}, },
clear=True, clear=True,
), ),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
): ):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}] mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock() proc = MagicMock()
@@ -206,69 +322,47 @@ class TestCli:
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_sleeps_while_waiting_for_process(self) -> None:
from click.testing import CliRunner
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep") as mock_sleep,
):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock()
proc.poll.side_effect = [None, 0]
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
mock_sleep.assert_called()
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None: def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
with ( real_sleep = time.sleep
patch.dict( call_count = [0]
os.environ,
{ def get_jobs_side_effect(*args, **kwargs):
"GITEA_URL": "https://gitea.example", call_count[0] += 1
"REPO_TOKEN": "token", if call_count[0] < 2:
"RUN_ID": "123", return [{"name": "molecule-tests (1)", "conclusion": "running"}]
"JOB_NAME": "molecule-tests", return [
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
mock_get_jobs.return_value = [
{"name": "molecule-tests (0)", "conclusion": "running"}, {"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"}, {"name": "molecule-tests (1)", "conclusion": "failure"},
] ]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = None proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc mock_popen.return_value = proc
runner = CliRunner() runner = CliRunner()
@@ -278,6 +372,18 @@ class TestCli:
def test_exits_when_other_runner_fails_with_timeout(self) -> None: def test_exits_when_other_runner_fails_with_timeout(self) -> None:
from click.testing import CliRunner from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with ( with (
patch.dict( patch.dict(
os.environ, os.environ,
@@ -292,18 +398,15 @@ class TestCli:
}, },
clear=True, clear=True,
), ),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg, patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")] mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
mock_get_jobs.return_value = [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
proc = MagicMock() proc = MagicMock()
proc.poll.return_value = None proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)] proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]