refactor: rootless Docker, fix auto-merge, molecule platform matrix
CI / quality (pull_request) Failing after 1m4s
CI / molecule-tests (0) (pull_request) Has been skipped
CI / molecule-tests (1) (pull_request) Has been skipped
CI / molecule-tests (2) (pull_request) Has been skipped

Three major improvements:

1. Rootless Docker refactor: Removes docker/binary modes, unifies to
   rootless Docker with per-runner system users. Each runner gets its
   own rootless Docker daemon, systemd user service, and isolated
   environment. Simplifies CLI (removes --mode option), Ansible role
   (single code path), and molecule scenarios (removes binary scenario).

2. Auto-merge fix: Fixes status check context mismatch in branch
   protection (was requiring "lint", "unit-tests", "molecule-tests" but
   actual contexts are "CI / quality", "CI / molecule-tests*"). Adds
   retry/wait logic to auto_merge.py that polls commit statuses for up
   to 15 minutes before attempting merge, eliminating the chicken-and-egg
   problem where auto-merge would fail because CI hadn't completed yet.

3. Molecule platform matrix: Adds OS platform matrix to CI — all 6
   scenarios now run on all 4 supported OSes (ubuntu-2204, ubuntu-2404,
   debian-12, archlinux) = 24 test pairs distributed across 3 parallel
   runners. Updates distribute_molecule.py to distribute (scenario,
   platform) pairs. Updates Makefile with molecule-all target for
   local multi-platform testing.

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 21:02:52 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0c6c735000
commit 55c2746569
71 changed files with 2444 additions and 1277 deletions
+76 -21
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env python3
"""Distribute molecule scenarios across N parallel runners.
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
splits them evenly across the requested number of runners.
crosses them with the supported OS platform matrix, then splits the
resulting test pairs evenly across the requested number of runners.
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
so the CI workflow can set the appropriate environment variables.
Usage:
python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3
# prints: default deregister
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
python3 scripts/distribute_molecule.py --list
# prints all scenarios, one per line
python3 scripts/distribute_molecule.py --list-platforms
# prints all platforms, one per line
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import click
@@ -23,6 +29,36 @@ from gitea_runner_manager.i18n import _
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
#: Supported OS platform matrix.
#: Each entry maps a short name to (image, command).
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": ""},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": ""},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": ""},
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
]
@dataclass(frozen=True)
class TestPair:
"""A (scenario, platform) combination to test."""
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
@staticmethod
def decode(encoded: str) -> TestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return TestPair(
scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
)
def discover_scenarios(root: Path | None = None) -> list[str]:
"""Return sorted list of molecule scenario directory names."""
@@ -40,19 +76,26 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios)
def distribute(scenarios: list[str], max_runners: int) -> list[list[str]]:
"""Split *scenarios* into *max_runners* balanced groups (round-robin)."""
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, scenario in enumerate(scenarios):
groups[i % max_runners].append(scenario)
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms."""
if platforms is None:
platforms = PLATFORMS
return [TestPair(s, p) for s in scenarios for p in platforms]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def scenarios_for_runner(
scenarios: list[str], runner_index: int, max_runners: int
) -> list[str]:
"""Return the subset of scenarios assigned to *runner_index*."""
groups = distribute(scenarios, max_runners)
def pairs_for_runner(
pairs: list[TestPair], runner_index: int, max_runners: int
) -> list[TestPair]:
"""Return the subset of pairs assigned to *runner_index*."""
groups = distribute(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_(
@@ -84,19 +127,31 @@ def scenarios_for_runner(
is_flag=True,
help="List all discovered scenarios, one per line.",
)
def cli(runner_index: int | None, max_runners: int, list_all: bool) -> None:
@click.option(
"--list-platforms",
"list_platforms",
is_flag=True,
help="List all supported platforms, one per line.",
)
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
scenarios = discover_scenarios()
if list_all:
for s in scenarios:
click.echo(s)
return
if runner_index is None:
groups = distribute(scenarios, max_runners)
for i, group in enumerate(groups):
click.echo(f"Runner {i}: {' '.join(group) if group else '(none)'}")
if list_platforms:
for p in PLATFORMS:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
assigned = scenarios_for_runner(scenarios, runner_index, max_runners)
click.echo(" ".join(assigned))
pairs = build_pairs(scenarios)
if runner_index is None:
groups = distribute(pairs, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
assigned = pairs_for_runner(pairs, runner_index, max_runners)
click.echo(" ".join(p.encode() for p in assigned))
if __name__ == "__main__": # pragma: no cover