fix: run molecule pairs sequentially within each CI runner
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
6ef38631fc
commit
c271293b2d
@@ -1,20 +1,29 @@
|
||||
#!/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:
|
||||
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
OWNER, REPO Repository owner/name.
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess # nosec B404
|
||||
@@ -51,7 +60,6 @@ def any_other_runner_failed(
|
||||
name = job.get("name", "")
|
||||
if not name.startswith(current_job_name):
|
||||
continue
|
||||
# Skip the current runner instance
|
||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
||||
continue
|
||||
if job.get("conclusion") == "failure":
|
||||
@@ -87,10 +95,32 @@ def poll_for_other_failures(
|
||||
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.argument("pairs", nargs=-1, required=True)
|
||||
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", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
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
|
||||
script_path = repo_root / "scripts" / "run_molecule_parallel.py"
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
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,
|
||||
)
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
@@ -147,26 +167,59 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
poller.start()
|
||||
|
||||
try:
|
||||
while process.poll() is None:
|
||||
for pair in pairs:
|
||||
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)
|
||||
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:
|
||||
stop_event.set()
|
||||
|
||||
sys.exit(process.returncode)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
Reference in New Issue
Block a user