atlas-iac/services/harbor/scripts/harbor_immutable_rule_ensure.py

185 lines
7.1 KiB
Python

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