Switch official environment publishing to Docker Hub
This commit is contained in:
parent
0c4ac17b82
commit
6988d85f7d
11 changed files with 590 additions and 73 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue