diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c17dfda..af609e5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -35,6 +35,25 @@ _HERE = Path(__file__).parent.resolve() ENABLE_ENV = "AEGIS_INTEGRATION" +REQUIRE_RUNNER_ENV = "AEGIS_IT_REQUIRE_RUNNER" + + +def runner_required() -> bool: + """True when a runner that cannot start must fail the test instead of xfailing it. + + Set by the CI job (``AEGIS_IT_REQUIRE_RUNNER=1``) and implied by ``CI``: a pipeline that + silently skips the Actions scenario would hide a broken runner setup. + """ + return os.environ.get(REQUIRE_RUNNER_ENV) == "1" or bool(os.environ.get("CI")) + + +def runner_unavailable(reason: str) -> None: + """Report that the act_runner could not start or come online (fails or xfails).""" + if runner_required(): + pytest.fail(f"act_runner unavailable: {reason}", pytrace=False) + pytest.xfail(reason) + + def integration_enabled() -> bool: """Return True when the opt-in env var is set.""" return os.environ.get(ENABLE_ENV) == "1" diff --git a/tests/integration/harness.py b/tests/integration/harness.py index 74ddeff..3a071ce 100644 --- a/tests/integration/harness.py +++ b/tests/integration/harness.py @@ -561,9 +561,19 @@ def stop_gitea(instance: GiteaInstance) -> None: cleanup_resources(res) -def start_runner(instance: GiteaInstance, registration_token: str) -> str: +def start_runner( + instance: GiteaInstance, + registration_token: str, + *, + suffix: str = "runner", + label: str = RUNNER_LABEL, +) -> str: """Start an ``act_runner`` container registered with ``registration_token``. + ``suffix`` names the container (``aegis-it--``, also the runner's name in + Gitea) and ``label`` is the single label it serves, so extra runners at other scopes + never pick up the jobs of the Actions scenario. + The runner joins the run's private network, reaches Gitea by its container name (so it works in local and in container network mode) and runs jobs labelled ``aegis-host`` in host mode inside its own container. The token travels in the @@ -575,7 +585,7 @@ def start_runner(instance: GiteaInstance, registration_token: str) -> str: if res is None: raise HarnessError("the Gitea instance is not tracked (already stopped?)") ensure_image(ACT_RUNNER_IMAGE) - name = f"{NAME_PREFIX}{instance.run_id}-runner" + name = f"{NAME_PREFIX}{instance.run_id}-{suffix}" res.extra_containers.append(name) run_docker( "run", @@ -593,7 +603,7 @@ def start_runner(instance: GiteaInstance, registration_token: str) -> str: "-e", f"GITEA_RUNNER_NAME={name}", "-e", - f"GITEA_RUNNER_LABELS={RUNNER_LABEL}:host", + f"GITEA_RUNNER_LABELS={label}:host", ACT_RUNNER_IMAGE, env={RUNNER_REGISTRATION_ENV: registration_token}, timeout=300, diff --git a/tests/integration/test_scenario_actions.py b/tests/integration/test_scenario_actions.py index 6579449..a2df243 100644 --- a/tests/integration/test_scenario_actions.py +++ b/tests/integration/test_scenario_actions.py @@ -6,9 +6,7 @@ import asyncio import time from typing import Any -import pytest - -from tests.integration.conftest import McpSession, RepoRef, b64 +from tests.integration.conftest import McpSession, RepoRef, b64, drop_org, runner_unavailable from tests.integration.harness import GiteaInstance, HarnessError, runner_logs, start_runner WORKFLOW_PATH = ".gitea/workflows/it.yml" @@ -45,7 +43,7 @@ async def test_workflow_run_on_act_runner( try: runner = start_runner(gitea, token.data["token"]) except HarnessError as exc: - pytest.xfail(f"the act_runner container could not be started: {exc}") + runner_unavailable(f"the act_runner container could not be started: {exc}") async def online() -> dict[str, Any] | None: runners = await mcp.ok("gitea_actions_get_repo_runners", **repo) @@ -58,7 +56,7 @@ async def test_workflow_run_on_act_runner( registered = await _wait_for(online, timeout=RUNNER_ONLINE_TIMEOUT) if not registered: - pytest.xfail( + runner_unavailable( "the act_runner did not come online within " f"{RUNNER_ONLINE_TIMEOUT:.0f}s; runner log tail: {runner_logs(runner, 15)}" ) @@ -115,3 +113,129 @@ async def test_workflow_run_on_act_runner( assert (await mcp.ok("gitea_actions_get_artifacts", **repo)).data["total_count"] == 0 tasks = await mcp.ok("gitea_actions_list_action_tasks", **repo) assert tasks.data["total_count"] >= 2 + + # The runner registered above, managed through the repository-scope runner tools. + runner_id = registered["id"] + got = await mcp.ok("gitea_actions_get_repo_runner", runner_id=str(runner_id), **repo) + assert got.data["id"] == runner_id and got.data["name"] == registered["name"] + off = await mcp.ok( + "gitea_actions_update_repo_runner", + runner_id=str(runner_id), + body={"disabled": True}, + **repo, + ) + assert off.data["disabled"] is True + on = await mcp.ok( + "gitea_actions_update_repo_runner", + runner_id=str(runner_id), + body={"disabled": False}, + **repo, + ) + assert on.data["disabled"] is False + + # The completed run: logs archive, rerun of the run and of one job, then delete the run. + archive = await mcp.ok("gitea_actions_get_workflow_run_logs", run=run_id, **repo) + assert archive.data + await mcp.ok("gitea_actions_rerun_workflow_run", status=201, run=run_id, **repo) + await _wait_for_attempt(mcp, repo, run_id, attempt=2) + rerun_jobs = await mcp.ok("gitea_actions_list_workflow_run_jobs", run=run_id, **repo) + await mcp.ok( + "gitea_actions_rerun_workflow_job", + status=201, + run=run_id, + job_id=rerun_jobs.data["jobs"][0]["id"], + **repo, + ) + await _wait_for_attempt(mcp, repo, run_id, attempt=3) + await mcp.ok("gitea_actions_delete_action_run", run=run_id, **repo) + await mcp.fail("gitea_actions_get_workflow_run", 404, run=run_id, **repo) + + await mcp.ok("gitea_actions_delete_repo_runner", runner_id=str(runner_id), **repo) + await mcp.fail("gitea_actions_get_repo_runner", 404, runner_id=str(runner_id), **repo) + + +async def _wait_for_attempt( + mcp: McpSession, repo: dict[str, str], run_id: int, *, attempt: int +) -> None: + """Wait until ``run_id`` reaches ``attempt`` and is completed again.""" + + async def done() -> bool: + run = (await mcp.ok("gitea_actions_get_workflow_run", run=run_id, **repo)).data + return bool(run["status"] == "completed" and run.get("run_attempt", 1) >= attempt) + + assert await _wait_for(done, timeout=RUN_TIMEOUT), ( + f"run {run_id} did not finish attempt {attempt}" + ) + + +async def test_runner_management_at_every_scope(mcp: McpSession, gitea: GiteaInstance) -> None: + """Register one runner per scope (org, user, admin) and manage it through the tools.""" + org = gitea.unique("it-runner") + await mcp.ok("gitea_org_create", status=201, body={"username": org}) + try: + scopes: list[tuple[str, str, dict[str, str], str]] = [ + ("org", "gitea_actions_org_create_runner_registration_token", {"org": org}, "org"), + ("user", "gitea_actions_user_create_runner_registration_token", {}, "user"), + ("admin", "gitea_admin_create_runner_registration_token", {}, "admin"), + ] + for scope, token_tool, args, _ in scopes: + token = await mcp.ok(token_tool, **args) + suffix = f"{scope}-runner" + try: + container = start_runner( + gitea, token.data["token"], suffix=suffix, label=f"aegis-{scope}" + ) + except HarnessError as exc: + runner_unavailable(f"the {scope} runner could not be started: {exc}") + return + listing = ( + "gitea_admin_get_runners" + if scope == "admin" + else (f"gitea_actions_get_{scope}_runners") + ) + getter = ( + "gitea_admin_get_runner" + if scope == "admin" + else (f"gitea_actions_get_{scope}_runner") + ) + updater = ( + "gitea_admin_update_runner" + if scope == "admin" + else (f"gitea_actions_update_{scope}_runner") + ) + deleter = ( + "gitea_admin_delete_runner" + if scope == "admin" + else (f"gitea_actions_delete_{scope}_runner") + ) + + async def registered( + listing: str = listing, args: dict[str, str] = args, container: str = container + ) -> dict[str, Any] | None: + runners = await mcp.ok(listing, **args) + entries = ( + runners.data["runners"] if isinstance(runners.data, dict) else runners.data + ) + for entry in entries or []: + if entry["name"] == container: + found: dict[str, Any] = entry + return found + return None + + runner = await _wait_for(registered, timeout=RUNNER_ONLINE_TIMEOUT) + if not runner: + runner_unavailable( + f"the {scope} runner did not register; log tail: {runner_logs(container, 15)}" + ) + return + assert token.data["token"] not in runner_logs(container, 200) + got = await mcp.ok(getter, runner_id=str(runner["id"]), **args) + assert got.data["name"] == container + changed = await mcp.ok( + updater, runner_id=str(runner["id"]), body={"disabled": True}, **args + ) + assert changed.data["disabled"] is True + await mcp.ok(deleter, runner_id=str(runner["id"]), **args) + await mcp.fail(getter, 404, runner_id=str(runner["id"]), **args) + finally: + drop_org(gitea, org)