480 lines
16 KiB
Python
480 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from sqlalchemy.pool import StaticPool
|
|
from sqlmodel import Session, SQLModel, create_engine, select
|
|
|
|
from backend.app.db.models import Policy
|
|
from backend.app.services.gitea_project_auto_registration import (
|
|
GiteaProjectAutoRegistration,
|
|
)
|
|
|
|
|
|
class FakeClient:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class FakeTargetRegistry:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def register_compose_target(self, repository, revision, **kwargs):
|
|
self.calls.append((repository, revision, kwargs))
|
|
return {
|
|
"status": "registered",
|
|
"repository": repository,
|
|
"revision": revision,
|
|
}
|
|
|
|
|
|
class FakeProjectSync(GiteaProjectAutoRegistration):
|
|
def __init__(self, repositories, contracts, *, target_registry):
|
|
super().__init__(target_registry=target_registry)
|
|
self.repositories = repositories
|
|
self.contracts = contracts
|
|
self.branch_calls = []
|
|
self.contract_calls = []
|
|
|
|
def _client(self):
|
|
return FakeClient()
|
|
|
|
def _repositories(self, client):
|
|
return list(self.repositories)
|
|
|
|
def _branch_head(self, client, *, repository_name, branch):
|
|
self.branch_calls.append((repository_name, branch))
|
|
return (repository_name[0].lower() if repository_name else "a") * 40
|
|
|
|
def _compose_contract(self, client, *, repository_name, revision):
|
|
self.contract_calls.append((repository_name, revision))
|
|
return self.contracts.get(repository_name)
|
|
|
|
|
|
class FakeComposeInspection(GiteaProjectAutoRegistration):
|
|
def __init__(self, root_names, files):
|
|
super().__init__(target_registry=FakeTargetRegistry())
|
|
self.root_names = list(root_names)
|
|
self.files = dict(files)
|
|
|
|
def _contents(
|
|
self,
|
|
client,
|
|
*,
|
|
repository_name,
|
|
revision,
|
|
path="",
|
|
allow_not_found=False,
|
|
):
|
|
if not path:
|
|
return [{"name": name} for name in self.root_names]
|
|
raise AssertionError(f"unexpected direct contents read: {path}")
|
|
|
|
def _read_text(
|
|
self,
|
|
client,
|
|
*,
|
|
repository_name,
|
|
revision,
|
|
path,
|
|
allow_not_found=False,
|
|
):
|
|
value = self.files.get(path)
|
|
if value is None and not allow_not_found:
|
|
raise AssertionError(f"missing fake file: {path}")
|
|
return value
|
|
|
|
|
|
def make_session():
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
SQLModel.metadata.create_all(engine)
|
|
return Session(engine)
|
|
|
|
|
|
def repo(name, *, branch="main", updated="2026-09-16T12:00:00Z", archived=False, empty=False):
|
|
return {
|
|
"name": name,
|
|
"full_name": f"Jens/{name}",
|
|
"default_branch": branch,
|
|
"updated_at": updated,
|
|
"archived": archived,
|
|
"empty": empty,
|
|
"private": True,
|
|
}
|
|
|
|
|
|
def deployable_contract():
|
|
return {
|
|
"applicationId": "deployable",
|
|
"composeFile": "docker-compose.yml",
|
|
"composeProject": "deployable",
|
|
"containers": ["deployable"],
|
|
"serviceNames": ["deployable"],
|
|
"healthTimeout": 180,
|
|
"verifyOciRevision": False,
|
|
}
|
|
|
|
|
|
def test_repository_names_normalize_to_deployment_plan_application_ids():
|
|
assert GiteaProjectAutoRegistration._application_id("ITWorx_MCP_Hub") == "itworx-mcp-hub"
|
|
assert GiteaProjectAutoRegistration._application_id("Media.Codec_Dashboard") == "media-codec-dashboard"
|
|
|
|
|
|
def test_single_service_compose_can_be_inferred_without_descriptor():
|
|
service = FakeComposeInspection(
|
|
["docker-compose.yml"],
|
|
{
|
|
"docker-compose.yml": "services:\n my-app:\n image: example/app:1\n",
|
|
},
|
|
)
|
|
contract = service._compose_contract(
|
|
FakeClient(), repository_name="My_App", revision="a" * 40
|
|
)
|
|
assert contract is not None
|
|
assert contract["applicationId"] == "my-app"
|
|
assert contract["composeProject"] == "my-app"
|
|
assert contract["serviceNames"] == ["my-app"]
|
|
assert contract["containers"] == ["my-app-my-app-1"]
|
|
|
|
|
|
def test_ambiguous_multi_service_compose_stays_known_but_unconfigured():
|
|
service = FakeComposeInspection(
|
|
["compose.yml"],
|
|
{
|
|
"compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n",
|
|
},
|
|
)
|
|
assert (
|
|
service._compose_contract(
|
|
FakeClient(), repository_name="DifferentName", revision="b" * 40
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_descriptor_can_select_primary_service_for_multi_service_project():
|
|
descriptor = {
|
|
"apiVersion": "itworx.deployment-target/v1",
|
|
"applicationId": "api",
|
|
"composeFile": "compose.yml",
|
|
}
|
|
service = FakeComposeInspection(
|
|
[".itworx", "compose.yml"],
|
|
{
|
|
".itworx/deployment-target.json": json.dumps(descriptor),
|
|
"compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n",
|
|
},
|
|
)
|
|
contract = service._compose_contract(
|
|
FakeClient(), repository_name="MultiService", revision="c" * 40
|
|
)
|
|
assert contract is not None
|
|
assert contract["applicationId"] == "api"
|
|
assert contract["composeProject"] == "api"
|
|
assert contract["primaryService"] == "api"
|
|
assert contract["serviceNames"] == ["api", "worker"]
|
|
assert contract["containers"] == ["api-api-1", "api-worker-1"]
|
|
|
|
|
|
def test_descriptor_separates_application_identity_compose_project_and_primary_service():
|
|
descriptor = {
|
|
"apiVersion": "itworx.deployment-target/v1",
|
|
"applicationId": "quizmonitor",
|
|
"composeProject": "quiz-monitor",
|
|
"primaryService": "quiz-server",
|
|
"composeFile": "docker-compose.yml",
|
|
}
|
|
service = FakeComposeInspection(
|
|
[".itworx", "docker-compose.yml"],
|
|
{
|
|
".itworx/deployment-target.json": json.dumps(descriptor),
|
|
"docker-compose.yml": "services:\n quiz-server:\n image: example/quiz-server:1\n",
|
|
},
|
|
)
|
|
|
|
contract = service._compose_contract(
|
|
FakeClient(), repository_name="QuizMonitor", revision="d" * 40
|
|
)
|
|
|
|
assert contract == {
|
|
"applicationId": "quizmonitor",
|
|
"composeFile": "docker-compose.yml",
|
|
"composeProject": "quiz-monitor",
|
|
"primaryService": "quiz-server",
|
|
"containers": ["quiz-monitor-quiz-server-1"],
|
|
"serviceNames": ["quiz-server"],
|
|
"healthTimeout": 180,
|
|
"verifyOciRevision": False,
|
|
}
|
|
|
|
|
|
def test_descriptor_rejects_invalid_compose_project_and_missing_primary_service():
|
|
with pytest.raises(ValueError, match="composeProject is invalid"):
|
|
GiteaProjectAutoRegistration._safe_descriptor(
|
|
{
|
|
"apiVersion": "itworx.deployment-target/v1",
|
|
"applicationId": "quizmonitor",
|
|
"composeProject": "../quiz-monitor",
|
|
"primaryService": "quiz-server",
|
|
},
|
|
"QuizMonitor",
|
|
)
|
|
|
|
service = FakeComposeInspection(
|
|
[".itworx", "compose.yml"],
|
|
{
|
|
".itworx/deployment-target.json": json.dumps({
|
|
"apiVersion": "itworx.deployment-target/v1",
|
|
"applicationId": "quizmonitor",
|
|
"composeProject": "quiz-monitor",
|
|
"primaryService": "quiz-server",
|
|
}),
|
|
"compose.yml": "services:\n web:\n image: example/web:1\n",
|
|
},
|
|
)
|
|
with pytest.raises(ValueError, match="primaryService must name a Compose service"):
|
|
service._compose_contract(
|
|
FakeClient(), repository_name="QuizMonitor", revision="e" * 40
|
|
)
|
|
|
|
|
|
def test_sync_catalogs_every_repo_but_only_activates_proven_compose_targets():
|
|
registry = FakeTargetRegistry()
|
|
contract = deployable_contract()
|
|
contract["containers"] = ["deployable-api-1", "deployable-web-1"]
|
|
contract["serviceNames"] = ["deployable", "worker"]
|
|
service = FakeProjectSync(
|
|
[repo("Deployable"), repo("DocsOnly"), repo("Legacy", branch="master")],
|
|
{
|
|
"Deployable": contract,
|
|
"DocsOnly": None,
|
|
"Legacy": None,
|
|
},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
result = service.sync(session)
|
|
assert result["status"] == "ok"
|
|
assert result["summary"]["total"] == 3
|
|
assert result["summary"]["registered"] == 1
|
|
assert result["summary"]["knownUnconfigured"] == 2
|
|
assert len(registry.calls) == 1
|
|
repository, revision, kwargs = registry.calls[0]
|
|
assert repository == "jens/deployable"
|
|
assert len(revision) == 40
|
|
assert kwargs["branch"] == "main"
|
|
assert kwargs["containers"] == ["deployable-api-1", "deployable-web-1"]
|
|
|
|
policies = session.exec(
|
|
select(Policy).where(Policy.target == "deployable")
|
|
).all()
|
|
assert {item.action for item in policies} == {
|
|
"deployment.apply",
|
|
"deployment.rollback",
|
|
"deployment.runnerless.provision",
|
|
}
|
|
assert all(item.manual == "allowed" for item in policies)
|
|
assert all(item.voice == "blocked" for item in policies)
|
|
|
|
|
|
def test_sync_registers_quizmonitor_identity_without_changing_compose_project_or_service():
|
|
registry = FakeTargetRegistry()
|
|
contract = {
|
|
"applicationId": "quizmonitor",
|
|
"composeFile": "docker-compose.yml",
|
|
"composeProject": "quiz-monitor",
|
|
"primaryService": "quiz-server",
|
|
"containers": ["quiz-monitor-quiz-server-1"],
|
|
"serviceNames": ["quiz-server"],
|
|
"healthTimeout": 180,
|
|
"verifyOciRevision": False,
|
|
}
|
|
service = FakeProjectSync(
|
|
[repo("QuizMonitor")],
|
|
{"QuizMonitor": contract},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
result = service.sync(session)
|
|
catalog = GiteaProjectAutoRegistration.catalog(session)
|
|
|
|
assert result["summary"]["registered"] == 1
|
|
assert len(registry.calls) == 1
|
|
repository, revision, kwargs = registry.calls[0]
|
|
assert repository == "jens/quizmonitor"
|
|
assert kwargs["compose_project"] == "quiz-monitor"
|
|
assert kwargs["containers"] == ["quiz-monitor-quiz-server-1"]
|
|
assert catalog["projects"][0]["applicationId"] == "quizmonitor"
|
|
assert catalog["projects"][0]["composeProject"] == "quiz-monitor"
|
|
assert catalog["projects"][0]["primaryService"] == "quiz-server"
|
|
|
|
|
|
def test_unchanged_catalog_entries_do_not_repeat_registration_work():
|
|
registry = FakeTargetRegistry()
|
|
repositories = [repo("Deployable")]
|
|
service = FakeProjectSync(
|
|
repositories,
|
|
{"Deployable": deployable_contract()},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
first = service.sync(session)
|
|
assert first["summary"]["registered"] == 1
|
|
assert len(registry.calls) == 1
|
|
first_branch_calls = len(service.branch_calls)
|
|
first_contract_calls = len(service.contract_calls)
|
|
|
|
second = service.sync(session)
|
|
assert second["summary"]["registered"] == 1
|
|
assert second["summary"]["changedThisSync"] == 0
|
|
assert len(registry.calls) == 1
|
|
assert len(service.branch_calls) == first_branch_calls
|
|
assert len(service.contract_calls) == first_contract_calls
|
|
|
|
|
|
def test_existing_exact_policy_is_not_repurposed_when_it_blocks_deployments():
|
|
registry = FakeTargetRegistry()
|
|
service = FakeProjectSync(
|
|
[repo("Deployable")],
|
|
{"Deployable": deployable_contract()},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
session.add(
|
|
Policy(
|
|
id="operator-owned-policy",
|
|
action="deployment.apply",
|
|
target="deployable",
|
|
voice="blocked",
|
|
manual="blocked",
|
|
risk="high",
|
|
reason="operator-owned denial",
|
|
)
|
|
)
|
|
session.commit()
|
|
result = service.sync(session)
|
|
project = result["projects"][0]
|
|
assert project["state"] == "policy_conflict"
|
|
preserved = session.get(Policy, "operator-owned-policy")
|
|
assert preserved is not None
|
|
assert preserved.manual == "blocked"
|
|
policies = session.exec(select(Policy).where(Policy.target == "deployable")).all()
|
|
assert [item.id for item in policies] == ["operator-owned-policy"]
|
|
|
|
|
|
def test_rollback_policy_conflict_does_not_stage_or_commit_apply_policy():
|
|
registry = FakeTargetRegistry()
|
|
service = FakeProjectSync(
|
|
[repo("Deployable")],
|
|
{"Deployable": deployable_contract()},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
session.add(
|
|
Policy(
|
|
id="operator-owned-rollback-denial",
|
|
action="deployment.rollback",
|
|
target="deployable",
|
|
voice="blocked",
|
|
manual="blocked",
|
|
risk="high",
|
|
reason="operator-owned rollback denial",
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
result = service.sync(session)
|
|
assert result["projects"][0]["state"] == "policy_conflict"
|
|
policies = session.exec(select(Policy).where(Policy.target == "deployable")).all()
|
|
assert [(item.id, item.action, item.manual) for item in policies] == [
|
|
("operator-owned-rollback-denial", "deployment.rollback", "blocked")
|
|
]
|
|
|
|
def test_unchanged_controller_conflict_is_retried_and_can_self_heal():
|
|
class ConflictThenRegisteredRegistry(FakeTargetRegistry):
|
|
def register_compose_target(self, repository, revision, **kwargs):
|
|
self.calls.append((repository, revision, kwargs))
|
|
if len(self.calls) == 1:
|
|
return {
|
|
"status": "conflict",
|
|
"repository": repository,
|
|
"revision": revision,
|
|
"reason": "deployment target already exists with different topology",
|
|
}
|
|
return {
|
|
"status": "registered",
|
|
"repository": repository,
|
|
"revision": revision,
|
|
}
|
|
|
|
registry = ConflictThenRegisteredRegistry()
|
|
service = FakeProjectSync(
|
|
[repo("Deployable")],
|
|
{"Deployable": deployable_contract()},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
first = service.sync(session)
|
|
assert first["projects"][0]["state"] == "controller_conflict"
|
|
assert len(registry.calls) == 1
|
|
|
|
second = service.sync(session)
|
|
assert second["projects"][0]["state"] == "registered"
|
|
assert second["summary"]["changedThisSync"] == 1
|
|
assert len(registry.calls) == 2
|
|
assert len(second["projects"][0]["policyIds"]) == 3
|
|
|
|
|
|
def test_unchanged_policy_conflict_is_retried_after_operator_owned_policy_is_repaired():
|
|
registry = FakeTargetRegistry()
|
|
service = FakeProjectSync(
|
|
[repo("Deployable")],
|
|
{"Deployable": deployable_contract()},
|
|
target_registry=registry,
|
|
)
|
|
|
|
with make_session() as session:
|
|
policy = Policy(
|
|
id="operator-owned-policy",
|
|
action="deployment.apply",
|
|
target="deployable",
|
|
voice="blocked",
|
|
manual="blocked",
|
|
risk="high",
|
|
reason="operator-owned denial",
|
|
)
|
|
session.add(policy)
|
|
session.commit()
|
|
|
|
first = service.sync(session)
|
|
assert first["projects"][0]["state"] == "policy_conflict"
|
|
assert len(registry.calls) == 1
|
|
|
|
policy.manual = "allowed"
|
|
session.add(policy)
|
|
session.commit()
|
|
|
|
second = service.sync(session)
|
|
assert second["projects"][0]["state"] == "registered"
|
|
assert second["summary"]["changedThisSync"] == 1
|
|
assert len(registry.calls) == 2
|
|
assert {
|
|
item.action
|
|
for item in session.exec(select(Policy).where(Policy.target == "deployable")).all()
|
|
} == {"deployment.apply", "deployment.rollback", "deployment.runnerless.provision"}
|