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
+27
View File
@@ -201,6 +201,33 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT,
)
def test_get_commit_status(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"context": "CI / quality", "status": "success"}])
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/statuses",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
+132 -1
View File
@@ -9,7 +9,21 @@ from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError
from scripts.auto_merge import extract_task_id, has_ready_to_merge_label, main, validate_pr_title
from scripts.auto_merge import (
extract_task_id,
has_ready_to_merge_label,
main,
validate_pr_title,
wait_for_ci,
)
CI_SUCCESS = "success"
CI_PENDING = "pending"
CI_FAILURE = "failure"
def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z") -> dict[str, str]:
return {"context": context, "status": status, "updated_at": updated_at}
class TestRegexes:
@@ -70,12 +84,91 @@ class TestHasReadyToMergeLabel:
assert has_ready_to_merge_label(client, "5") is False
class TestWaitForCi:
def test_all_pass_immediately(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_waits_then_passes(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("CI / quality (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_fails_on_failed_check(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_FAILURE),
]
assert wait_for_ci(client, "abc123", max_wait=10) is False
def test_times_out(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING),
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=5) is False
def test_no_statuses_waits(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_ignores_non_ci_contexts(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("Auto-merge / merge (pull_request)", CI_PENDING),
_status("CI / quality (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_deduplicates_by_latest(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING, "2026-01-01T00:00:00Z"),
_status("CI / quality (pull_request)", CI_SUCCESS, "2026-01-01T00:01:00Z"),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_only_non_ci_contexts_waits_then_ci_appears(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def _mock_pr(sha: str = "abc123def456") -> dict[str, object]:
return {"head": {"sha": sha}}
def _mock_ci_passing() -> list[dict[str, str]]:
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_successful_flow_with_label_arg(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
runner = CliRunner()
result = runner.invoke(
main,
@@ -91,6 +184,8 @@ class TestMain:
"""Label not passed via arg, but PR has ready-to-merge via API."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
@@ -123,6 +218,8 @@ class TestMain:
"""Gitea Actions doesn't populate label name, but API shows ready-to-merge."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
@@ -166,6 +263,8 @@ class TestMain:
def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
@@ -178,9 +277,41 @@ class TestMain:
def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_ci_failure_blocks_merge(self, mock_client_cls: MagicMock) -> None:
"""CI checks fail — merge should not be attempted."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_FAILURE),
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1
assert "CI checks did not pass" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_no_sha_proceeds_without_wait(self, mock_client_cls: MagicMock) -> None:
"""PR head SHA missing — should proceed without waiting."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = {"head": {}}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 0
assert "squash-merged" in result.output
+1 -56
View File
@@ -29,7 +29,6 @@ class TestCLI:
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="docker",
admin_token="",
integration_retries=3,
labels=None,
@@ -51,7 +50,6 @@ class TestCLI:
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="docker",
admin_token="",
integration_retries=3,
labels=None,
@@ -91,7 +89,6 @@ class TestCLI:
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="docker",
admin_token=None,
integration_retries=3,
labels=None,
@@ -141,7 +138,6 @@ class TestCLI:
name="r1",
token="tok",
gitea_url="https://git.example.com",
mode="docker",
admin_token="",
integration_retries=3,
labels=None,
@@ -163,29 +159,6 @@ class TestCLI:
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="docker",
admin_token="",
integration_retries=3,
labels=None,
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_binary_mode(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--mode", "binary"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="binary",
admin_token="",
integration_retries=3,
labels=None,
@@ -230,7 +203,6 @@ class TestCLI:
user="ubuntu",
key="/key",
version="v0.2.0",
mode="docker",
ask_become_pass=True,
)
@@ -247,24 +219,6 @@ class TestCLI:
user="ubuntu",
key=None,
version=None,
mode="docker",
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_update_binary_mode(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["update", "host1", "--user", "ubuntu", "--mode", "binary"])
assert result.exit_code == 0
mock_manager.update.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
version=None,
mode="binary",
ask_become_pass=True,
)
@@ -294,7 +248,6 @@ class TestCLI:
host=None,
user=None,
key=None,
mode=None,
ask_become_pass=True,
)
@@ -304,14 +257,13 @@ class TestCLI:
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["start", "r1", "--host", "newhost", "--user", "newuser", "--mode", "binary"])
result = runner.invoke(cli, ["start", "r1", "--host", "newhost", "--user", "newuser"])
assert result.exit_code == 0
mock_manager.start.assert_called_once_with(
name="r1",
host="newhost",
user="newuser",
key=None,
mode="binary",
ask_become_pass=True,
)
@@ -362,7 +314,6 @@ class TestCLI:
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
ask_become_pass=True,
)
@@ -396,7 +347,6 @@ class TestCLI:
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
ask_become_pass=True,
)
@@ -426,7 +376,6 @@ class TestCLI:
host=None,
user=None,
key=None,
mode=None,
ask_become_pass=True,
)
@@ -445,7 +394,6 @@ class TestCLI:
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
force=False,
ask_become_pass=True,
)
@@ -465,7 +413,6 @@ class TestCLI:
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
force=True,
ask_become_pass=True,
)
@@ -552,7 +499,6 @@ class TestCLI:
key=None,
token="tok",
gitea_url="https://git.example.com",
mode=None,
force=False,
ask_become_pass=True,
)
@@ -578,7 +524,6 @@ class TestCLI:
"name": "r1",
"host": "10.0.0.1",
"user": "ubuntu",
"mode": "docker",
"labels": "docker:docker://alpine:latest",
"status": "active",
},
+1 -1
View File
@@ -47,7 +47,7 @@ class TestConfigConstants:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is False
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 1
assert BRANCH_PROTECTION_CONFIG["status_check_contexts"] == ["lint", "unit-tests", "molecule-tests"]
assert BRANCH_PROTECTION_CONFIG["status_check_contexts"] == ["CI / quality", "CI / molecule-tests*"]
def test_label_config(self) -> None:
assert LABEL_CONFIG["name"] == "ready-to-merge"
+95 -28
View File
@@ -8,9 +8,12 @@ import pytest
from scripts.distribute_molecule import (
MOLECULE_ROOT,
PLATFORMS,
TestPair,
build_pairs,
discover_scenarios,
distribute,
scenarios_for_runner,
pairs_for_runner,
)
@@ -33,47 +36,97 @@ class TestDiscoverScenarios:
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
class TestPairEncoding:
def test_encode_roundtrip(self) -> None:
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
encoded = pair.encode()
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
decoded = TestPair.decode(encoded)
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
assert decoded.platform["image"] == "ubuntu:22.04"
assert decoded.platform["command"] == ""
def test_encode_with_command(self) -> None:
pair = TestPair(
"default",
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
)
encoded = pair.encode()
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
decoded = TestPair.decode(encoded)
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
class TestBuildPairs:
def test_cross_product(self) -> None:
scenarios = ["a", "b"]
platforms = [
{"name": "p1", "image": "img1", "command": ""},
{"name": "p2", "image": "img2", "command": ""},
]
pairs = build_pairs(scenarios, platforms)
assert len(pairs) == 4
assert pairs[0].scenario == "a"
assert pairs[0].platform["name"] == "p1"
assert pairs[1].scenario == "a"
assert pairs[1].platform["name"] == "p2"
assert pairs[2].scenario == "b"
assert pairs[2].platform["name"] == "p1"
assert pairs[3].scenario == "b"
assert pairs[3].platform["name"] == "p2"
def test_default_platforms(self) -> None:
pairs = build_pairs(["default"])
assert len(pairs) == len(PLATFORMS)
assert all(p.scenario == "default" for p in pairs)
class TestDistribute:
def test_even_split(self) -> None:
scenarios = ["a", "b", "c", "d", "e", "f"]
groups = distribute(scenarios, 3)
assert groups == [["a", "d"], ["b", "e"], ["c", "f"]]
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute(pairs, 3)
assert len(groups) == 3
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 2
def test_uneven_split(self) -> None:
scenarios = ["a", "b", "c", "d", "e"]
groups = distribute(scenarios, 3)
assert groups == [["a", "d"], ["b", "e"], ["c"]]
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
groups = distribute(pairs, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_scenarios(self) -> None:
scenarios = ["a", "b"]
groups = distribute(scenarios, 5)
assert groups == [["a"], ["b"], [], [], []]
def test_more_runners_than_pairs(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
groups = distribute(pairs, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_single_runner(self) -> None:
scenarios = ["a", "b", "c"]
groups = distribute(scenarios, 1)
assert groups == [["a", "b", "c"]]
def test_empty_scenarios(self) -> None:
def test_empty_pairs(self) -> None:
groups = distribute([], 3)
assert groups == [[], [], []]
class TestScenariosForRunner:
class TestPairsForRunner:
def test_returns_correct_subset(self) -> None:
scenarios = ["a", "b", "c", "d", "e", "f"]
assert scenarios_for_runner(scenarios, 0, 3) == ["a", "d"]
assert scenarios_for_runner(scenarios, 1, 3) == ["b", "e"]
assert scenarios_for_runner(scenarios, 2, 3) == ["c", "f"]
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
assert len(pairs_for_runner(pairs, 0, 3)) == 2
assert len(pairs_for_runner(pairs, 1, 3)) == 2
assert len(pairs_for_runner(pairs, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
scenarios_for_runner(["a"], 5, 3)
pairs_for_runner(pairs, 5, 3)
assert "out of range" in str(exc.value)
def test_negative_index_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
scenarios_for_runner(["a"], -1, 3)
pairs_for_runner(pairs, -1, 3)
assert "out of range" in str(exc.value)
@@ -93,6 +146,19 @@ class TestCli:
assert "alpha" in result.output
assert "beta" in result.output
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
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
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -115,13 +181,14 @@ class TestCli:
from scripts.distribute_molecule import cli
root = tmp_path / "molecule"
for s in ["a", "b", "c"]:
(root / s).mkdir(parents=True)
(root / "alpha").mkdir(parents=True)
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code == 0
assert result.output.strip() == "b"
# Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
def test_main_module_block() -> None:
-2
View File
@@ -22,7 +22,6 @@ class TestRunnerRegistry:
host="10.0.0.1",
user="ubuntu",
key="/key",
mode="docker",
gitea_url="https://git.example.com",
labels="docker:docker://alpine:latest",
)
@@ -31,7 +30,6 @@ class TestRunnerRegistry:
assert info["host"] == "10.0.0.1"
assert info["user"] == "ubuntu"
assert info["key"] == "/key"
assert info["mode"] == "docker"
assert info["gitea_url"] == "https://git.example.com"
assert info["labels"] == "docker:docker://alpine:latest"
assert "created_at" in info
+22 -133
View File
@@ -33,14 +33,12 @@ class TestRunnerManager:
assert "registration_token=tok" in cmd_str
assert "runner_name=192.168.1.10" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"]
mock_registry.add.assert_called_once_with(
name="192.168.1.10",
host="192.168.1.10",
user="ubuntu",
key=None,
mode="docker",
gitea_url="https://git.example.com",
labels=None,
)
@@ -60,14 +58,12 @@ class TestRunnerManager:
assert "/key" in cmd_str
assert "registration_token=preset" in cmd_str
assert "runner_name=my-runner" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "--ask-become-pass" not in cmd_str
mock_registry.add.assert_called_once_with(
name="my-runner",
host="host1",
user="root",
key="/key",
mode="docker",
gitea_url="https://git.example.com",
labels=None,
)
@@ -82,27 +78,6 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "--ask-become-pass" in cmd_str
assert "runner_mode=docker" in cmd_str
def test_install_binary_mode(self) -> None:
mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", mode="binary")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "runner_mode=binary" in cmd_str
mock_registry.add.assert_called_once_with(
name="host1",
host="host1",
user="root",
key=None,
mode="binary",
gitea_url="https://git.example.com",
labels=None,
)
def test_install_missing_gitea_url(self) -> None:
manager = RunnerManager()
@@ -181,7 +156,6 @@ class TestRunnerManager:
assert "--private-key" in cmd_str
assert "/key" in cmd_str
assert "gitea_runner_version=v0.2.0" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "--ask-become-pass" not in cmd_str
assert "Updating Gitea Runner on host" in mock_executor.run.call_args.kwargs["description"]
@@ -194,17 +168,6 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "--ask-become-pass" in cmd_str
assert "runner_mode=docker" in cmd_str
def test_update_binary_mode(self) -> None:
manager = RunnerManager()
mock_executor = MagicMock()
manager._executor = mock_executor
manager.update("host", "user", mode="binary")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "runner_mode=binary" in cmd_str
def test_update_playbook_not_found(self) -> None:
manager = RunnerManager()
@@ -226,15 +189,13 @@ class TestRunnerManager:
"host": "10.0.0.1",
"user": "ubuntu",
"key": "/key",
"mode": "docker",
"gitea_url": "https://git.example.com",
}
manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner("r1")
host, user, key, gitea_url = manager._resolve_runner("r1")
assert host == "10.0.0.1"
assert user == "ubuntu"
assert key == "/key"
assert mode == "docker"
assert gitea_url == "https://git.example.com"
mock_registry.get.assert_called_once_with("r1")
@@ -242,11 +203,10 @@ class TestRunnerManager:
mock_registry = MagicMock()
mock_registry.get.return_value = None
manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root")
host, user, key, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root")
assert host == "10.0.0.2"
assert user == "root"
assert key is None
assert mode == "docker"
assert gitea_url == ""
def test_resolve_runner_override(self) -> None:
@@ -255,17 +215,13 @@ class TestRunnerManager:
"host": "10.0.0.1",
"user": "ubuntu",
"key": "/key",
"mode": "docker",
"gitea_url": "https://git.example.com",
}
manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner(
"r1", host="10.0.0.2", user="root", key="/new", mode="binary"
)
host, user, key, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root", key="/new")
assert host == "10.0.0.2"
assert user == "root"
assert key == "/new"
assert mode == "binary"
assert gitea_url == "https://git.example.com"
def test_resolve_runner_not_found(self) -> None:
@@ -277,7 +233,7 @@ class TestRunnerManager:
def test_start(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"}
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
@@ -287,22 +243,20 @@ class TestRunnerManager:
cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str
assert "runner_name=r1" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_start_with_override(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"}
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.start("r1", host="newhost", user="newuser", mode="binary")
manager.start("r1", host="newhost", user="newuser")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "newhost," in cmd_str
assert "newuser" in cmd_str
assert "runner_mode=binary" in cmd_str
def test_start_no_registry_with_explicit(self) -> None:
mock_registry = MagicMock()
@@ -311,14 +265,14 @@ class TestRunnerManager:
mock_executor = MagicMock()
manager._executor = mock_executor
manager.start("r1", host="host", user="user", mode="docker")
manager.start("r1", host="host", user="user")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str
def test_stop(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"}
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
@@ -332,7 +286,7 @@ class TestRunnerManager:
def test_enable(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"}
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
@@ -350,7 +304,6 @@ class TestRunnerManager:
"host": "host",
"user": "user",
"key": None,
"mode": "docker",
"gitea_url": "",
}
manager = RunnerManager(registry=mock_registry)
@@ -364,7 +317,6 @@ class TestRunnerManager:
assert "runner_name=r1" in cmd_str
assert "registration_token=tok" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable_uses_registry_gitea_url(self) -> None:
@@ -373,7 +325,6 @@ class TestRunnerManager:
"host": "host",
"user": "user",
"key": None,
"mode": "docker",
"gitea_url": "https://registry.example.com",
}
manager = RunnerManager(registry=mock_registry)
@@ -401,7 +352,7 @@ class TestRunnerManager:
def test_status(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "binary"}
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
@@ -411,7 +362,6 @@ class TestRunnerManager:
cmd_str = " ".join(cmd)
assert "status-runner.yml" in cmd_str
assert "runner_name=r1" in cmd_str
assert "runner_mode=binary" in cmd_str
assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_remove(self) -> None:
@@ -420,7 +370,6 @@ class TestRunnerManager:
"host": "host",
"user": "user",
"key": None,
"mode": "docker",
"gitea_url": "",
}
manager = RunnerManager(registry=mock_registry)
@@ -434,7 +383,6 @@ class TestRunnerManager:
assert "runner_name=r1" in cmd_str
assert "registration_token=tok" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"]
mock_registry.remove.assert_called_once_with("r1")
@@ -470,7 +418,6 @@ class TestRunnerManager:
"host": "host",
"user": "user",
"key": None,
"mode": "docker",
"gitea_url": "",
}
manager = RunnerManager(registry=mock_registry)
@@ -493,14 +440,14 @@ class TestRunnerManager:
mock_executor.run.assert_not_called()
mock_registry.remove.assert_called_once_with("r1")
def test_list_runners_docker(self) -> None:
def test_list_runners(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "docker"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "running"
mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor
runners = manager.list_runners()
@@ -513,44 +460,16 @@ class TestRunnerManager:
"ubuntu",
"/key",
"shell",
(
"docker inspect gitea-runner-r1 2>/dev/null | "
"python3 -c \"import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])\" "
"2>/dev/null || echo unknown"
),
"sudo -u grm-r1 systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
def test_list_runners_binary(self) -> None:
def test_list_runners_exception(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "binary"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor
runners = manager.list_runners()
assert len(runners) == 1
assert runners[0]["status"] == "active"
mock_executor.run_ad_hoc.assert_called_once_with(
"10.0.0.1",
"ubuntu",
"/key",
"shell",
"systemctl is-active gitea-runner@r1 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
def test_list_runners_binary_exception(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "binary"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
@@ -569,7 +488,7 @@ class TestRunnerManager:
def test_list_runners_without_labels(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
@@ -579,38 +498,23 @@ class TestRunnerManager:
runners = manager.list_runners()
assert runners[0]["labels"] == ""
def test_list_runners_docker_exited(self) -> None:
def test_list_runners_inactive(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "exited"
mock_executor.run_ad_hoc.return_value = "inactive"
manager._executor = mock_executor
runners = manager.list_runners()
assert runners[0]["status"] == "inactive"
def test_list_runners_docker_fallback_to_host_container(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
# First call (name-based) returns unknown, second (host-based) returns running
mock_executor.run_ad_hoc.side_effect = ["unknown", "running"]
manager._executor = mock_executor
runners = manager.list_runners()
assert runners[0]["status"] == "active"
assert mock_executor.run_ad_hoc.call_count == 2
def test_list_runners_unknown_status(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
@@ -620,25 +524,10 @@ class TestRunnerManager:
runners = manager.list_runners()
assert runners[0]["status"] == "unknown"
def test_list_runners_docker_first_command_falls_back(self) -> None:
"""When the first docker inspect raises, the loop continues to the next fallback."""
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = [AnsibleError("ssh fail"), "running"]
manager._executor = mock_executor
runners = manager.list_runners()
assert runners[0]["status"] == "active"
assert mock_executor.run_ad_hoc.call_count == 2
def test_list_runners_ad_hoc_error(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()