Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,395 @@
|
||||
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["serviceNames"] == ["api", "worker"]
|
||||
assert contract["containers"] == ["api-api-1", "api-worker-1"]
|
||||
|
||||
|
||||
def test_descriptor_cannot_diverge_compose_project_from_application_id():
|
||||
with pytest.raises(ValueError, match="composeProject must equal applicationId"):
|
||||
GiteaProjectAutoRegistration._safe_descriptor(
|
||||
{
|
||||
"apiVersion": "itworx.deployment-target/v1",
|
||||
"applicationId": "api",
|
||||
"composeProject": "different",
|
||||
},
|
||||
"Example",
|
||||
)
|
||||
|
||||
|
||||
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_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"}
|
||||
Reference in new issue
Block a user