from __future__ import annotations import importlib.util import io import json import sys from email.message import Message from pathlib import Path from types import SimpleNamespace import pytest CONTROLLER = Path(__file__).resolve().parents[2] / "ops/gitea-deploy-broker" @pytest.fixture def controller(monkeypatch): monkeypatch.syspath_prepend(str(CONTROLLER)) spec = importlib.util.spec_from_file_location("specialization_surface_test", CONTROLLER / "status_server.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module def handler(controller, monkeypatch, payload=None): instance = object.__new__(controller.Handler) body = json.dumps(payload or {"reason": "Specialize CI profiles"}).encode() instance.headers = Message() instance.headers["Content-Length"] = str(len(body)) instance.rfile = io.BytesIO(body) instance.path = controller.RUNNER_FLEET_SPECIALIZE_PATH responses = [] monkeypatch.setattr(instance, "_trusted_client", lambda: True) monkeypatch.setattr(instance, "_send_json", lambda code, data: responses.append((code, json.loads(data)))) signatures = [] monkeypatch.setattr(controller, "_self_upgrade_signature_is_valid", lambda *args, **kwargs: signatures.append(kwargs) or True) monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: {"revision": "a" * 40}) monkeypatch.setattr(controller, "_require_controller_release", lambda: {"sourceRevision": "a" * 40, "sourceRepository": controller.SELF_UPGRADE_REPOSITORY}) monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda revision: {"status": "already_deployed"}) monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {"branch": "main"}}) monkeypatch.setattr(controller, "_repository_path_for_delivery", lambda *args: Path("fixed-repo.git")) monkeypatch.setattr(controller, "_is_current_branch_head", lambda *args: True) monkeypatch.setattr(controller, "runner_fleet_specialized_topology", lambda: {"ready": False}) return instance, responses, signatures @pytest.mark.parametrize("state", ["blocked", "active", "unknown"]) def test_legacy_upgrade_cannot_recreate_runners_after_cutover(controller, monkeypatch, state): instance, responses, _ = handler(controller, monkeypatch) monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: state) monkeypatch.setattr(controller, "execute_runner_fleet_v2", lambda _: pytest.fail("legacy executor must be fenced")) instance._handle_runner_fleet_upgrade_v2() assert responses == [(409, {"status": "legacy_fleet_upgrade_blocked"})] def test_specialization_requires_matching_installed_controller_source(controller, monkeypatch): instance, responses, _ = handler(controller, monkeypatch) monkeypatch.setattr(controller, "_require_controller_release", lambda: {"sourceRevision": "b" * 40, "sourceRepository": controller.SELF_UPGRADE_REPOSITORY}) monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda _: pytest.fail("source mismatch must block mutation")) instance.do_POST() assert responses == [(409, {"status": "controller_source_mismatch"})] def test_specialization_uses_only_signed_current_main_script(controller, monkeypatch): instance, responses, signatures = handler(controller, monkeypatch) commands = [] scripts = [] monkeypatch.setattr(controller.subprocess, "run", lambda command, **_: commands.append(command) or SimpleNamespace(returncode=0, stdout=b"exact source script")) monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda script: scripts.append(script) or {"status": "specialized", "verifiedRunners": [], "resourceEnvelope": {"ready": True}}) instance.do_POST() assert signatures == [{"method": "POST", "path": controller.RUNNER_FLEET_SPECIALIZE_PATH}] assert commands == [["git", "--git-dir=fixed-repo.git", "show", "a" * 40 + ":scripts/upgrade_ci_runner_fleet_specialized.sh"]] assert scripts == [b"exact source script"] assert responses[0][0] == 200 assert responses[0][1]["revision"] == "a" * 40 assert "burstRunning" not in responses[0][1] def test_pending_protected_validation_blocks_specialization(controller, monkeypatch): instance, responses, _ = handler(controller, monkeypatch) monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda _: {"status": "preflight_rejected"}) monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda _: pytest.fail("pending validation must block mutation")) instance.do_POST() assert responses[0][0] == 409 assert responses[0][1]["status"] == "preflight_rejected" def test_caller_cannot_choose_script_image_or_runner(controller, monkeypatch): instance, responses, _ = handler(controller, monkeypatch, {"reason": "Specialize CI profiles", "script": "untrusted.sh"}) instance.do_POST() assert responses == [(400, {"status": "invalid_request"})] @pytest.mark.parametrize("state", ["blocked", "unknown"]) def test_maintenance_never_falls_back_to_legacy_during_cutover(controller, monkeypatch, state): monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: state) monkeypatch.setattr(controller.RunnerFleetService, "automatic_cycle", lambda _: pytest.fail("legacy maintenance must be fenced")) assert controller._runner_maintenance_cycle() == "deferred" def test_cutover_and_maintenance_share_single_owner_lock(controller, monkeypatch): monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: pytest.fail("maintenance must not inspect during cutover")) with controller._runner_fleet_upgrade_lock: assert controller._runner_maintenance_cycle() == "migration_busy" def test_active_maintenance_calls_only_admission_controller(controller, monkeypatch): import runner_admission_controller calls = [] monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: "active") monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "cycle", lambda _: calls.append("admission")) monkeypatch.setattr(controller.RunnerFleetService, "automatic_cycle", lambda _: pytest.fail("legacy worker must stop")) assert controller._runner_maintenance_cycle() == "specialized" assert calls == ["admission"] def test_statistics_get_returns_persisted_projection_without_a_cycle(controller, monkeypatch): import runner_admission_controller instance, responses, _ = handler(controller, monkeypatch) instance.path = "/v1/runner-fleet/statistics" monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "cycle", lambda _: pytest.fail("GET may not start collection or mutate fleet")) monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "snapshot", lambda _: {"schemaVersion": 1, "status": "unavailable"}) instance.do_GET() assert responses == [(200, {"schemaVersion": 1, "status": "unavailable"})] def test_statistics_configuration_uses_same_protected_source_and_fixed_signed_route(controller, monkeypatch): instance, responses, signatures = handler(controller, monkeypatch) instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH calls = [] monkeypatch.setattr(controller, 'configure_runner_statistics_binding', lambda **kwargs: calls.append(kwargs) or {'status': 'configured', 'credentialReturned': False}) monkeypatch.setattr(controller, 'runner_fleet_specialized_topology', lambda: pytest.fail('configuration must not mutate fleet')) instance.do_POST() assert signatures == [{'method': 'POST', 'path': controller.RUNNER_STATISTICS_CONFIGURE_PATH}] assert calls == [{'revision': 'a' * 40, 'projects': {controller.SELF_UPGRADE_REPOSITORY: {'branch': 'main'}}}] assert responses == [(200, {'status': 'configured', 'credentialReturned': False})] def test_statistics_configuration_source_mismatch_writes_nothing(controller, monkeypatch): instance, responses, _ = handler(controller, monkeypatch) instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH monkeypatch.setattr(controller, '_require_controller_release', lambda: {'sourceRevision': 'b' * 40, 'sourceRepository': controller.SELF_UPGRADE_REPOSITORY}) monkeypatch.setattr(controller, 'configure_runner_statistics_binding', lambda **kwargs: pytest.fail('must not write configuration')) instance.do_POST() assert responses == [(409, {'status': 'controller_source_mismatch'})] def test_statistics_configuration_caller_cannot_select_credential_or_endpoint(controller, monkeypatch): instance, responses, _ = handler(controller, monkeypatch, {'reason': 'configure stats', 'token': 'caller-credential'}) instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH monkeypatch.setattr(controller, 'configure_runner_statistics_binding', lambda **kwargs: pytest.fail('must not write configuration')) instance.do_POST() assert responses == [(400, {'status': 'invalid_request'})]