from __future__ import annotations import base64 import hashlib import json import re from typing import Any from urllib.parse import quote, urlsplit, urlunsplit import httpx import yaml from sqlmodel import Session, select from ..core.config import settings from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import AppSetting, Policy from .deployment_target_registry import DeploymentTargetRegistry _CATALOG_KEY = "deployment:gitea-project-catalog:v1" _OWNER = "Jens" _DESCRIPTOR = ".itworx/deployment-target.json" _COMPOSE_CANDIDATES = ( "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", ) # Must remain identical to DeploymentPlanV1.application_id. _APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") _TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"} _RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"} _DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision") class GiteaProjectAutoRegistration: """Keep AppOps' project catalog aligned with the owned Gitea portfolio. Every repository is recorded in the catalog. A repository becomes an executable deployment target only when an exact revision exposes a bounded Compose contract compatible with DeploymentPlanV1. Exact deployment policies are created only after the controller target has been registered successfully. Existing targets and policies are never overwritten. """ def __init__( self, *, target_registry: DeploymentTargetRegistry | None = None, owner: str = _OWNER, ) -> None: self.target_registry = target_registry or DeploymentTargetRegistry() self.owner = owner @staticmethod def _base_url() -> str: raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/") parsed = urlsplit(raw) if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username: raise RuntimeError("invalid_gitea_url") return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) @staticmethod def _token() -> str: token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "").strip() if not token: raise RuntimeError("missing_gitea_admin_token") return token def _client(self) -> httpx.Client: timeout = max(2.0, min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8))) return httpx.Client( timeout=timeout, verify=bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)), headers={ "Authorization": f"token {self._token()}", "Accept": "application/json", }, ) def _get( self, client: httpx.Client, path: str, *, params: dict[str, Any] | None = None, allow_not_found: bool = False, ) -> Any: try: response = client.get(f"{self._base_url()}{path}", params=params) except (httpx.RequestError, ValueError) as exc: raise RuntimeError(redact_message(str(exc))) from exc if allow_not_found and response.status_code == 404: return None if response.status_code in {401, 403}: raise RuntimeError("gitea_admin_access_denied") if response.status_code >= 400: raise RuntimeError(f"gitea_project_catalog_http_{response.status_code}") try: return response.json() except ValueError as exc: raise RuntimeError("gitea_project_catalog_invalid_response") from exc def _repositories(self, client: httpx.Client) -> list[dict[str, Any]]: owner = quote(self.owner, safe="") repositories: list[dict[str, Any]] = [] for page in range(1, 11): payload = self._get( client, f"/api/v1/users/{owner}/repos", params={"page": page, "limit": 50}, ) if not isinstance(payload, list): raise RuntimeError("gitea_project_catalog_invalid_repository_list") items = [item for item in payload if isinstance(item, dict)] repositories.extend(items) if len(items) < 50: break return repositories[:500] def _branch_head( self, client: httpx.Client, *, repository_name: str, branch: str, ) -> str: owner = quote(self.owner, safe="") repository = quote(repository_name, safe="") branch_ref = quote(branch, safe="") payload = self._get( client, f"/api/v1/repos/{owner}/{repository}/branches/{branch_ref}", ) commit = payload.get("commit") if isinstance(payload, dict) else None revision = "" if isinstance(commit, dict): revision = str(commit.get("id") or commit.get("sha") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{40}", revision): raise RuntimeError("gitea_default_branch_head_unavailable") return revision def _contents( self, client: httpx.Client, *, repository_name: str, revision: str, path: str = "", allow_not_found: bool = False, ) -> Any: owner = quote(self.owner, safe="") repository = quote(repository_name, safe="") suffix = f"/{quote(path, safe='/')}" if path else "" return self._get( client, f"/api/v1/repos/{owner}/{repository}/contents{suffix}", params={"ref": revision}, allow_not_found=allow_not_found, ) def _read_text( self, client: httpx.Client, *, repository_name: str, revision: str, path: str, allow_not_found: bool = False, ) -> str | None: payload = self._contents( client, repository_name=repository_name, revision=revision, path=path, allow_not_found=allow_not_found, ) if payload is None: return None if not isinstance(payload, dict): raise RuntimeError("gitea_project_file_invalid_response") if str(payload.get("encoding") or "").lower() != "base64": raise RuntimeError("gitea_project_file_encoding_unsupported") content = str(payload.get("content") or "").replace("\n", "") try: raw = base64.b64decode(content, validate=True) return raw.decode("utf-8") except (ValueError, UnicodeDecodeError) as exc: raise RuntimeError("gitea_project_file_decode_failed") from exc @staticmethod def _application_id(repository_name: str) -> str: raw = repository_name.lower() value = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")[:63] if not _APP_ID.fullmatch(value): raise ValueError("repository name cannot be converted to a DeploymentPlanV1 application id") return value @staticmethod def _safe_descriptor(payload: Any, repository_name: str) -> dict[str, Any]: if payload is None: return {} if not isinstance(payload, dict): raise ValueError("deployment descriptor must contain a JSON object") allowed = { "apiVersion", "applicationId", "composeFile", "composeProject", "healthTimeout", "verifyOciRevision", } unknown = set(payload) - allowed if unknown: raise ValueError("deployment descriptor contains unsupported fields") if payload.get("apiVersion") not in {None, "itworx.deployment-target/v1"}: raise ValueError("deployment descriptor apiVersion is unsupported") application_id = str( payload.get("applicationId") or GiteaProjectAutoRegistration._application_id(repository_name) ).strip().lower() if not _APP_ID.fullmatch(application_id): raise ValueError("deployment descriptor applicationId is invalid") compose_file = str(payload.get("composeFile") or "").strip() compose_project = str(payload.get("composeProject") or application_id).strip().lower() if compose_project != application_id: raise ValueError("deployment descriptor composeProject must equal applicationId") timeout = int(payload.get("healthTimeout") or 180) return { "applicationId": application_id, "composeFile": compose_file, "composeProject": compose_project, "healthTimeout": timeout, "verifyOciRevision": bool(payload.get("verifyOciRevision", False)), } def _compose_contract( self, client: httpx.Client, *, repository_name: str, revision: str, ) -> dict[str, Any] | None: root = self._contents( client, repository_name=repository_name, revision=revision, ) if not isinstance(root, list): raise RuntimeError("gitea_project_root_invalid_response") names = { str(item.get("name") or "") for item in root if isinstance(item, dict) } descriptor: dict[str, Any] = {} descriptor_present = False if ".itworx" in names: raw_descriptor = self._read_text( client, repository_name=repository_name, revision=revision, path=_DESCRIPTOR, allow_not_found=True, ) if raw_descriptor is not None: descriptor_present = True try: descriptor = self._safe_descriptor(json.loads(raw_descriptor), repository_name) except (json.JSONDecodeError, TypeError, ValueError) as exc: raise ValueError(f"invalid deployment descriptor: {exc}") from exc compose_file = str(descriptor.get("composeFile") or "") if not compose_file: compose_file = next((candidate for candidate in _COMPOSE_CANDIDATES if candidate in names), "") if not compose_file: return None raw_compose = self._read_text( client, repository_name=repository_name, revision=revision, path=compose_file, ) if raw_compose is None: raise ValueError("configured Compose file is unavailable") try: compose = yaml.safe_load(raw_compose) except yaml.YAMLError as exc: raise ValueError("Compose file is not valid YAML") from exc services = compose.get("services") if isinstance(compose, dict) else None if not isinstance(services, dict) or not services: raise ValueError("Compose file does not define services") if len(services) > 8: if descriptor_present: raise ValueError("deployment descriptor exceeds DeploymentPlanV1's eight-service bound") return None service_names = [str(name).strip() for name in services] if any(not name for name in service_names): raise ValueError("Compose service name is invalid") if descriptor_present: application_id = str(descriptor["applicationId"]) if application_id not in service_names: raise ValueError("deployment descriptor applicationId must name a Compose service") else: repository_id = self._application_id(repository_name) compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else "" candidates: list[str] = [] for candidate in (repository_id, compose_name): if candidate and _APP_ID.fullmatch(candidate) and candidate in service_names: if candidate not in candidates: candidates.append(candidate) if len(service_names) == 1 and _APP_ID.fullmatch(service_names[0]): if service_names[0] not in candidates: candidates.append(service_names[0]) if len(candidates) != 1: # The repository remains cataloged, but deployment requires an # explicit bounded descriptor to select its primary service. return None application_id = candidates[0] compose_project = application_id containers: list[str] = [] for service_name, raw_service in services.items(): service = raw_service if isinstance(raw_service, dict) else {} explicit_name = str(service.get("container_name") or "").strip() container = explicit_name or f"{compose_project}-{service_name}-1" containers.append(container) return { "applicationId": application_id, "composeFile": compose_file, "composeProject": compose_project, "containers": containers, "serviceNames": service_names, "healthTimeout": int(descriptor.get("healthTimeout") or 180), "verifyOciRevision": bool(descriptor.get("verifyOciRevision", False)), } @staticmethod def _policy_id(application_id: str, action: str) -> str: digest = hashlib.sha256(f"{action}:{application_id}".encode("utf-8")).hexdigest()[:20] suffix = action.rsplit(".", 1)[-1] return f"gitea-auto-{application_id[:32]}-{suffix}-{digest}" @staticmethod def _existing_exact_policy_ids( session: Session, *, application_id: str, ) -> tuple[bool, list[str]]: identifiers: list[str] = [] for action in _DEPLOYMENT_ACTIONS: existing = session.exec( select(Policy).where( (Policy.action == action) & (Policy.target == application_id) ) ).first() if existing is None or existing.manual != "allowed": return False, [] identifiers.append(existing.id) return True, identifiers @staticmethod def _ensure_exact_policies( session: Session, *, application_id: str, repository: str, revision: str, ) -> tuple[bool, list[str], int]: existing_by_action: dict[str, Policy | None] = {} for action in _DEPLOYMENT_ACTIONS: existing = session.exec( select(Policy).where( (Policy.action == action) & (Policy.target == application_id) ) ).first() if existing is not None and existing.manual != "allowed": return False, [], 0 existing_by_action[action] = existing identifiers: list[str] = [] created = 0 for action in _DEPLOYMENT_ACTIONS: existing = existing_by_action[action] if existing is not None: identifiers.append(existing.id) continue policy_id = GiteaProjectAutoRegistration._policy_id(application_id, action) row = Policy( id=policy_id, action=action, target=application_id, voice="blocked", manual="allowed", risk="high", reason="Gitea auto-registration generated exact-target deployment policy.", provenance_json={ "createdBy": "gitea-project-auto-registration/v1", "repository": repository, "sourceRevision": revision, "createdAt": utc_now().isoformat(), "policyTemplateVersion": "exact-target/v1", }, ) session.add(row) identifiers.append(policy_id) created += 1 session.commit() return True, identifiers, created @staticmethod def catalog(session: Session) -> dict[str, Any]: row = session.get(AppSetting, _CATALOG_KEY) if row is None or row.is_secret: return { "status": "never_synced", "owner": _OWNER, "projects": [], "summary": {"total": 0}, } return dict(row.value_json or {}) @staticmethod def _marker(repo: dict[str, Any]) -> dict[str, Any]: return { "updatedAt": str(repo.get("updated_at") or ""), "defaultBranch": str(repo.get("default_branch") or ""), "archived": bool(repo.get("archived")), "empty": bool(repo.get("empty")), "private": bool(repo.get("private")), } def sync(self, session: Session, *, force: bool = False) -> dict[str, Any]: previous = self.catalog(session) previous_items = { str(item.get("repository") or ""): item for item in previous.get("projects", []) if isinstance(item, dict) } try: client = self._client() with client: repositories = self._repositories(client) projects: list[dict[str, Any]] = [] controller_registered = 0 policies_created = 0 changed = 0 for repo in sorted( repositories, key=lambda item: str(item.get("full_name") or item.get("name") or "").casefold(), ): name = str(repo.get("name") or "").strip() if not name: continue repository = f"jens/{name.lower()}" marker = self._marker(repo) prior = previous_items.get(repository) if ( not force and prior is not None and all(prior.get(key) == value for key, value in marker.items()) and str(prior.get("state") or "") not in _RETRY_STATES ): projects.append(dict(prior)) continue changed += 1 item: dict[str, Any] = { "repository": repository, "name": name, **marker, "state": "known", "deploymentRegistered": False, "policyIds": [], } if marker["archived"]: item["state"] = "archived" projects.append(item) continue branch = str(marker["defaultBranch"] or "").strip() if marker["empty"] or not branch: item["state"] = "empty" projects.append(item) continue try: revision = self._branch_head( client, repository_name=name, branch=branch, ) item["revision"] = revision contract = self._compose_contract( client, repository_name=name, revision=revision, ) if contract is None: item["state"] = "known_unconfigured" projects.append(item) continue application_id = str(contract["applicationId"]) item.update( { "applicationId": application_id, "composeFile": contract["composeFile"], "composeProject": contract["composeProject"], "containers": list(contract["containers"]), "serviceNames": list(contract["serviceNames"]), } ) result = self.target_registry.register_compose_target( repository, revision, branch=branch, compose_file=str(contract["composeFile"]), compose_project=str(contract["composeProject"]), containers=list(contract["containers"]), health_timeout=int(contract["healthTimeout"]), verify_oci_revision=bool(contract["verifyOciRevision"]), ) result_status = str(result.get("status") or "error") if result_status == "registered": policy_ok, policy_ids, created = self._ensure_exact_policies( session, application_id=application_id, repository=repository, revision=revision, ) policies_created += created if policy_ok: item["state"] = "registered" item["deploymentRegistered"] = True item["policyIds"] = policy_ids controller_registered += 1 else: item["state"] = "policy_conflict" elif result_status == "conflict": policy_ok, policy_ids = self._existing_exact_policy_ids( session, application_id=application_id, ) if policy_ok: item["state"] = "registered_existing" item["deploymentRegistered"] = True item["policyIds"] = policy_ids else: item["state"] = "controller_conflict" item["reason"] = str(result.get("reason") or "")[:240] elif result_status in {"controller_unavailable", "connector_unavailable"}: item["state"] = "controller_unavailable" item["reason"] = str(result.get("reason") or "")[:240] else: item["state"] = "registration_error" item["reason"] = str(result.get("reason") or result_status)[:240] except (RuntimeError, TypeError, ValueError, yaml.YAMLError) as exc: item["state"] = "registration_error" item["reason"] = redact_message(str(exc))[:240] projects.append(item) except (RuntimeError, ValueError) as exc: return { **previous, "status": "gitea_unavailable", "reason": redact_message(str(exc))[:240], } summary = { "total": len(projects), "registered": sum( str(item.get("state")) in {"registered", "registered_existing"} for item in projects ), "knownUnconfigured": sum( str(item.get("state")) == "known_unconfigured" for item in projects ), "archived": sum(str(item.get("state")) == "archived" for item in projects), "empty": sum(str(item.get("state")) == "empty" for item in projects), "conflicts": sum( str(item.get("state")) in {"controller_conflict", "policy_conflict"} for item in projects ), "errors": sum( str(item.get("state")) in _TRANSIENT_STATES for item in projects ), "changedThisSync": changed, "controllerTargetsRegisteredThisSync": controller_registered, "policiesCreatedThisSync": policies_created, } value = { "status": "ok", "owner": self.owner, "syncedAt": utc_now().isoformat(), "automatic": True, "projects": projects, "summary": summary, } row = session.get(AppSetting, _CATALOG_KEY) or AppSetting( key=_CATALOG_KEY, is_secret=False, ) row.value_json = value row.is_secret = False row.updated_at = utc_now() session.add(row) session.commit() return value