"""Bounded helpers for enforcing one exact Harbor immutable-tag rule.""" from __future__ import annotations import base64 import json import time import urllib.error import urllib.parse import urllib.request from typing import Any EXPECTED_ORIGIN = "https://registry.bstein.dev/api/v2.0" MAX_RESPONSE = 1_048_576 TRANSIENT_STATUSES = {429, 502, 503, 504} class NoRedirect(urllib.request.HTTPRedirectHandler): """Prevent Basic credentials from following an unexpected redirect.""" def redirect_request(self, _request, _file, _code, _message, _headers, _url): return None class HarborUnavailable(RuntimeError): """Harbor is temporarily unavailable rather than denying policy.""" def expected_rule(repository: str, tag_pattern: str) -> dict[str, Any]: """Build the exact enabled immutable rule for one literal repository.""" return { "disabled": False, "action": "immutable", "template": "immutable_template", "tag_selectors": [ { "kind": "doublestar", "decoration": "matches", "pattern": tag_pattern, } ], "scope_selectors": { "repository": [ { "kind": "doublestar", "decoration": "repoMatches", "pattern": repository, } ] }, } def normalized_rule(rule: dict[str, Any]) -> dict[str, Any]: """Return only the immutable contract fields Harbor must preserve.""" return { "disabled": bool(rule.get("disabled", False)), "action": rule.get("action"), "template": rule.get("template"), "tag_selectors": [ {key: item.get(key) for key in ("kind", "decoration", "pattern")} for item in rule.get("tag_selectors") or [] if isinstance(item, dict) ], "scope_selectors": { "repository": [ {key: item.get(key) for key in ("kind", "decoration", "pattern")} for item in (rule.get("scope_selectors") or {}).get("repository", []) if isinstance(item, dict) ] }, } class HarborClient: """Small same-origin client for Harbor's immutable-tag API.""" def __init__(self, origin: str, username: str, password: str) -> None: if origin.rstrip("/") != EXPECTED_ORIGIN: raise ValueError("Harbor API origin is not the pinned production API") if not username or not password: raise RuntimeError("Harbor admin credential is empty") token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") self.origin = EXPECTED_ORIGIN self.headers = {"Authorization": f"Basic {token}"} self.opener = urllib.request.build_opener(NoRedirect()) def request( self, method: str, path: str, payload: dict[str, Any] | None = None ) -> tuple[int, bytes, dict[str, str]]: """Issue a bounded request and preserve non-2xx responses for checks.""" data = None headers = dict(self.headers) if payload is not None: data = json.dumps(payload, separators=(",", ":")).encode() headers["Content-Type"] = "application/json" request = urllib.request.Request( f"{self.origin}{path}", data=data, headers=headers, method=method ) try: response = self.opener.open(request, timeout=20) except urllib.error.HTTPError as exc: response = exc except (urllib.error.URLError, TimeoutError) as exc: raise HarborUnavailable("Harbor policy API is unavailable") from exc with response: body = response.read(MAX_RESPONSE + 1) if len(body) > MAX_RESPONSE: raise RuntimeError("Harbor response exceeded the size limit") return int(response.status), body, dict(response.headers) def list_rules(client: HarborClient, project: str) -> list[dict[str, Any]]: """Read and validate the complete bounded project rule list.""" path = f"/projects/{project}/immutabletagrules?page=1&page_size=100" status, body, headers = client.request("GET", path) if status in TRANSIENT_STATUSES: raise HarborUnavailable(f"Harbor immutable rule list returned HTTP {status}") if status != 200: raise RuntimeError(f"Harbor immutable rule list returned HTTP {status}") try: rules = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): raise RuntimeError("Harbor immutable rule list has an invalid shape") total = next( (value for key, value in headers.items() if key.lower() == "x-total-count"), None, ) if total is None or not str(total).isdecimal() or int(total) != len(rules): raise RuntimeError("Harbor immutable rule list is incomplete") return rules def ensure_rule( client: HarborClient, *, project: str, repository: str, tag_pattern: str ) -> int: """Create once or verify the one exact enabled repository rule.""" expected = expected_rule(repository, tag_pattern) def matches() -> list[dict[str, Any]]: return [ item for item in list_rules(client, project) if normalized_rule(item)["tag_selectors"] == expected["tag_selectors"] and normalized_rule(item)["scope_selectors"] == expected["scope_selectors"] ] existing = matches() if len(existing) > 1: raise RuntimeError("multiple matching immutable rules exist") if existing: if normalized_rule(existing[0]) != expected: raise RuntimeError("matching immutable rule is not enabled and exact") rule_id = existing[0].get("id") if not isinstance(rule_id, int) or rule_id < 1: raise RuntimeError("Harbor immutable rule omitted a valid ID") return rule_id path = f"/projects/{project}/immutabletagrules" status, _body, headers = client.request("POST", path, expected) if status in TRANSIENT_STATUSES: raise HarborUnavailable(f"Harbor immutable rule create returned HTTP {status}") if status != 201: raise RuntimeError(f"Harbor immutable rule create returned HTTP {status}") location = headers.get("Location") or headers.get("location") or "" api_path = urllib.parse.urlsplit(client.origin).path.rstrip("/") prefix = f"{api_path}{path}/" suffix = location.removeprefix(prefix) if location.startswith(prefix) else "" if not suffix.isdecimal() or int(suffix) < 1: raise RuntimeError("Harbor immutable rule create omitted the exact Location") for attempt in range(1, 6): created = matches() if ( len(created) == 1 and normalized_rule(created[0]) == expected and created[0].get("id") == int(suffix) ): return int(suffix) if attempt < 5: time.sleep(attempt) raise RuntimeError("created Harbor immutable rule did not verify exactly")