Switch official environment publishing to Docker Hub

This commit is contained in:
Thales Maciel 2026-03-09 17:39:18 -03:00
parent 0c4ac17b82
commit 6988d85f7d
11 changed files with 590 additions and 73 deletions

View file

@ -37,6 +37,15 @@ OCI_LAYOUT_VERSION = "1.0.0"
VALIDATION_READ_LIMIT = 1024 * 1024
DEFAULT_OCI_USERNAME_ENV = "OCI_REGISTRY_USERNAME"
DEFAULT_OCI_PASSWORD_ENV = "OCI_REGISTRY_PASSWORD"
DEFAULT_DOCKERHUB_USERNAME_ENV = "DOCKERHUB_USERNAME"
DEFAULT_DOCKERHUB_TOKEN_ENV = "DOCKERHUB_TOKEN"
DEFAULT_OCI_REQUEST_TIMEOUT_ENV = "PYRO_OCI_REQUEST_TIMEOUT_SECONDS"
DEFAULT_OCI_UPLOAD_TIMEOUT_ENV = "PYRO_OCI_UPLOAD_TIMEOUT_SECONDS"
DEFAULT_OCI_UPLOAD_CHUNK_SIZE_ENV = "PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES"
DEFAULT_OCI_REQUEST_TIMEOUT_SECONDS = 90
DEFAULT_OCI_UPLOAD_TIMEOUT_SECONDS = 900
DEFAULT_OCI_UPLOAD_CHUNK_SIZE_BYTES = 8 * 1024 * 1024
_REGISTRY_BEARER_TOKENS: dict[tuple[str, str, str, str], str] = {}
@dataclass(frozen=True)
@ -269,7 +278,24 @@ def _basic_auth_header(username: str | None, password: str | None) -> str | None
def _parse_authenticate_parameters(raw: str) -> dict[str, str]:
params: dict[str, str] = {}
for segment in raw.split(","):
segments: list[str] = []
current: list[str] = []
in_quotes = False
for char in raw:
if char == '"':
in_quotes = not in_quotes
if char == "," and not in_quotes:
segment = "".join(current).strip()
if segment:
segments.append(segment)
current = []
continue
current.append(char)
tail = "".join(current).strip()
if tail:
segments.append(tail)
for segment in segments:
if "=" not in segment:
continue
key, value = segment.split("=", 1)
@ -286,8 +312,8 @@ def _registry_credentials(
username_env: str = DEFAULT_OCI_USERNAME_ENV,
password_env: str = DEFAULT_OCI_PASSWORD_ENV,
) -> tuple[str | None, str | None]:
username = os.environ.get(username_env)
password = os.environ.get(password_env)
username = os.environ.get(username_env) or os.environ.get(DEFAULT_DOCKERHUB_USERNAME_ENV)
password = os.environ.get(password_env) or os.environ.get(DEFAULT_DOCKERHUB_TOKEN_ENV)
return (
username if isinstance(username, str) and username != "" else None,
password if isinstance(password, str) and password != "" else None,
@ -305,6 +331,55 @@ def _read_response_body(response: Any) -> bytes:
return b""
def _positive_int_from_env(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None or raw == "":
return default
try:
value = int(raw)
except ValueError as exc:
raise RuntimeError(f"{name} must be an integer; got {raw!r}") from exc
if value <= 0:
raise RuntimeError(f"{name} must be greater than zero; got {value}")
return value
def _oci_request_timeout_seconds() -> int:
return _positive_int_from_env(
DEFAULT_OCI_REQUEST_TIMEOUT_ENV,
DEFAULT_OCI_REQUEST_TIMEOUT_SECONDS,
)
def _oci_upload_timeout_seconds() -> int:
return _positive_int_from_env(
DEFAULT_OCI_UPLOAD_TIMEOUT_ENV,
DEFAULT_OCI_UPLOAD_TIMEOUT_SECONDS,
)
def _oci_upload_chunk_size_bytes() -> int:
return _positive_int_from_env(
DEFAULT_OCI_UPLOAD_CHUNK_SIZE_ENV,
DEFAULT_OCI_UPLOAD_CHUNK_SIZE_BYTES,
)
def _registry_token_cache_key(
*,
url: str,
repository: str,
scope_actions: str,
username: str | None,
) -> tuple[str, str, str, str]:
return (
urllib.parse.urlsplit(url).netloc,
repository,
scope_actions,
username or "",
)
def _fetch_registry_token(
authenticate: str,
*,
@ -332,7 +407,7 @@ def _fetch_registry_token(
headers=headers,
method="GET",
)
with urllib.request.urlopen(request, timeout=90) as response: # noqa: S310
with urllib.request.urlopen(request, timeout=_oci_request_timeout_seconds()) as response: # noqa: S310
payload = json.loads(_read_response_body(response).decode("utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("OCI auth token response was not a JSON object")
@ -353,8 +428,19 @@ def _request_registry(
allow_statuses: tuple[int, ...] = (),
username: str | None = None,
password: str | None = None,
timeout_seconds: int | None = None,
) -> tuple[int, bytes, dict[str, str]]:
request_headers = dict(headers or {})
request_timeout = timeout_seconds or _oci_request_timeout_seconds()
cache_key = _registry_token_cache_key(
url=url,
repository=repository,
scope_actions=scope_actions,
username=username,
)
cached_token = _REGISTRY_BEARER_TOKENS.get(cache_key)
if cached_token is not None and "Authorization" not in request_headers:
request_headers["Authorization"] = f"Bearer {cached_token}"
request = urllib.request.Request(
url,
data=cast(Any, data),
@ -362,7 +448,7 @@ def _request_registry(
method=method,
)
try:
with urllib.request.urlopen(request, timeout=90) as response: # noqa: S310
with urllib.request.urlopen(request, timeout=request_timeout) as response: # noqa: S310
return (
response.status,
_read_response_body(response),
@ -380,6 +466,7 @@ def _request_registry(
username=username,
password=password,
)
_REGISTRY_BEARER_TOKENS[cache_key] = token
authenticated_headers = {**request_headers, "Authorization": f"Bearer {token}"}
_rewind_body(data)
retry = urllib.request.Request(
@ -389,7 +476,7 @@ def _request_registry(
method=method,
)
try:
with urllib.request.urlopen(retry, timeout=90) as response: # noqa: S310
with urllib.request.urlopen(retry, timeout=request_timeout) as response: # noqa: S310
return (
response.status,
_read_response_body(response),
@ -403,9 +490,13 @@ def _request_registry(
_normalize_headers(retry_exc.headers),
)
raise RuntimeError(f"registry request failed for {url}: {retry_exc}") from retry_exc
except urllib.error.URLError as retry_exc:
raise RuntimeError(f"registry request failed for {url}: {retry_exc}") from retry_exc
if exc.code in allow_statuses:
return exc.code, _read_response_body(exc), _normalize_headers(exc.headers)
raise RuntimeError(f"registry request failed for {url}: {exc}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"registry request failed for {url}: {exc}") from exc
def _load_oci_layout_manifest(
@ -489,24 +580,71 @@ def _upload_blob(
if location is None:
raise RuntimeError("registry did not return a blob upload location")
upload_url = urllib.parse.urljoin(f"https://{registry}", location)
separator = "&" if "?" in upload_url else "?"
upload_url = f"{upload_url}{separator}{urllib.parse.urlencode({'digest': digest})}"
size = blob_path.stat().st_size
upload_timeout_seconds = _oci_upload_timeout_seconds()
upload_chunk_size = _oci_upload_chunk_size_bytes()
with blob_path.open("rb") as blob_fp:
uploaded_bytes = 0
chunk_index = 0
while True:
chunk = blob_fp.read(upload_chunk_size)
if chunk == b"":
break
try:
status, _, headers = _request_registry(
upload_url,
method="PATCH",
repository=repository,
scope_actions="pull,push",
headers={
"Content-Length": str(len(chunk)),
"Content-Type": "application/octet-stream",
},
data=chunk,
allow_statuses=(202,),
username=username,
password=password,
timeout_seconds=upload_timeout_seconds,
)
except RuntimeError as exc:
raise RuntimeError(
"registry blob upload patch failed for "
f"{digest} at byte offset {uploaded_bytes} (chunk {chunk_index}): {exc}"
) from exc
if status != 202:
raise RuntimeError(
f"unexpected registry status when uploading blob chunk {chunk_index}: {status}"
)
location = headers.get("location")
if location is None:
raise RuntimeError(
"registry did not return a blob upload location after chunk "
f"{chunk_index}"
)
upload_url = urllib.parse.urljoin(f"https://{registry}", location)
uploaded_bytes += len(chunk)
chunk_index += 1
separator = "&" if "?" in upload_url else "?"
finalize_url = f"{upload_url}{separator}{urllib.parse.urlencode({'digest': digest})}"
try:
status, _, _ = _request_registry(
upload_url,
finalize_url,
method="PUT",
repository=repository,
scope_actions="pull,push",
headers={
"Content-Length": str(size),
"Content-Length": "0",
"Content-Type": "application/octet-stream",
},
data=blob_fp,
data=b"",
allow_statuses=(201,),
username=username,
password=password,
timeout_seconds=upload_timeout_seconds,
)
except RuntimeError as exc:
raise RuntimeError(f"registry blob upload finalize failed for {digest}: {exc}") from exc
if status != 201:
raise RuntimeError(f"unexpected registry status when uploading blob: {status}")
return {"digest": digest, "size": size, "uploaded": True}
@ -535,6 +673,12 @@ def publish_environment_oci_layout(
username_env=username_env,
password_env=password_env,
)
if username is None or password is None:
raise RuntimeError(
"OCI registry credentials are not configured; set "
f"{username_env}/{password_env} or "
f"{DEFAULT_DOCKERHUB_USERNAME_ENV}/{DEFAULT_DOCKERHUB_TOKEN_ENV}"
)
config = manifest.get("config")
layers = manifest.get("layers")
@ -1166,9 +1310,18 @@ def main() -> None: # pragma: no cover - CLI wiring
if args.command == "publish-environment-oci":
if not isinstance(args.environment, str) or args.environment == "":
raise RuntimeError("--environment is required for publish-environment-oci")
layout_root = Path(args.layout_root)
layout_index = layout_root / _environment_slug(args.environment) / "index.json"
if not layout_index.exists():
export_environment_oci_layout(
paths,
environment=args.environment,
output_dir=layout_root,
reference=args.reference,
)
result = publish_environment_oci_layout(
environment=args.environment,
layout_root=Path(args.layout_root),
layout_root=layout_root,
registry=args.registry,
repository=args.repository,
reference=args.reference,

View file

@ -74,8 +74,8 @@ CATALOG: dict[str, VmEnvironment] = {
distribution="debian",
distribution_version="12",
source_profile="debian-git",
oci_registry="ghcr.io",
oci_repository="thaloco/pyro-environments/debian-12",
oci_registry="registry-1.docker.io",
oci_repository="thalesmaciel/pyro-environment-debian-12",
oci_reference=DEFAULT_ENVIRONMENT_VERSION,
),
"debian:12-base": VmEnvironment(
@ -86,8 +86,8 @@ CATALOG: dict[str, VmEnvironment] = {
distribution="debian",
distribution_version="12",
source_profile="debian-base",
oci_registry="ghcr.io",
oci_repository="thaloco/pyro-environments/debian-12-base",
oci_registry="registry-1.docker.io",
oci_repository="thalesmaciel/pyro-environment-debian-12-base",
oci_reference=DEFAULT_ENVIRONMENT_VERSION,
),
"debian:12-build": VmEnvironment(
@ -98,8 +98,8 @@ CATALOG: dict[str, VmEnvironment] = {
distribution="debian",
distribution_version="12",
source_profile="debian-build",
oci_registry="ghcr.io",
oci_repository="thaloco/pyro-environments/debian-12-build",
oci_registry="registry-1.docker.io",
oci_repository="thalesmaciel/pyro-environment-debian-12-build",
oci_reference=DEFAULT_ENVIRONMENT_VERSION,
),
}