From 2ead959fcf369b7b4e09708cd6a5ff219d0a58f0 Mon Sep 17 00:00:00 2001 From: emil Date: Tue, 23 Jun 2026 18:10:19 +0000 Subject: [PATCH] DEVX-14: feat: fix molecule platforms to use sleep infinity, add --platforms-file --- .taskid | 2 +- src/devx/molecule/distribute_molecule.py | 19 +++++++--- src/devx/molecule/platforms.py | 40 ++++++++++++++++---- tests/unit/test_distribute_molecule.py | 30 ++++++++++++--- tests/unit/test_platforms.py | 47 ++++++++++++++++++++---- 5 files changed, 112 insertions(+), 26 deletions(-) diff --git a/.taskid b/.taskid index 463fb4d..0d88bd5 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-13 +DEVX-14 diff --git a/src/devx/molecule/distribute_molecule.py b/src/devx/molecule/distribute_molecule.py index c878192..4207f44 100644 --- a/src/devx/molecule/distribute_molecule.py +++ b/src/devx/molecule/distribute_molecule.py @@ -25,7 +25,7 @@ from pathlib import Path import click from devx.i18n import _ -from devx.molecule.platforms import PLATFORMS +from devx.molecule.platforms import PLATFORMS, load_platforms DEFAULT_MAX_RUNNERS = 3 MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") @@ -238,6 +238,13 @@ def _write_github_env(key: str, value: str) -> None: help="Roles directory for multi-role discovery (scans */molecule/*/). " "Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).", ) +@click.option( + "--platforms-file", + type=click.Path(exists=True, file_okay=True, path_type=Path), + default=None, + help="JSON file with custom platform list (each entry: name, image, command). " + "Overrides the default platform matrix. Useful for projects with custom test images.", +) def cli( runner_index: int | None, max_runners: int, @@ -247,7 +254,9 @@ def cli( skip_if_excess: bool, molecule_root: Path | None, roles_root: Path | None, + platforms_file: Path | None, ) -> None: + platforms = load_platforms(platforms_file) # Multi-role mode: discover (role, scenario) pairs across all roles if roles_root is not None: role_scenarios = discover_multi_role_scenarios(roles_root) @@ -256,10 +265,10 @@ def cli( click.echo(f"{role}|{scenario}") return if list_platforms: - for p in PLATFORMS: + for p in platforms: click.echo(f"{p['name']}|{p['image']}|{p['command']}") return - pairs_mr = build_multi_role_pairs(role_scenarios) + pairs_mr = build_multi_role_pairs(role_scenarios, platforms) if runner_index is None: groups = distribute_multi_role(pairs_mr, max_runners) for i, group in enumerate(groups): @@ -291,10 +300,10 @@ def cli( click.echo(s) return if list_platforms: - for p in PLATFORMS: + for p in platforms: click.echo(f"{p['name']}|{p['image']}|{p['command']}") return - pairs = build_pairs(scenarios) + pairs = build_pairs(scenarios, platforms) if runner_index is None: groups = distribute(pairs, max_runners) for i, group in enumerate(groups): diff --git a/src/devx/molecule/platforms.py b/src/devx/molecule/platforms.py index bcb9321..bb06d55 100644 --- a/src/devx/molecule/platforms.py +++ b/src/devx/molecule/platforms.py @@ -10,13 +10,39 @@ dev tools and CI scripts. from __future__ import annotations -#: Supported OS platform matrix. +import json +from pathlib import Path + +#: Default supported OS platform matrix. #: Each entry maps a short name to (image, command). -#: The command must be systemd since rootless Docker requires -#: loginctl/systemctl --user. +#: Uses the project's pre-built molecule-test-base image with +#: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures. PLATFORMS: list[dict[str, str]] = [ - {"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"}, - {"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"}, - {"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"}, - {"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"}, + { + "name": "ubuntu-2604", + "image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest", + "command": "sleep infinity", + }, ] + + +def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]: + """Load platforms from a JSON file, falling back to PLATFORMS. + + Args: + platforms_file: Path to a JSON file with a list of platform dicts. + Each dict must have ``name``, ``image``, and ``command`` keys. + + Returns: + List of platform dictionaries. + """ + if platforms_file is None: + return PLATFORMS + path = Path(platforms_file) + if not path.is_file(): + return PLATFORMS + with path.open() as f: + data = json.load(f) + if not isinstance(data, list) or not data: + return PLATFORMS + return data diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index ddac579..5800b6c 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -163,10 +163,7 @@ class TestCli: runner = CliRunner() result = runner.invoke(cli, ["--list-platforms"]) assert result.exit_code == 0 - assert "ubuntu-2204" in result.output - assert "ubuntu-2404" in result.output - assert "debian-12" in result.output - assert "archlinux" in result.output + assert "ubuntu-2604" in result.output def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: from click.testing import CliRunner @@ -198,7 +195,7 @@ class TestCli: assert result.exit_code == 0 # Output should contain encoded pairs with platform info assert "alpha|" in result.output - assert "ubuntu-2204" in result.output + assert "ubuntu-2604" in result.output class TestGithubEnv: @@ -409,7 +406,7 @@ class TestCliMultiRole: runner = CliRunner() result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"]) assert result.exit_code == 0 - assert "ubuntu-2204" in result.output + assert "ubuntu-2604" in result.output def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None: """--roles-root without --runner-index prints all groups.""" @@ -422,6 +419,27 @@ class TestCliMultiRole: assert "Runner 0:" in result.output assert "Runner 1:" in result.output + def test_platforms_file_overrides_default(self, tmp_path: Path) -> None: + """--platforms-file loads custom platforms from JSON.""" + import json + + from click.testing import CliRunner + + from devx.molecule.distribute_molecule import cli + + roles = tmp_path / "roles" + (roles / "role-a" / "molecule" / "default").mkdir(parents=True) + platforms_file = tmp_path / "platforms.json" + custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}] + platforms_file.write_text(json.dumps(custom)) + runner = CliRunner() + result = runner.invoke( + cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"] + ) + assert result.exit_code == 0 + assert "custom-os" in result.output + assert "custom:latest" in result.output + def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None: """Non-directory entries in roles root are skipped.""" roles = tmp_path / "roles" diff --git a/tests/unit/test_platforms.py b/tests/unit/test_platforms.py index 2dda85f..1339a88 100644 --- a/tests/unit/test_platforms.py +++ b/tests/unit/test_platforms.py @@ -1,11 +1,13 @@ -"""Unit tests for scripts/ci/platforms.py.""" +"""Unit tests for devx.molecule.platforms.""" -from devx.molecule.platforms import PLATFORMS +import json + +from devx.molecule.platforms import PLATFORMS, load_platforms class TestPlatforms: def test_platforms_not_empty(self) -> None: - assert len(PLATFORMS) >= 4 + assert len(PLATFORMS) >= 1 def test_each_platform_has_required_keys(self) -> None: for p in PLATFORMS: @@ -17,9 +19,40 @@ class TestPlatforms: names = [p["name"] for p in PLATFORMS] assert len(names) == len(set(names)) + def test_platforms_use_sleep_infinity(self) -> None: + """All default platforms must use sleep infinity, not systemd.""" + for p in PLATFORMS: + assert p["command"] == "sleep infinity", f"Platform {p['name']} uses {p['command']}" + def test_known_platforms_present(self) -> None: names = {p["name"] for p in PLATFORMS} - assert "ubuntu-2204" in names - assert "ubuntu-2404" in names - assert "debian-12" in names - assert "archlinux" in names + assert "ubuntu-2604" in names + + +class TestLoadPlatforms: + def test_load_platforms_default(self, tmp_path) -> None: # type: ignore[no-untyped-def] + """load_platforms with no file returns PLATFORMS.""" + result = load_platforms(None) + assert result == PLATFORMS + + def test_load_platforms_from_file(self, tmp_path) -> None: # type: ignore[no-untyped-def] + """load_platforms reads custom platforms from JSON file.""" + custom = [ + {"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}, + ] + f = tmp_path / "platforms.json" + f.write_text(json.dumps(custom)) + result = load_platforms(f) + assert result == custom + + def test_load_platforms_missing_file_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def] + """load_platforms falls back to PLATFORMS when file doesn't exist.""" + result = load_platforms(tmp_path / "nonexistent.json") + assert result == PLATFORMS + + def test_load_platforms_empty_list_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def] + """load_platforms falls back to PLATFORMS when file has empty list.""" + f = tmp_path / "platforms.json" + f.write_text("[]") + result = load_platforms(f) + assert result == PLATFORMS