da15bda6d2
---ci--- phase: 3 milestone: v0.2 status: complete requirements: covered: [REQ-21, REQ-22, REQ-23] partial: [] ---/ci---
844 lines
36 KiB
Python
844 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""Daemonless OCI image publish client (D-019; stdlib only).
|
|
|
|
Usage:
|
|
python3 scripts/publish_image.py <tag> [--smoke]
|
|
[--artifact PATH] [--hub-registry URL] [--hub-auth URL]
|
|
[--gitea-registry URL] [--env-root DIR] [--owner O] [--repo R]
|
|
[--base-manifest DIGEST] [--timeout SECS]
|
|
|
|
Flow (REQ-23, RESEARCH §3): pull the pinned ``python:3.11-slim`` base
|
|
from the public registry anonymously (token -> OCI index -> resolve the
|
|
linux/amd64 child manifest by digest -> config + layer blobs; digests
|
|
recorded for provenance; ``--base-manifest`` overrides the tag
|
|
resolution for exact re-publishes) -> authenticate to the gitea
|
|
container registry (``GET /v2/token`` with Basic credentials — the
|
|
gitea token comes only from the sanctioned env files via
|
|
``scripts/_env_secrets.py``; the endpoint answers 200 even anonymously
|
|
with an empty-scope JWT, so the issued token must be non-empty and
|
|
failures must be loud — never treat a 200 alone as authorization) ->
|
|
push: HEAD each blob first (200 -> skip — retries resume cheap; the
|
|
~45.7 MB base layers are re-uploaded once per milestone because the
|
|
registries cannot cross-mount) -> monolithic blob upload
|
|
``POST /v2/<name>/blobs/uploads/?digest=`` (202 + ``Location`` ->
|
|
two-step PUT) with explicit Location-following on 307/308 (stdlib
|
|
urllib does NOT method-preserve on PUT/POST redirects) -> deterministic
|
|
broker layer (sorted tar entries, fixed metadata, member at the stable
|
|
path ``/app/coreci-broker.pyz``, ``gzip.compress(..., mtime=0)``) ->
|
|
new config blob (base config + appended diff_id; Entrypoint
|
|
``["python3", "/app/coreci-broker.pyz"]``, ``Cmd []``, ``WorkingDir
|
|
"/state"``, version label; serialized with sort_keys) -> OCI manifest
|
|
PUT with the base layer descriptors reused verbatim under ``<tag>``
|
|
FIRST, then ``latest`` (D-021).
|
|
|
|
Exit codes: 0 only after both manifest PUTs confirm 201; 1 after a
|
|
remote failure — bounded retries (3 attempts, 2s/4s backoff) exhausted
|
|
OR a permanent registry answer — printing the machine-readable
|
|
``IMAGE_PENDING <image>:<tag>`` marker for the ship escalation (never
|
|
blocks tag+merge); 2 for local precondition errors (missing artifact,
|
|
token, or config — nothing attempted remotely, no marker). Token values
|
|
are never printed.
|
|
|
|
Smoke mode (``--smoke``; G-013) — read-only pre-ship verify. NEVER part
|
|
of the default publish flow (the flag dispatches an early return; the
|
|
publish path above never consults it): four GET-only checks against
|
|
the live endpoints, run in order and stopping at the first failure —
|
|
nothing is uploaded, no manifest is PUT, no remote state is mutated.
|
|
|
|
1. ``GET {gitea}/v2/`` must answer 401 with a Bearer challenge whose
|
|
realm is the gitea token endpoint (the realm is recorded for 2).
|
|
2. Token->JWT exchange at that recorded realm
|
|
(``?service=container_registry&scope=repository:{o}/{r}:pull,push``)
|
|
with Basic auth — the gitea token resolves ONLY from the sanctioned
|
|
env files via ``scripts/_env_secrets.py`` (never the shell
|
|
environment); an empty token in the 200 answer is the anonymous-200
|
|
trap and fails loudly.
|
|
3. ``GET {gitea}/api/v1/repos/{o}/{r}/releases/tags/<tag>`` — 200
|
|
(exists) and 404 (not yet created) are BOTH acceptable pre-ship and
|
|
the line says which; the release's asset names print as a fact, and
|
|
a hard assertion binds ONLY when the tag is exactly ``v0.2.0``
|
|
(G-021 forge-side confirmation: the phase-0 release predates the
|
|
CLI and must carry NO ``.pyz`` asset, so any ``.pyz`` in its asset
|
|
list fails the smoke — the structural half of the guarantee,
|
|
release.py taking the tag as a positional argument with no v0.2.0
|
|
code path, is already machine-checked in the suite).
|
|
4. Docker Hub anonymous index resolve of ``python:3.11-slim`` with the
|
|
full Accept set must yield an index whose linux/amd64 child is
|
|
resolved; the amd64 child digest is printed — the base-drift fact
|
|
the P3-05 ship compares against expectations.
|
|
|
|
Smoke exit codes mirror the publish flow: 0 all four PASS; 1 with one
|
|
clear FAILED line per failure (checks after the failed one never run);
|
|
2 for local preconditions (missing token — nothing attempted
|
|
remotely). Smoke never prints token values and never emits
|
|
``IMAGE_PENDING`` (nothing was attempted, so nothing is pending).
|
|
|
|
Live gating (G-013): the LIVE smoke executes only at the P3-05 ship
|
|
step, which sets ``PUBLISH_SMOKE=1`` in the environment before invoking
|
|
this mode — this script neither reads nor requires that variable (the
|
|
SHIP GATE owns the gating). The smoke's LOGIC is what the default
|
|
unittest suite exercises (stub-tested, zero network access); the live
|
|
smoke itself never runs in the default suite.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
import gzip
|
|
import hashlib
|
|
import http.client
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
if _SCRIPTS_DIR not in sys.path:
|
|
sys.path.insert(0, _SCRIPTS_DIR)
|
|
|
|
import _env_secrets # sibling import (scripts/ dir on sys.path)
|
|
|
|
METADATA_TIMEOUT_SECONDS = 30
|
|
BLOB_TIMEOUT_SECONDS = 120
|
|
MAX_ATTEMPTS = 3
|
|
BACKOFF_SECONDS = (2, 4)
|
|
MAX_REDIRECTS = 5
|
|
BASE_REPO = "library/python"
|
|
BASE_TAG = "3.11-slim"
|
|
HUB_AUTH_SERVICE = "registry.docker.io"
|
|
GITEA_AUTH_SERVICE = "container_registry"
|
|
# The smoke-mode G-021 forge-side no-.pyz assertion binds ONLY this
|
|
# tag: the phase-0 release predates the CLI (D-020 starts at v0.2.1).
|
|
SMOKE_G021_TAG = "v0.2.0"
|
|
|
|
OCI_INDEX = "application/vnd.oci.image.index.v1+json"
|
|
OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json"
|
|
OCI_CONFIG = "application/vnd.oci.image.config.v1+json"
|
|
OCI_LAYER = "application/vnd.oci.image.layer.v1.tar+gzip"
|
|
DOCKER_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"
|
|
DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"
|
|
ACCEPT_MANIFESTS = ", ".join(
|
|
(OCI_INDEX, DOCKER_LIST, OCI_MANIFEST, DOCKER_MANIFEST))
|
|
|
|
|
|
class PublishError(Exception):
|
|
"""Image-publisher failure base."""
|
|
|
|
|
|
class TransientFailure(PublishError):
|
|
"""Connection error or 5xx persisting past the bounded retries."""
|
|
|
|
|
|
class RegistryApiError(PublishError):
|
|
"""Permanent registry answer carrying the remote detail."""
|
|
|
|
|
|
class LocalError(PublishError):
|
|
"""Local precondition failure (nothing was attempted remotely)."""
|
|
|
|
|
|
def repo_root():
|
|
return os.path.dirname(_SCRIPTS_DIR)
|
|
|
|
|
|
def load_release_defaults(root=None):
|
|
"""Read the release block from .ciagent/config.json ({} if absent)."""
|
|
path = os.path.join(root or repo_root(), ".ciagent", "config.json")
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
config = json.load(handle)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
block = config.get("release")
|
|
return block if isinstance(block, dict) else {}
|
|
|
|
|
|
def _error_detail(body):
|
|
try:
|
|
payload = json.loads(body.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
text = body.decode("utf-8", "replace").strip()
|
|
return text[:200] if text else "<empty body>"
|
|
if isinstance(payload, dict):
|
|
for key in ("errors", "message"):
|
|
if payload.get(key):
|
|
return json.dumps(payload[key])[:200]
|
|
return json.dumps(payload)[:200] if not isinstance(payload, str) else payload[:200]
|
|
|
|
|
|
def _header(headers, name):
|
|
"""Case-insensitive response-header lookup — ``_attempt_once``
|
|
returns a plain dict whose keys keep the wire casing, and registry
|
|
header casing is not guaranteed (e.g. WWW-Authenticate)."""
|
|
for key, value in headers.items():
|
|
if key.lower() == name.lower():
|
|
return value
|
|
return None
|
|
|
|
|
|
def _parse_json(body, context, status):
|
|
"""Parse a remote JSON body (token / manifest / config answers).
|
|
|
|
Malformed bodies raise RegistryApiError — never an uncaught
|
|
ValueError — so main()'s handler keeps the exit-1 + IMAGE_PENDING
|
|
marker contract for this failure class (CWE-754).
|
|
"""
|
|
try:
|
|
return json.loads(body.decode("utf-8", "replace"))
|
|
except ValueError as error:
|
|
raise RegistryApiError(
|
|
"{} answered malformed JSON ({})".format(context, error),
|
|
status, body)
|
|
|
|
|
|
def _attempt_once(method, url, headers, data, timeout):
|
|
"""One HTTP exchange; HTTPError becomes a completed answer."""
|
|
request = urllib.request.Request(url, data=data, method=method)
|
|
for key, value in headers.items():
|
|
request.add_header(key, value)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
body = b"" if method == "HEAD" else response.read()
|
|
return response.status, body, dict(response.headers.items())
|
|
except urllib.error.HTTPError as error:
|
|
body = b"" if method == "HEAD" else error.read()
|
|
return error.code, body, dict((error.headers or {}).items())
|
|
|
|
|
|
def _origin(url):
|
|
"""(scheme, netloc) of a URL — two URLs share an origin iff both match."""
|
|
parts = urllib.parse.urlsplit(url)
|
|
return (parts.scheme, parts.netloc)
|
|
|
|
|
|
def _attempt_following(method, url, headers, data, timeout):
|
|
"""One attempt, following 307/308 redirects explicitly (urllib does
|
|
NOT method-preserve on PUT/POST — the Location may be relative).
|
|
|
|
Cross-origin discipline (CWE-200): when a followed Location resolves
|
|
to a different origin than the ORIGINAL request's, the Authorization
|
|
header is stripped from the re-issued request — the registry JWT
|
|
must never reach another origin (blob-upload Locations are commonly
|
|
presigned external storage). Same-origin redirects keep it; once
|
|
stripped it is never re-attached."""
|
|
current_url = url
|
|
current_headers = dict(headers)
|
|
for _ in range(MAX_REDIRECTS + 1):
|
|
status, body, resp_headers = _attempt_once(
|
|
method, current_url, current_headers, data, timeout)
|
|
if status in (307, 308) and resp_headers.get("Location"):
|
|
next_url = urllib.parse.urljoin(
|
|
current_url, resp_headers["Location"])
|
|
if _origin(next_url) != _origin(url):
|
|
current_headers.pop("Authorization", None)
|
|
current_url = next_url
|
|
continue
|
|
return status, body, resp_headers
|
|
raise RegistryApiError(
|
|
"too many redirects following {} {}".format(method, url), 500, b"")
|
|
|
|
|
|
def _request(method, url, headers, data, timeout,
|
|
max_attempts=None, backoff=None):
|
|
"""Bounded-retry HTTP: retries connection errors and 5xx answers.
|
|
|
|
Returns (status, body, response_headers) for any answer below 500;
|
|
a 4xx returns immediately (permanent). Raises TransientFailure once
|
|
the retry budget is exhausted.
|
|
"""
|
|
attempts = MAX_ATTEMPTS if max_attempts is None else int(max_attempts)
|
|
delays = BACKOFF_SECONDS if backoff is None else tuple(backoff)
|
|
failure = "unknown failure"
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
status, body, resp_headers = _attempt_following(
|
|
method, url, headers, data, timeout)
|
|
except (OSError, http.client.HTTPException) as error:
|
|
status = None
|
|
failure = "connection error: {}: {}".format(
|
|
type(error).__name__, error)
|
|
else:
|
|
if status < 500:
|
|
return status, body, resp_headers
|
|
failure = "HTTP {}: {}".format(status, _error_detail(body))
|
|
if attempt < attempts and delays:
|
|
time.sleep(delays[min(attempt - 1, len(delays) - 1)])
|
|
raise TransientFailure(
|
|
"{} {} failed after {} attempt(s): {}".format(
|
|
method, url, attempts, failure))
|
|
|
|
|
|
def _hub_token(hub_auth, scope):
|
|
query = urllib.parse.urlencode(
|
|
{"service": HUB_AUTH_SERVICE, "scope": scope})
|
|
status, body, _ = _request(
|
|
"GET", "{}?{}".format(hub_auth, query), {}, None,
|
|
METADATA_TIMEOUT_SECONDS)
|
|
if status != 200:
|
|
raise RegistryApiError("hub token request failed", status, body)
|
|
payload = _parse_json(body, "hub token endpoint", status)
|
|
token = ""
|
|
if isinstance(payload, dict):
|
|
token = payload.get("token") or payload.get("access_token") or ""
|
|
if not token:
|
|
raise RegistryApiError("hub token endpoint returned no token",
|
|
status, body)
|
|
return token
|
|
|
|
|
|
def _fetch_manifest(registry, repository, ref, bearer, accept=ACCEPT_MANIFESTS):
|
|
url = "{}/v2/{}/manifests/{}".format(registry.rstrip("/"), repository, ref)
|
|
status, body, _ = _request(
|
|
"GET", url, {"Authorization": "Bearer {}".format(bearer),
|
|
"Accept": accept}, None, METADATA_TIMEOUT_SECONDS)
|
|
if status != 200:
|
|
raise RegistryApiError(
|
|
"manifest fetch {} {}".format(repository, ref), status, body)
|
|
return _parse_json(
|
|
body, "manifest fetch {} {}".format(repository, ref), status)
|
|
|
|
|
|
def _resolve_amd64_child(index):
|
|
"""linux/amd64 child digest from a base index; malformed index
|
|
shapes degrade to the not-found RegistryApiError, never a crash."""
|
|
manifests = index.get("manifests") if isinstance(index, dict) else None
|
|
if not isinstance(manifests, list):
|
|
manifests = []
|
|
for descriptor in manifests:
|
|
if not isinstance(descriptor, dict):
|
|
continue
|
|
platform = descriptor.get("platform")
|
|
if not isinstance(platform, dict):
|
|
continue
|
|
if platform.get("os") == "linux" and platform.get(
|
|
"architecture") == "amd64":
|
|
return descriptor.get("digest")
|
|
raise RegistryApiError(
|
|
"no linux/amd64 child manifest in the base index", 404,
|
|
json.dumps(manifests).encode("utf-8", "replace"))
|
|
|
|
|
|
def _fetch_blob(registry, repository, digest, bearer, timeout):
|
|
url = "{}/v2/{}/blobs/{}".format(
|
|
registry.rstrip("/"), repository, digest)
|
|
status, body, _ = _request(
|
|
"GET", url, {"Authorization": "Bearer {}".format(bearer)}, None,
|
|
timeout)
|
|
if status != 200:
|
|
raise RegistryApiError("blob fetch {}".format(digest), status, body)
|
|
if "sha256:" + hashlib.sha256(body).hexdigest() != digest:
|
|
raise RegistryApiError("blob digest mismatch {}".format(digest),
|
|
500, b"")
|
|
return body
|
|
|
|
|
|
def _child_parts(child):
|
|
"""Validated (config descriptor, layer descriptors) of a base child
|
|
manifest — missing or malformed keys raise a clear RegistryApiError
|
|
(never an uncaught KeyError), keeping the marker contract for this
|
|
failure class (CWE-754)."""
|
|
def malformed(reason):
|
|
return RegistryApiError(
|
|
"malformed base child manifest: {}".format(reason), 500,
|
|
json.dumps(child).encode("utf-8", "replace"))
|
|
|
|
if not isinstance(child, dict):
|
|
raise malformed("not a JSON object")
|
|
config = child.get("config")
|
|
if not isinstance(config, dict) or not config.get("digest"):
|
|
raise malformed("missing or empty config.digest")
|
|
layers = child.get("layers")
|
|
if not isinstance(layers, list):
|
|
raise malformed("missing or non-list layers")
|
|
for descriptor in layers:
|
|
if not isinstance(descriptor, dict) or not descriptor.get("digest"):
|
|
raise malformed("layer descriptor without a digest")
|
|
return config, layers
|
|
|
|
|
|
def _gitea_jwt(token_url, owner, repo, token, timeout):
|
|
"""Token->JWT exchange at a token endpoint URL (Basic auth).
|
|
|
|
``token_url`` is the FULL endpoint: the registry's ``/v2/token``
|
|
for the publish flow, or the realm recorded from a ``/v2/``
|
|
challenge for the smoke (the standard token-auth flow).
|
|
"""
|
|
query = urllib.parse.urlencode({
|
|
"service": GITEA_AUTH_SERVICE,
|
|
"scope": "repository:{}/{}:pull,push".format(owner, repo),
|
|
})
|
|
basic = base64.b64encode(
|
|
"publisher:{}".format(token).encode("utf-8")).decode("ascii")
|
|
status, body, _ = _request(
|
|
"GET", "{}?{}".format(token_url, query),
|
|
{"Authorization": "Basic {}".format(basic)}, None, timeout)
|
|
if status != 200:
|
|
raise RegistryApiError("gitea registry token request failed",
|
|
status, body)
|
|
payload = _parse_json(body, "gitea registry token endpoint", status)
|
|
jwt = (payload.get("token") or "") if isinstance(payload, dict) else ""
|
|
if not jwt:
|
|
# The endpoint answers 200 even anonymously with an unusable
|
|
# (empty-scope) JWT — a 200 proves nothing about push rights.
|
|
raise RegistryApiError(
|
|
"gitea registry token endpoint returned an empty token "
|
|
"(anonymous-200 trap refused; never treat 200 as authorization)",
|
|
status, body)
|
|
return jwt
|
|
|
|
|
|
def _bearer_challenge(www_authenticate):
|
|
"""Realm recorded from a Bearer WWW-Authenticate challenge.
|
|
|
|
Returns None when the header is absent, is not a Bearer challenge,
|
|
or carries no realm — a challenge without a realm cannot be
|
|
followed to the token endpoint, so the smoke treats that as no
|
|
challenge at all.
|
|
"""
|
|
if not www_authenticate:
|
|
return None
|
|
parts = www_authenticate.strip().split(None, 1)
|
|
if len(parts) < 2 or parts[0].lower() != "bearer":
|
|
return None
|
|
match = re.search(r'\brealm\s*=\s*"([^"]*)"', parts[1])
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def _asset_names(release):
|
|
"""Asset display names from a gitea release payload — odd shapes
|
|
degrade to [] (the read-only smoke never crashes on them)."""
|
|
if not isinstance(release, dict):
|
|
return []
|
|
assets = release.get("assets")
|
|
if not isinstance(assets, list):
|
|
return []
|
|
names = []
|
|
for asset in assets:
|
|
if isinstance(asset, dict) and isinstance(asset.get("name"), str):
|
|
names.append(asset["name"])
|
|
return names
|
|
|
|
|
|
def _blob_exists(gitea_registry, name, digest, jwt, timeout):
|
|
url = "{}/v2/{}/blobs/{}".format(
|
|
gitea_registry.rstrip("/"), name, digest)
|
|
status, _, _ = _request(
|
|
"HEAD", url, {"Authorization": "Bearer {}".format(jwt)}, None,
|
|
timeout)
|
|
return status == 200
|
|
|
|
|
|
def _upload_blob(gitea_registry, name, digest, blob, jwt, timeout):
|
|
base_url = gitea_registry.rstrip("/")
|
|
url = "{}/v2/{}/blobs/uploads/?digest={}".format(base_url, name, digest)
|
|
headers = {"Authorization": "Bearer {}".format(jwt),
|
|
"Content-Type": "application/octet-stream"}
|
|
status, body, resp_headers = _request(
|
|
"POST", url, headers, blob, timeout)
|
|
if status in (200, 201):
|
|
return
|
|
if status == 202 and resp_headers.get("Location"):
|
|
location = urllib.parse.urljoin(url, resp_headers["Location"])
|
|
if "digest=" not in location:
|
|
separator = "&" if "?" in location else "?"
|
|
location = "{}{}digest={}".format(location, separator, digest)
|
|
status, body, _ = _request(
|
|
"PUT", location, headers, blob, timeout)
|
|
if status in (200, 201):
|
|
return
|
|
raise RegistryApiError("blob upload {}".format(digest), status, body)
|
|
|
|
|
|
def _push_blob_if_missing(gitea_registry, name, digest, blob, jwt, timeout):
|
|
"""HEAD-skip then upload — retries resume cheap."""
|
|
if _blob_exists(gitea_registry, name, digest, jwt, timeout):
|
|
return False
|
|
_upload_blob(gitea_registry, name, digest, blob, jwt, timeout)
|
|
return True
|
|
|
|
|
|
def build_broker_layer(artifact_bytes):
|
|
"""Deterministic layer carrying /app/coreci-broker.pyz.
|
|
|
|
Returns (gzip_blob, layer_digest, diff_id): sorted single entry,
|
|
fixed TarInfo metadata, gzip with mtime=0 — byte-stable rebuilds.
|
|
"""
|
|
info = tarfile.TarInfo("app/coreci-broker.pyz")
|
|
info.size = len(artifact_bytes)
|
|
info.mode = 0o644
|
|
info.uid = 0
|
|
info.gid = 0
|
|
info.uname = ""
|
|
info.gname = ""
|
|
info.mtime = 0
|
|
buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=buffer, mode="w",
|
|
format=tarfile.GNU_FORMAT) as archive:
|
|
archive.addfile(info, io.BytesIO(artifact_bytes))
|
|
raw = buffer.getvalue()
|
|
compressed = gzip.compress(raw, compresslevel=9, mtime=0)
|
|
layer_digest = "sha256:" + hashlib.sha256(compressed).hexdigest()
|
|
diff_id = "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
return compressed, layer_digest, diff_id
|
|
|
|
|
|
def build_config_blob(base_config, diff_id, tag):
|
|
"""Base config + appended diff_id + CLI entrypoint/WorkingDir/label.
|
|
|
|
Serialized with sort_keys — deterministic config digest.
|
|
"""
|
|
config = dict(base_config)
|
|
rootfs = dict(base_config.get("rootfs") or {})
|
|
rootfs["diff_ids"] = list(rootfs.get("diff_ids") or []) + [diff_id]
|
|
if "type" not in rootfs:
|
|
rootfs["type"] = "layers"
|
|
config["rootfs"] = rootfs
|
|
inner = dict(base_config.get("config") or {})
|
|
inner["Entrypoint"] = ["python3", "/app/coreci-broker.pyz"]
|
|
inner["Cmd"] = []
|
|
inner["WorkingDir"] = "/state"
|
|
inner["Labels"] = {"org.opencontainers.image.version": tag}
|
|
config["config"] = inner
|
|
return json.dumps(config, sort_keys=True,
|
|
separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def build_manifest_blob(base_layer_descriptors, broker_layer_descriptor,
|
|
config_descriptor):
|
|
manifest = {
|
|
"schemaVersion": 2,
|
|
"mediaType": OCI_MANIFEST,
|
|
"config": config_descriptor,
|
|
"layers": list(base_layer_descriptors) + [broker_layer_descriptor],
|
|
}
|
|
return json.dumps(manifest, sort_keys=True,
|
|
separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _descriptor(digest, size, media_type):
|
|
return {"mediaType": media_type, "digest": digest, "size": size}
|
|
|
|
|
|
def _put_manifest(gitea_registry, name, tag, manifest_blob, jwt, timeout):
|
|
url = "{}/v2/{}/manifests/{}".format(
|
|
gitea_registry.rstrip("/"), name, tag)
|
|
status, body, _ = _request(
|
|
"PUT", url, {"Authorization": "Bearer {}".format(jwt),
|
|
"Content-Type": OCI_MANIFEST}, manifest_blob, timeout)
|
|
if status not in (200, 201):
|
|
raise RegistryApiError("manifest PUT {}".format(tag), status, body)
|
|
return "sha256:" + hashlib.sha256(manifest_blob).hexdigest()
|
|
|
|
|
|
def _load_artifact(path):
|
|
if not os.path.isfile(path):
|
|
raise LocalError("artifact not found: {}".format(path))
|
|
with open(path, "rb") as handle:
|
|
return handle.read()
|
|
|
|
|
|
def smoke(tag, gitea_registry, hub_registry, hub_auth, owner, repo,
|
|
timeout, env_root):
|
|
"""G-013 read-only live smoke: the four GET-only pre-ship checks.
|
|
|
|
Never part of the default publish flow; never mutates remote state
|
|
(no uploads, no manifest PUTs). The LIVE invocation happens only
|
|
at the P3-05 ship step, which gates it by setting PUBLISH_SMOKE=1
|
|
in the environment — this script neither reads nor requires that
|
|
variable (the SHIP GATE owns the gating). The default unittest
|
|
suite exercises this LOGIC against stubs (zero network).
|
|
|
|
Checks, in order, stopping at the first failure (see the module
|
|
docstring for the full G-021 tie-in): [1/4] the gitea /v2/ 401
|
|
Bearer challenge (realm recorded); [2/4] the token->JWT exchange at
|
|
the recorded realm — the gitea token resolves only from the
|
|
sanctioned env files via ``_env_secrets.get_token`` (never the
|
|
shell environment), and an empty token is the anonymous-200 trap
|
|
refused loudly; [3/4] the gitea API release-by-tag GET — 200
|
|
(exists) and 404 (not yet created) are both acceptable pre-ship,
|
|
asset names print as a fact, and the hard no-.pyz assertion binds
|
|
ONLY the tag v0.2.0 exactly (G-021 forge-side confirmation);
|
|
[4/4] the Docker Hub anonymous index resolve of python:3.11-slim —
|
|
the linux/amd64 child digest prints (the drift fact P3-05
|
|
compares against expectations).
|
|
|
|
Returns 0 (all PASS), or 1 after one clear FAILED line (token
|
|
values are never printed; IMAGE_PENDING is never emitted — nothing
|
|
was attempted, so nothing is pending). Local preconditions (a
|
|
missing token) raise ``_env_secrets.SecretsError`` BEFORE any
|
|
request; main() maps that to exit 2.
|
|
"""
|
|
gitea_token = _env_secrets.get_token(root=env_root)
|
|
metadata_timeout = min(METADATA_TIMEOUT_SECONDS, timeout)
|
|
gitea_base = gitea_registry.rstrip("/")
|
|
|
|
# [1/4] gitea /v2/ auth challenge (anonymous; realm recorded).
|
|
try:
|
|
status, _, resp_headers = _request(
|
|
"GET", "{}/v2/".format(gitea_base), {}, None, metadata_timeout)
|
|
except (TransientFailure, RegistryApiError) as error:
|
|
print("FAILED [1/4] gitea /v2/ auth challenge: {}".format(error))
|
|
return 1
|
|
challenge = _header(resp_headers, "WWW-Authenticate")
|
|
realm = _bearer_challenge(challenge)
|
|
if status != 401 or not realm:
|
|
print("FAILED [1/4] gitea /v2/ auth challenge: expected 401 with "
|
|
"a Bearer challenge, got HTTP {} (WWW-Authenticate: {!r})"
|
|
.format(status, challenge))
|
|
return 1
|
|
print("PASS [1/4] gitea /v2/ auth challenge: 401 + Bearer challenge "
|
|
"(realm {})".format(realm))
|
|
|
|
# [2/4] token->JWT exchange at the recorded realm (Basic auth).
|
|
try:
|
|
_gitea_jwt(realm, owner, repo, gitea_token, metadata_timeout)
|
|
except (TransientFailure, RegistryApiError) as error:
|
|
print("FAILED [2/4] gitea token->JWT exchange at {}: {}".format(
|
|
realm, error))
|
|
return 1
|
|
print("PASS [2/4] gitea token->JWT exchange: 200 + non-empty token "
|
|
"(service {}, scope repository:{}/{}:pull,push)".format(
|
|
GITEA_AUTH_SERVICE, owner, repo))
|
|
|
|
# [3/4] release-by-tag GET (gitea API; 200 exists, 404 pre-ship ok).
|
|
release_url = "{}/api/v1/repos/{}/{}/releases/tags/{}".format(
|
|
gitea_base, owner, repo, urllib.parse.quote(tag, safe=""))
|
|
try:
|
|
status, body, _ = _request(
|
|
"GET", release_url,
|
|
{"Authorization": "token {}".format(gitea_token)}, None,
|
|
metadata_timeout)
|
|
except (TransientFailure, RegistryApiError) as error:
|
|
print("FAILED [3/4] release-by-tag {}: {}".format(tag, error))
|
|
return 1
|
|
if status == 404:
|
|
print("PASS [3/4] release-by-tag {}: not yet created (404) — "
|
|
"acceptable pre-ship".format(tag))
|
|
elif status != 200:
|
|
print("FAILED [3/4] release-by-tag {}: expected 200 or 404, got "
|
|
"HTTP {}".format(tag, status))
|
|
return 1
|
|
else:
|
|
try:
|
|
release = _parse_json(
|
|
body, "release-by-tag {}".format(tag), status)
|
|
except RegistryApiError as error:
|
|
print("FAILED [3/4] release-by-tag {}: {}".format(tag, error))
|
|
return 1
|
|
asset_names = _asset_names(release)
|
|
pyz_assets = [name for name in asset_names
|
|
if name.endswith(".pyz")]
|
|
if tag == SMOKE_G021_TAG and pyz_assets:
|
|
print("FAILED [3/4] release-by-tag {}: G-021 violation — the "
|
|
"phase-0 release must carry no .pyz asset, found: {}"
|
|
.format(tag, ", ".join(pyz_assets)))
|
|
return 1
|
|
listing = ", ".join(asset_names) if asset_names else "(none)"
|
|
note = (" — G-021 confirmed: no .pyz asset"
|
|
if tag == SMOKE_G021_TAG else "")
|
|
print("PASS [3/4] release-by-tag {}: exists (assets: {}){}".format(
|
|
tag, listing, note))
|
|
|
|
# [4/4] Docker Hub index resolve (anonymous; amd64 drift print).
|
|
try:
|
|
hub_bearer = _hub_token(
|
|
hub_auth, "repository:{}:pull".format(BASE_REPO))
|
|
index = _fetch_manifest(hub_registry, BASE_REPO, BASE_TAG,
|
|
hub_bearer)
|
|
child_digest = _resolve_amd64_child(index)
|
|
if not child_digest:
|
|
raise RegistryApiError(
|
|
"amd64 child descriptor carries no digest", 500, b"")
|
|
except (TransientFailure, RegistryApiError) as error:
|
|
print("FAILED [4/4] hub index resolve {}:{}: {}".format(
|
|
BASE_REPO, BASE_TAG, error))
|
|
return 1
|
|
print("PASS [4/4] hub index resolve {}:{} -> linux/amd64 child {}"
|
|
.format(BASE_REPO, BASE_TAG, child_digest))
|
|
return 0
|
|
|
|
|
|
def publish(tag, artifact_path, hub_registry, hub_auth, gitea_registry,
|
|
owner, repo, base_manifest, timeout, env_root):
|
|
"""Run the full publish flow; returns the image provenance dict."""
|
|
artifact_bytes = _load_artifact(artifact_path)
|
|
gitea_token = _env_secrets.get_token(root=env_root)
|
|
name = "{}/{}".format(owner, repo)
|
|
metadata_timeout = min(METADATA_TIMEOUT_SECONDS, timeout)
|
|
|
|
hub_bearer = _hub_token(hub_auth, "repository:{}:pull".format(BASE_REPO))
|
|
if base_manifest:
|
|
child_digest = base_manifest
|
|
child = _fetch_manifest(hub_registry, BASE_REPO, base_manifest,
|
|
hub_bearer)
|
|
index_digest = None
|
|
else:
|
|
base = _fetch_manifest(hub_registry, BASE_REPO, BASE_TAG, hub_bearer)
|
|
index_digest = "sha256:" + hashlib.sha256(
|
|
json.dumps(base, sort_keys=True,
|
|
separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
child_digest = (_resolve_amd64_child(base)
|
|
if "manifests" in base else BASE_TAG)
|
|
child = _fetch_manifest(hub_registry, BASE_REPO, child_digest,
|
|
hub_bearer)
|
|
child_config, base_layer_descriptors = _child_parts(child)
|
|
base_config_bytes = _fetch_blob(
|
|
hub_registry, BASE_REPO, child_config["digest"], hub_bearer,
|
|
timeout)
|
|
base_config = _parse_json(base_config_bytes, "base config blob", 200)
|
|
if not isinstance(base_config, dict):
|
|
raise RegistryApiError("base config blob is not a JSON object",
|
|
500, base_config_bytes)
|
|
|
|
jwt = _gitea_jwt("{}/v2/token".format(gitea_registry.rstrip("/")),
|
|
owner, repo, gitea_token, metadata_timeout)
|
|
|
|
pushed = []
|
|
pushed.append(_push_blob_if_missing(
|
|
gitea_registry, name, child_config["digest"], base_config_bytes,
|
|
jwt, timeout))
|
|
for descriptor in base_layer_descriptors:
|
|
if _blob_exists(gitea_registry, name, descriptor["digest"], jwt,
|
|
timeout):
|
|
continue
|
|
blob = _fetch_blob(hub_registry, BASE_REPO, descriptor["digest"],
|
|
hub_bearer, timeout)
|
|
_upload_blob(gitea_registry, name, descriptor["digest"], blob, jwt,
|
|
timeout)
|
|
pushed.append(True)
|
|
|
|
layer_blob, layer_digest, diff_id = build_broker_layer(artifact_bytes)
|
|
config_blob = build_config_blob(base_config, diff_id, tag)
|
|
config_digest = "sha256:" + hashlib.sha256(config_blob).hexdigest()
|
|
_push_blob_if_missing(gitea_registry, name, layer_digest, layer_blob,
|
|
jwt, timeout)
|
|
_push_blob_if_missing(gitea_registry, name, config_digest, config_blob,
|
|
jwt, timeout)
|
|
|
|
manifest_blob = build_manifest_blob(
|
|
base_layer_descriptors,
|
|
_descriptor(layer_digest, len(layer_blob), OCI_LAYER),
|
|
_descriptor(config_digest, len(config_blob), OCI_CONFIG))
|
|
manifest_digest = _put_manifest(
|
|
gitea_registry, name, tag, manifest_blob, jwt, timeout)
|
|
_put_manifest(gitea_registry, name, "latest", manifest_blob, jwt,
|
|
timeout)
|
|
|
|
return {
|
|
"base_index_digest": index_digest,
|
|
"base_child_digest": child_digest if child_digest != BASE_TAG
|
|
else "tag-resolved",
|
|
"base_config_digest": child_config["digest"],
|
|
"base_layers": [descriptor["digest"]
|
|
for descriptor in base_layer_descriptors],
|
|
"broker_layer_digest": layer_digest,
|
|
"broker_layer_diff_id": diff_id,
|
|
"config_digest": config_digest,
|
|
"manifest_digest": manifest_digest,
|
|
"blobs_uploaded": sum(1 for flag in pushed if flag),
|
|
}
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
description="Daemonless OCI image publish (REQ-23, D-019)."
|
|
)
|
|
parser.add_argument("tag", help="image tag, e.g. v0.2.3")
|
|
parser.add_argument("--artifact",
|
|
help="path to the built .pyz artifact "
|
|
"(default: dist/coreci-broker-v<tag>.pyz)")
|
|
parser.add_argument("--hub-registry",
|
|
default="https://registry-1.docker.io",
|
|
help="public base-image registry")
|
|
parser.add_argument("--hub-auth", default="https://auth.docker.io/token",
|
|
help="public registry token service")
|
|
parser.add_argument("--gitea-registry", default="https://git.coreci.dev",
|
|
help="gitea registry base URL")
|
|
parser.add_argument("--owner", help="gitea owner (default: release.owner)")
|
|
parser.add_argument("--repo", help="gitea repo (default: release.repo)")
|
|
parser.add_argument("--base-manifest",
|
|
help="base child-manifest digest for exact "
|
|
"re-publishes (skips tag resolution)")
|
|
parser.add_argument("--smoke", action="store_true",
|
|
help="G-013 read-only live smoke: four GET-only "
|
|
"pre-ship checks, NO publish (see the "
|
|
"module docstring)")
|
|
parser.add_argument("--timeout", type=float, default=BLOB_TIMEOUT_SECONDS,
|
|
help="per-request timeout for blob operations "
|
|
"(seconds; metadata uses the smaller of this "
|
|
"and 30)")
|
|
parser.add_argument("--env-root",
|
|
help="root dir for the sanctioned .env* files "
|
|
"(default: repo root)")
|
|
args = parser.parse_args(argv)
|
|
|
|
defaults = load_release_defaults()
|
|
owner = args.owner or defaults.get("owner")
|
|
repo = args.repo or defaults.get("repo")
|
|
if not owner or not repo:
|
|
print("ERROR: missing gitea target (owner/repo); pass the flags or"
|
|
" extend the .ciagent/config.json release block",
|
|
file=sys.stderr)
|
|
return 2
|
|
blob_timeout = max(1.0, float(args.timeout))
|
|
if args.smoke:
|
|
# G-013 read-only live smoke: dispatched BEFORE any
|
|
# publish-side work and returning directly — the default
|
|
# publish flow below can never trigger it.
|
|
try:
|
|
return smoke(
|
|
tag=args.tag, gitea_registry=args.gitea_registry,
|
|
hub_registry=args.hub_registry, hub_auth=args.hub_auth,
|
|
owner=owner, repo=repo, timeout=blob_timeout,
|
|
env_root=args.env_root)
|
|
except _env_secrets.SecretsError as error:
|
|
print("ERROR: {}".format(error), file=sys.stderr)
|
|
return 2
|
|
artifact_path = args.artifact or os.path.join(
|
|
"dist", "coreci-broker-{}.pyz".format(args.tag))
|
|
# Reuse the smaller metadata timeout for the registry auth exchange so
|
|
# the timeout-abort path stays bounded for every request class.
|
|
try:
|
|
provenance = publish(
|
|
tag=args.tag, artifact_path=artifact_path,
|
|
hub_registry=args.hub_registry, hub_auth=args.hub_auth,
|
|
gitea_registry=args.gitea_registry, owner=owner, repo=repo,
|
|
base_manifest=args.base_manifest, timeout=blob_timeout,
|
|
env_root=args.env_root)
|
|
except _env_secrets.SecretsError as error:
|
|
print("ERROR: {}".format(error), file=sys.stderr)
|
|
return 2
|
|
except LocalError as error:
|
|
print("ERROR: {}".format(error), file=sys.stderr)
|
|
return 2
|
|
except (TransientFailure, RegistryApiError) as error:
|
|
print("ERROR: {}".format(error), file=sys.stderr)
|
|
registry_host = urllib.parse.urlsplit(
|
|
args.gitea_registry).netloc or args.gitea_registry
|
|
print("IMAGE_PENDING {}/{}:{}".format(registry_host, owner, repo,
|
|
args.tag))
|
|
return 1
|
|
|
|
print("image: {}/{}:{} (+ latest)".format(
|
|
args.gitea_registry.rstrip("/"), owner, repo, args.tag))
|
|
print("base index: {}".format(
|
|
provenance["base_index_digest"] or "direct (--base-manifest)"))
|
|
print("base amd64 manifest: {}".format(provenance["base_child_digest"]))
|
|
print("base config: {}".format(provenance["base_config_digest"]))
|
|
print("base layers: {}".format(", ".join(provenance["base_layers"])))
|
|
print("broker layer: {} (diff_id {})".format(
|
|
provenance["broker_layer_digest"],
|
|
provenance["broker_layer_diff_id"]))
|
|
print("config: {}".format(provenance["config_digest"]))
|
|
print("manifest: {}".format(provenance["manifest_digest"]))
|
|
print("blobs uploaded this run: {}".format(provenance["blobs_uploaded"]))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main()) |