414 lines
13 KiB
Python
414 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import sys
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CLI_PATH = REPO_ROOT / "ops" / "gitea-deploy-broker" / "repopulse_adoption_cli.py"
|
|
|
|
|
|
def _load_module(name: str, path: Path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
cli = _load_module("repopulse_adoption_cli_test", CLI_PATH)
|
|
|
|
|
|
def _sha(value: bytes | str) -> str:
|
|
raw = value.encode("ascii") if isinstance(value, str) else value
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def _canonical_sha(value: object) -> str:
|
|
raw = json.dumps(
|
|
value,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
ensure_ascii=True,
|
|
allow_nan=False,
|
|
).encode("ascii")
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def _projection() -> dict[str, object]:
|
|
return {
|
|
"repository": "jens/repopulse",
|
|
"main_revision": "a" * 40,
|
|
"live_revision": "b" * 40,
|
|
"container_id_sha256": _sha("legacy-container"),
|
|
"container_image_sha256": _sha("legacy-image"),
|
|
"queue": {"queued": 0, "active": 0, "completed": 10, "failed": 0},
|
|
"database": {
|
|
"database_bytes": 12345,
|
|
"journal_bytes": 0,
|
|
"scan_count": 10,
|
|
"compacted_scan_count": 4,
|
|
},
|
|
"container_state": "running",
|
|
"container_health": "healthy",
|
|
}
|
|
|
|
|
|
def _payload() -> dict[str, object]:
|
|
idempotency = _sha("itworx.repopulse-legacy-adoption/v1:jens/repopulse:cli-test")
|
|
projection = _projection()
|
|
return {
|
|
"intentId": f"rpa-{idempotency[:20]}",
|
|
"idempotencySha256": idempotency,
|
|
"preflightSha256": _canonical_sha(projection),
|
|
"preflightProjection": projection,
|
|
"targetCredential": "A" * 64,
|
|
}
|
|
|
|
|
|
def _raw(payload: dict[str, object] | None = None) -> bytes:
|
|
return json.dumps(payload or _payload(), separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _core():
|
|
return cli._load_core_modules()
|
|
|
|
|
|
def _parsed(payload: dict[str, object] | None = None):
|
|
core = _core()
|
|
return cli.parse_handoff(_raw(payload), core.executor)
|
|
|
|
|
|
def test_parser_accepts_exact_handoff_and_rejects_duplicate_keys_at_any_depth():
|
|
core = _core()
|
|
payload = _payload()
|
|
parsed = cli.parse_handoff(_raw(payload), core.executor)
|
|
|
|
assert parsed.intent_id == payload["intentId"]
|
|
assert parsed.target_credential == b"A" * 64
|
|
assert parsed.preflight_projection == payload["preflightProjection"]
|
|
|
|
regular = _raw(payload)
|
|
duplicate_top = (
|
|
b'{"intentId":'
|
|
+ json.dumps(payload["intentId"]).encode("ascii")
|
|
+ bytes([44])
|
|
+ regular[1:]
|
|
)
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli.parse_handoff(duplicate_top, core.executor)
|
|
assert error.value.code == "controller_handoff_duplicate_key"
|
|
|
|
duplicate_nested = regular.replace(b'"queued":0', b'"queued":0,"queued":0', 1)
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli.parse_handoff(duplicate_nested, core.executor)
|
|
assert error.value.code == "controller_handoff_duplicate_key"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"change",
|
|
[
|
|
lambda value: value.pop("targetCredential"),
|
|
lambda value: value.update({"extra": True}),
|
|
lambda value: value.update({"targetCredential": "short"}),
|
|
lambda value: value.update({"targetCredential": "x" * 48 + "\n"}),
|
|
],
|
|
)
|
|
def test_parser_rejects_schema_changes_and_noncontroller_credentials(change):
|
|
core = _core()
|
|
payload = _payload()
|
|
change(payload)
|
|
|
|
with pytest.raises(cli.ControllerCliError):
|
|
cli.parse_handoff(_raw(payload), core.executor)
|
|
|
|
|
|
def test_parser_enforces_bounded_stdin_size():
|
|
core = _core()
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli.parse_handoff(b"x" * (cli._MAX_HANDOFF_BYTES + 1), core.executor)
|
|
assert error.value.code == "controller_handoff_too_large"
|
|
|
|
|
|
def test_reserved_binding_requires_existing_matching_intent_and_credential():
|
|
core = _core()
|
|
handoff = _parsed()
|
|
binding = core.store.intent_binding_sha256(
|
|
handoff.intent_id,
|
|
handoff.idempotency_sha256,
|
|
handoff.preflight_sha256,
|
|
)
|
|
record = {
|
|
"intentId": handoff.intent_id,
|
|
"idempotencySha256": handoff.idempotency_sha256,
|
|
"preflightSha256": handoff.preflight_sha256,
|
|
"intentBindingSha256": binding,
|
|
"expectedTargetCredentialSha256": _sha(handoff.target_credential),
|
|
"status": "in_progress",
|
|
"registration": None,
|
|
}
|
|
|
|
class Store:
|
|
def read_only_status(self):
|
|
return {
|
|
"status": record["status"],
|
|
"hashes": {
|
|
"idempotencySha256": record["idempotencySha256"],
|
|
"preflightSha256": record["preflightSha256"],
|
|
"intentBindingSha256": record["intentBindingSha256"],
|
|
},
|
|
}
|
|
|
|
def load_intent(self):
|
|
return record
|
|
|
|
assert cli._require_reserved_binding(
|
|
store=Store(), store_module=core.store, handoff=handoff
|
|
) is record
|
|
|
|
mismatched = replace(handoff, preflight_sha256=_sha("another preflight"))
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli._require_reserved_binding(
|
|
store=Store(), store_module=core.store, handoff=mismatched
|
|
)
|
|
assert error.value.code == "controller_intent_binding_mismatch"
|
|
|
|
wrong_credential = replace(handoff, target_credential=b"B" * 64)
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli._require_reserved_binding(
|
|
store=Store(), store_module=core.store, handoff=wrong_credential
|
|
)
|
|
assert error.value.code == "target_credential_binding_mismatch"
|
|
|
|
|
|
def test_missing_reservation_is_rejected_before_loading_or_creating_journal():
|
|
core = _core()
|
|
handoff = _parsed()
|
|
|
|
class Store:
|
|
def read_only_status(self):
|
|
return None
|
|
|
|
def load_intent(self):
|
|
raise AssertionError("missing reservation must not load mutable state")
|
|
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli._require_reserved_binding(
|
|
store=Store(), store_module=core.store, handoff=handoff
|
|
)
|
|
assert error.value.code == "controller_intent_not_reserved"
|
|
|
|
|
|
def test_fixed_storage_bridge_forwards_executor_revision_and_keeps_credential_in_memory():
|
|
handoff = _parsed()
|
|
|
|
class StorageHooks:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def ensure_quiesced(self, **kwargs):
|
|
self.calls.append(("quiesce", kwargs))
|
|
return {"ok": True}
|
|
|
|
def create_or_verify_fresh_snapshot(self, **kwargs):
|
|
self.calls.append(("snapshot", kwargs))
|
|
return {"ok": True}
|
|
|
|
def copy_and_verify_managed_data(self, **kwargs):
|
|
self.calls.append(("copy", kwargs))
|
|
return {"ok": True}
|
|
|
|
hooks = StorageHooks()
|
|
bridge = cli._FixedHandoffStorageEffects(
|
|
store=object(),
|
|
store_module=SimpleNamespace(REPOSITORY="jens/repopulse"),
|
|
storage_effects=hooks,
|
|
legacy_rollback=object(),
|
|
registry_restore=object(),
|
|
handoff=handoff,
|
|
)
|
|
revision = handoff.preflight_projection["main_revision"]
|
|
binding = "c" * 64
|
|
|
|
bridge.ensure_quiesced(
|
|
intent_id=handoff.intent_id,
|
|
intent_binding_sha256=binding,
|
|
expected_main_revision=revision,
|
|
expected_container_id_sha256="d" * 64,
|
|
expected_container_image_sha256="e" * 64,
|
|
)
|
|
bridge.create_or_verify_fresh_snapshot(
|
|
intent_id=handoff.intent_id,
|
|
intent_binding_sha256=binding,
|
|
expected_main_revision=revision,
|
|
expected_snapshot=None,
|
|
)
|
|
bridge.copy_and_verify_managed_data(
|
|
intent_id=handoff.intent_id,
|
|
expected_main_revision=revision,
|
|
snapshot_evidence={"intentId": handoff.intent_id},
|
|
)
|
|
|
|
assert [call[0] for call in hooks.calls] == ["quiesce", "snapshot", "copy"]
|
|
assert all(call[1]["expected_main_revision"] == revision for call in hooks.calls)
|
|
assert bridge.registration_material(intent_id=handoff.intent_id) == (
|
|
f"enr-{handoff.intent_id[4:]}",
|
|
b"A" * 64,
|
|
)
|
|
with pytest.raises(cli.ControllerCliError):
|
|
bridge.registration_material(intent_id="rpa-" + "f" * 20)
|
|
|
|
|
|
def test_executor_store_proxy_snapshots_registry_before_registration_cas():
|
|
events = []
|
|
|
|
class Store:
|
|
def register_target(self, **kwargs):
|
|
events.append(("register", kwargs))
|
|
return {"status": "registered"}
|
|
|
|
def read_only_status(self):
|
|
return {"status": "in_progress"}
|
|
|
|
class RegistryRestore:
|
|
def preserve_before_registry_cas(self, store):
|
|
assert isinstance(store, Store)
|
|
events.append(("preserve", None))
|
|
|
|
proxy = cli._RegistrySnapshotStoreProxy(Store(), RegistryRestore())
|
|
assert proxy.register_target(intent_id="rpa-" + "a" * 20) == {
|
|
"status": "registered"
|
|
}
|
|
assert proxy.read_only_status() == {"status": "in_progress"}
|
|
assert [name for name, _value in events] == ["preserve", "register"]
|
|
|
|
|
|
def test_serialized_result_rejects_secret_leaks_and_unexpected_fields():
|
|
credential = b"A" * 64
|
|
intent_id = "rpa-" + "a" * 20
|
|
result = {key: None for key in cli._RESULT_KEYS}
|
|
result.update(
|
|
{
|
|
"apiVersion": "itworx.repopulse-controller-adoption-execution-result/v1",
|
|
"repository": "jens/repopulse",
|
|
"applicationId": "repopulse",
|
|
"intentId": intent_id,
|
|
"status": "deployment_verified",
|
|
"phase": "completion_verified",
|
|
"legacyVolume": "repopulse_data",
|
|
"legacyVolumeRetained": True,
|
|
}
|
|
)
|
|
result["deploymentReceipt"] = {"note": credential.decode("ascii")}
|
|
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli._serialize_result(
|
|
result, target_credential=credential, intent_id=intent_id
|
|
)
|
|
assert error.value.code == "controller_result_secret_detected"
|
|
|
|
result["deploymentReceipt"] = {}
|
|
result["targetCredential"] = credential.decode("ascii")
|
|
with pytest.raises(cli.ControllerCliError) as error:
|
|
cli._serialize_result(
|
|
result, target_credential=credential, intent_id=intent_id
|
|
)
|
|
assert error.value.code == "controller_result_schema_invalid"
|
|
|
|
|
|
def test_disabled_controller_does_not_read_handoff_or_load_effects():
|
|
core = cli.CoreModules(
|
|
store=SimpleNamespace(EXECUTION_ENABLED=False),
|
|
executor=SimpleNamespace(EXECUTION_ENABLED=False),
|
|
)
|
|
effects_loaded = False
|
|
root_checked = False
|
|
|
|
class UnreadableInput:
|
|
def read(self, _size):
|
|
raise AssertionError("disabled controller must not read stdin")
|
|
|
|
def load_effects(_core):
|
|
nonlocal effects_loaded
|
|
effects_loaded = True
|
|
raise AssertionError("disabled controller must not construct effects")
|
|
|
|
def require_root():
|
|
nonlocal root_checked
|
|
root_checked = True
|
|
|
|
output = io.StringIO()
|
|
code = cli._run_cli(
|
|
argv=(),
|
|
stdin=UnreadableInput(),
|
|
stdout=output,
|
|
load_core=lambda: core,
|
|
load_effects=load_effects,
|
|
require_root=require_root,
|
|
)
|
|
|
|
assert code == 2
|
|
assert json.loads(output.getvalue()) == {
|
|
"status": "disabled",
|
|
"reason": "controller_adoption_execution_disabled",
|
|
}
|
|
assert not effects_loaded
|
|
assert root_checked
|
|
|
|
|
|
def test_nonroot_controller_rejects_before_loading_modules_or_reading_handoff():
|
|
core_loaded = False
|
|
|
|
class UnreadableInput:
|
|
def read(self, _size):
|
|
raise AssertionError("nonroot controller must not read stdin")
|
|
|
|
def load_core():
|
|
nonlocal core_loaded
|
|
core_loaded = True
|
|
raise AssertionError("nonroot controller must not load modules")
|
|
|
|
def require_root():
|
|
raise cli.ControllerCliError("controller_root_required")
|
|
|
|
output = io.StringIO()
|
|
code = cli._run_cli(
|
|
argv=(),
|
|
stdin=UnreadableInput(),
|
|
stdout=output,
|
|
load_core=load_core,
|
|
require_root=require_root,
|
|
)
|
|
|
|
assert code == 2
|
|
assert json.loads(output.getvalue()) == {
|
|
"status": "blocked",
|
|
"reason": "controller_root_required",
|
|
}
|
|
assert not core_loaded
|
|
|
|
|
|
def test_real_fixed_effect_modules_compose_without_side_effects():
|
|
core = cli._load_core_modules()
|
|
effects = cli._load_effect_modules(core)
|
|
handoff = _parsed()
|
|
store = core.store.RepoPulseAdoptionStore()
|
|
|
|
executor = cli._compose_executor(
|
|
core=core,
|
|
effects=effects,
|
|
store=store,
|
|
handoff=handoff,
|
|
)
|
|
|
|
assert isinstance(executor, core.executor.RepoPulseAdoptionExecutor)
|
|
assert executor._effects.__class__.__name__ == "_RevisionBoundRuntimeEffects"
|