157 lines
6.6 KiB
Python
157 lines
6.6 KiB
Python
from backend.app.services.action_execution import approval_execution_supported
|
|
from backend.app.services.action_registry import ACTION_RISK, ACTIONS, action_default_target
|
|
from backend.app.services.portfolio_deployment_enrollment import (
|
|
_ENROLL_SCRIPT,
|
|
_INSPECT_SCRIPT,
|
|
_LEGACY_REVISION,
|
|
_LEGACY_SINGLE_PROJECT,
|
|
_PROJECT,
|
|
_RECOVERY_FAILED_REVISION,
|
|
_RECOVERY_FAILURE_MESSAGE,
|
|
_RECOVERY_LIVE_REVISION,
|
|
_REPOSITORY,
|
|
PortfolioDeploymentEnrollment,
|
|
)
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, *, payload=None, text=""):
|
|
self._payload = payload
|
|
self.text = text
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _InspectPortainer:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def _request(self, method, path, **kwargs):
|
|
self.calls.append((method, path, kwargs))
|
|
if path.endswith("/exec"):
|
|
return _Response(payload={"Id": "exec-safe-inspect"})
|
|
if path.endswith("/start"):
|
|
return _Response(
|
|
text='{"branch":"main","compose_files":["docker-compose.yml"],"containers":["Portfolio","Portfolio-api"],"present":true}'
|
|
)
|
|
raise AssertionError(f"Unexpected Portainer path: {path}")
|
|
|
|
|
|
def test_portfolio_enrollment_contract_is_fixed_and_paired():
|
|
assert _REPOSITORY == "jens/portfolio"
|
|
assert _LEGACY_REVISION == "e59bfb3484531883e5486ee9e10b663ed0f6764f"
|
|
assert _PROJECT["branch"] == "main"
|
|
assert _PROJECT["exact_source"] is True
|
|
assert _PROJECT["env_source"] == "/srv/example/appdata/Portfolio/.env"
|
|
assert _PROJECT["compose_files"] == ["docker-compose.yml"]
|
|
assert _PROJECT["compose_project"] == "portfolio"
|
|
assert _PROJECT["containers"] == ["Portfolio", "Portfolio-api"]
|
|
assert _PROJECT["verify_oci_revision"] is False
|
|
assert _PROJECT["environment"] == {
|
|
"GIT_COMMIT": "{revision}",
|
|
"BUILD_ID": "build-{short_revision}",
|
|
}
|
|
|
|
|
|
def test_portfolio_enrollment_registry_alias_matches_controller_cache_bind_source():
|
|
assert 'registry_path = Path("/controller-config/projects.json")' in _ENROLL_SCRIPT
|
|
assert (
|
|
'host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")'
|
|
in _ENROLL_SCRIPT
|
|
)
|
|
assert "os.path.samefile(registry_path, host_registry_path)" in _ENROLL_SCRIPT
|
|
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in _ENROLL_SCRIPT
|
|
assert '/mnt/cache/appdata/gitea-deploy-runners/deploy.lock' in _ENROLL_SCRIPT
|
|
|
|
|
|
def test_portfolio_enrollment_uses_project_then_global_lock_order_without_child_deadlock():
|
|
project_lock = "fcntl.flock(project_lock, fcntl.LOCK_EX)"
|
|
cache_lock = "fcntl.flock(lock, fcntl.LOCK_EX)"
|
|
|
|
assert _ENROLL_SCRIPT.index(project_lock) < _ENROLL_SCRIPT.index(cache_lock)
|
|
assert "controller.bootstrap_legacy_state(repository, project, legacy_revision)" in _ENROLL_SCRIPT
|
|
assert '["python3", "/opt/gitea-deploy/deploy.py", "bootstrap-legacy"' not in _ENROLL_SCRIPT
|
|
|
|
|
|
def test_portfolio_enrollment_only_migrates_known_legacy_single_target():
|
|
assert _LEGACY_SINGLE_PROJECT["containers"] == ["portfolio"]
|
|
assert _LEGACY_SINGLE_PROJECT["compose_project"] == "portfolio"
|
|
assert _LEGACY_SINGLE_PROJECT["compose_files"] == ["docker-compose.yml"]
|
|
assert "env_source" not in _LEGACY_SINGLE_PROJECT
|
|
|
|
|
|
def test_portfolio_target_registration_is_a_typed_high_risk_action():
|
|
action = "deployment.target.register_portfolio"
|
|
assert action in ACTIONS
|
|
assert ACTION_RISK[action] == "high"
|
|
assert action_default_target(action) == "jens/portfolio"
|
|
assert approval_execution_supported(action) is True
|
|
|
|
|
|
def test_portfolio_target_inspection_returns_only_sanitized_json_from_controller():
|
|
portainer = _InspectPortainer()
|
|
service = PortfolioDeploymentEnrollment(portainer=portainer)
|
|
|
|
result = service._inspect_current_target(endpoint="1", container_id="controller-id")
|
|
|
|
assert result == {
|
|
"branch": "main",
|
|
"compose_files": ["docker-compose.yml"],
|
|
"containers": ["Portfolio", "Portfolio-api"],
|
|
"present": True,
|
|
}
|
|
assert portainer.calls[0][2]["json"]["AttachStdout"] is True
|
|
assert portainer.calls[0][2]["json"]["Tty"] is True
|
|
assert portainer.calls[1][2]["json"] == {"Detach": False, "Tty": True}
|
|
|
|
|
|
def test_portfolio_target_inspection_exposes_only_bounded_state_diagnostics():
|
|
assert (
|
|
'state_path = Path("/mnt/cache/appdata/gitea-deploy-runners/state/portfolio.json")'
|
|
in _INSPECT_SCRIPT
|
|
)
|
|
for allowed in (
|
|
'"schema_version"',
|
|
'"live_revision"',
|
|
'"live_status"',
|
|
'"live_legacy_provenance"',
|
|
'"last_attempt"',
|
|
'"live_artifact_containers"',
|
|
):
|
|
assert allowed in _INSPECT_SCRIPT
|
|
assert 'safe_attempt["message"]' in _INSPECT_SCRIPT
|
|
assert "[:768]" in _INSPECT_SCRIPT
|
|
assert 'state.get("live_receipt")' not in _INSPECT_SCRIPT
|
|
assert 'state.get("rollback_snapshot")' not in _INSPECT_SCRIPT
|
|
|
|
|
|
def test_portfolio_runtime_reconciliation_is_pinned_to_observed_failure():
|
|
assert _RECOVERY_LIVE_REVISION == "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e"
|
|
assert _RECOVERY_FAILED_REVISION == "b7955d90b63860ffff8e0bd84be123aaba632043"
|
|
assert (
|
|
_RECOVERY_FAILURE_MESSAGE
|
|
== "Existing production containers require a valid healthy live deployment state"
|
|
)
|
|
assert 'existing_state.get("live_status") == "unknown"' in _ENROLL_SCRIPT
|
|
assert 'existing_state.get("live_legacy_provenance") is False' in _ENROLL_SCRIPT
|
|
assert 'last_attempt.get("revision") == recovery_failed_revision' in _ENROLL_SCRIPT
|
|
assert 'last_attempt.get("message") == recovery_failure_message' in _ENROLL_SCRIPT
|
|
assert 'isinstance(live_receipt, dict)' in _ENROLL_SCRIPT
|
|
assert 'bool(live_receipt)' in _ENROLL_SCRIPT
|
|
assert 'set(artifact_containers) == set(project["containers"])' in _ENROLL_SCRIPT
|
|
|
|
|
|
def test_portfolio_runtime_reconciliation_proves_exact_recorded_artifacts():
|
|
assert 'revision_label != recovery_live_revision' in _ENROLL_SCRIPT
|
|
assert 'build_label != f"build-{recovery_live_revision[:12]}"' in _ENROLL_SCRIPT
|
|
assert 'artifact.get("image_id")' in _ENROLL_SCRIPT
|
|
assert 'artifact.get("container_id")' in _ENROLL_SCRIPT
|
|
assert 'str(container.get("Image") or "")' in _ENROLL_SCRIPT
|
|
assert 'str(container.get("Id") or "")' in _ENROLL_SCRIPT
|
|
assert 'reconciled_state = dict(existing_state)' in _ENROLL_SCRIPT
|
|
assert 'reconciled_state["live_status"] = "healthy"' in _ENROLL_SCRIPT
|
|
assert 'reconciled_state["status"] = "success"' in _ENROLL_SCRIPT
|
|
assert 'bool(verify.get("live_receipt"))' in _ENROLL_SCRIPT
|
|
assert "runtime-state-reconciled" in _ENROLL_SCRIPT
|