159 lines
9.0 KiB
Python
159 lines
9.0 KiB
Python
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'})]
|