Files
Unraid-AppOps-Gateway-Public/backend/tests/test_gitea_project_auto_registration.py
T

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"}