185 lines
7.1 KiB
Python
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")
|