wip: uncommitted work on kraftwerk, saved 9 Oct 2026 (netcup issue 86)
This commit is contained in:
Executable
+646
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build, inspect, publish, verify and roll back trusted static releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
REVISION_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
GENERATION_RE = re.compile(r"^[1-9][0-9]{0,11}$")
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
MAX_FILES = 10_000
|
||||
# The Sarah Weisman portfolio contains 164,651,446 bytes of site assets.
|
||||
# Keep one bounded limit for the archive, receiver and extracted release.
|
||||
MAX_BYTES = 200 * 1024 * 1024
|
||||
SOURCE_MARKER = Path(".release-source.json")
|
||||
PUBLIC_MARKER = Path(".well-known/release.json")
|
||||
# Last desired-state selection this target applied, outside the served tree.
|
||||
DESIRED_STATE = Path(".desired-state.json")
|
||||
FORBIDDEN_PARTS = {".git", ".hg", ".svn"}
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SupersededError(ReleaseError):
|
||||
"""A newer desired-state generation has already been applied here."""
|
||||
|
||||
|
||||
def validate_revision(revision: str) -> None:
|
||||
if not REVISION_RE.fullmatch(revision):
|
||||
raise ReleaseError("revision must be a full 40-character lowercase Git SHA")
|
||||
|
||||
|
||||
def validate_digest(digest: str) -> None:
|
||||
if not DIGEST_RE.fullmatch(digest):
|
||||
raise ReleaseError("digest must have the form sha256:<64 lowercase hex characters>")
|
||||
|
||||
|
||||
def validate_order(order: dict) -> dict:
|
||||
"""Validate the GitOps selection that requested a release.
|
||||
|
||||
``desired_generation`` is the ancestor count of ``desired_commit`` on the
|
||||
GitOps main branch. Main only moves to descendants, so a later selection
|
||||
always has a larger generation than an earlier one.
|
||||
"""
|
||||
if not isinstance(order, dict) or set(order) != {"desired_commit", "desired_generation"}:
|
||||
raise ReleaseError("order must contain exactly desired_commit and desired_generation")
|
||||
if not isinstance(order["desired_commit"], str) or not REVISION_RE.fullmatch(order["desired_commit"]):
|
||||
raise ReleaseError("desired_commit must be a full 40-character lowercase Git SHA")
|
||||
if not isinstance(order["desired_generation"], str) or not GENERATION_RE.fullmatch(order["desired_generation"]):
|
||||
raise ReleaseError("desired_generation must be a positive decimal integer without leading zeros")
|
||||
return order
|
||||
|
||||
|
||||
def load_desired_state(root: Path) -> dict | None:
|
||||
path = root / DESIRED_STATE
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return None
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseError("desired-state record must be a regular file")
|
||||
try:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid desired-state record: {exc}") from exc
|
||||
if not isinstance(state, dict) or set(state) != {
|
||||
"desired_commit", "desired_generation", "revision", "artifact_digest",
|
||||
}:
|
||||
raise ReleaseError("desired-state record has an unsupported shape")
|
||||
validate_order({key: state[key] for key in ("desired_commit", "desired_generation")})
|
||||
validate_revision(state["revision"])
|
||||
validate_digest(state["artifact_digest"])
|
||||
return state
|
||||
|
||||
|
||||
def check_order(root: Path, revision: str, digest: str, order: dict) -> None:
|
||||
"""Refuse a request whose selection is older than the one applied here.
|
||||
|
||||
An identical request (same generation, commit and release) is a retry of
|
||||
the accepted selection and may be applied again.
|
||||
"""
|
||||
state = load_desired_state(root)
|
||||
if state is None:
|
||||
return
|
||||
requested = int(order["desired_generation"])
|
||||
applied = int(state["desired_generation"])
|
||||
if requested < applied:
|
||||
raise SupersededError(
|
||||
f"superseded: generation {requested} ({order['desired_commit']}) is older than "
|
||||
f"applied generation {applied} ({state['desired_commit']})"
|
||||
)
|
||||
if requested == applied and (
|
||||
order["desired_commit"] != state["desired_commit"]
|
||||
or revision != state["revision"]
|
||||
or digest != state["artifact_digest"]
|
||||
):
|
||||
raise ReleaseError(
|
||||
f"generation {requested} was already applied with a different selection"
|
||||
)
|
||||
|
||||
|
||||
def write_desired_state(root: Path, revision: str, digest: str, order: dict) -> None:
|
||||
state = {**order, "revision": revision, "artifact_digest": digest}
|
||||
temporary = root / f".desired-state.next-{os.getpid()}"
|
||||
temporary.write_text(json.dumps(state, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, root / DESIRED_STATE)
|
||||
|
||||
|
||||
def validate_site_path(path: PurePosixPath) -> None:
|
||||
if any(part in FORBIDDEN_PARTS for part in path.parts):
|
||||
raise ReleaseError(f"version-control metadata is forbidden: {path}")
|
||||
filename = path.name.lower()
|
||||
if filename == ".env" or filename.startswith(".env.") or filename in {"id_rsa", "id_ed25519"}:
|
||||
raise ReleaseError(f"common secret filename is forbidden: {path}")
|
||||
|
||||
|
||||
def iter_files(root: Path):
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = PurePosixPath(path.relative_to(root).as_posix())
|
||||
validate_site_path(relative)
|
||||
if path.is_symlink():
|
||||
raise ReleaseError(f"symlinks are forbidden: {relative}")
|
||||
if path.is_file():
|
||||
yield path
|
||||
elif not path.is_dir():
|
||||
raise ReleaseError(f"unsupported filesystem entry: {relative}")
|
||||
|
||||
|
||||
def validate_tree(root: Path) -> None:
|
||||
count = 0
|
||||
size = 0
|
||||
for path in iter_files(root):
|
||||
count += 1
|
||||
size += path.stat().st_size
|
||||
if count > MAX_FILES or size > MAX_BYTES:
|
||||
raise ReleaseError("site exceeds the file-count or uncompressed-size limit")
|
||||
if not (root / "index.html").is_file():
|
||||
raise ReleaseError("release has no index.html")
|
||||
|
||||
|
||||
def load_source_marker(root: Path) -> dict:
|
||||
try:
|
||||
marker = json.loads((root / SOURCE_MARKER).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid or missing source marker: {exc}") from exc
|
||||
if set(marker) != {"revision"}:
|
||||
raise ReleaseError("source marker must contain only the revision")
|
||||
validate_revision(marker["revision"])
|
||||
validate_tree(root)
|
||||
return marker
|
||||
|
||||
|
||||
def load_public_marker(root: Path) -> dict:
|
||||
try:
|
||||
marker = json.loads((root / PUBLIC_MARKER).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"invalid or missing public release marker: {exc}") from exc
|
||||
if set(marker) != {"revision", "artifact_digest"}:
|
||||
raise ReleaseError("public marker must contain revision and artifact_digest")
|
||||
validate_revision(marker["revision"])
|
||||
validate_digest(marker["artifact_digest"])
|
||||
return marker
|
||||
|
||||
|
||||
def build(source: Path, output: Path, revision: str) -> None:
|
||||
validate_revision(revision)
|
||||
if not source.is_dir():
|
||||
raise ReleaseError(f"source directory does not exist: {source}")
|
||||
if (source / SOURCE_MARKER).exists() or (source / PUBLIC_MARKER).exists():
|
||||
raise ReleaseError("source must not provide reserved release markers")
|
||||
validate_tree(source)
|
||||
|
||||
source_root = source.resolve()
|
||||
output_root = output.resolve()
|
||||
if (
|
||||
output_root == source_root
|
||||
or output_root.is_relative_to(source_root)
|
||||
or source_root.is_relative_to(output_root)
|
||||
):
|
||||
raise ReleaseError("build output and source must be separate directory trees")
|
||||
|
||||
if output.exists():
|
||||
if output.is_symlink() or not output.is_dir():
|
||||
raise ReleaseError(f"build output must be a directory: {output}")
|
||||
shutil.rmtree(output)
|
||||
output.mkdir(parents=True)
|
||||
for path in iter_files(source):
|
||||
target = output / path.relative_to(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, target)
|
||||
target.chmod(0o644)
|
||||
(output / SOURCE_MARKER).write_text(
|
||||
json.dumps({"revision": revision}, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
load_source_marker(output)
|
||||
|
||||
|
||||
def package(directory: Path, archive: Path, checksum: Path) -> str:
|
||||
load_source_marker(directory)
|
||||
release_root = directory.resolve()
|
||||
if archive.resolve().is_relative_to(release_root) or checksum.resolve().is_relative_to(release_root):
|
||||
raise ReleaseError("archive and checksum must be written outside the release directory")
|
||||
if archive.resolve() == checksum.resolve():
|
||||
raise ReleaseError("archive and checksum paths must differ")
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open("wb") as raw:
|
||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in iter_files(directory):
|
||||
relative = path.relative_to(directory).as_posix()
|
||||
info = tar.gettarinfo(str(path), arcname=relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mode = 0o644
|
||||
info.mtime = 0
|
||||
with path.open("rb") as source:
|
||||
tar.addfile(info, source)
|
||||
digest = "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
checksum.write_text(f"{digest} {archive.name}\n", encoding="ascii")
|
||||
return digest
|
||||
|
||||
|
||||
def expected_archive_digest(checksum: Path, archive: Path) -> str:
|
||||
try:
|
||||
fields = checksum.read_text(encoding="ascii").strip().split()
|
||||
except OSError as exc:
|
||||
raise ReleaseError(f"cannot read checksum: {exc}") from exc
|
||||
if len(fields) != 2 or fields[1] != archive.name:
|
||||
raise ReleaseError("checksum file must contain 'sha256:<digest> <archive-name>'")
|
||||
validate_digest(fields[0])
|
||||
return fields[0]
|
||||
|
||||
|
||||
def inspect_archive(
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
revision: str,
|
||||
) -> tuple[bytes, list[tarfile.TarInfo], str]:
|
||||
validate_revision(revision)
|
||||
expected = expected_archive_digest(checksum, archive)
|
||||
try:
|
||||
if archive.stat().st_size > MAX_BYTES:
|
||||
raise ReleaseError("compressed archive exceeds the size limit")
|
||||
archive_data = archive.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ReleaseError(f"cannot read archive: {exc}") from exc
|
||||
actual = "sha256:" + hashlib.sha256(archive_data).hexdigest()
|
||||
if actual != expected:
|
||||
raise ReleaseError("archive digest mismatch")
|
||||
|
||||
members: list[tarfile.TarInfo] = []
|
||||
member_names: set[str] = set()
|
||||
total = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or not path.parts or any(part in ("", ".", "..") for part in path.parts):
|
||||
raise ReleaseError(f"unsafe archive path: {member.name!r}")
|
||||
validate_site_path(path)
|
||||
if not member.isfile():
|
||||
raise ReleaseError(f"archive may contain regular files only: {member.name!r}")
|
||||
if member.name in member_names:
|
||||
raise ReleaseError(f"duplicate archive path: {member.name!r}")
|
||||
total += member.size
|
||||
members.append(member)
|
||||
member_names.add(member.name)
|
||||
if len(members) > MAX_FILES or total > MAX_BYTES:
|
||||
raise ReleaseError("archive exceeds the file-count or uncompressed-size limit")
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise ReleaseError(f"invalid archive: {exc}") from exc
|
||||
|
||||
names = {member.name for member in members}
|
||||
if "index.html" not in names or SOURCE_MARKER.as_posix() not in names:
|
||||
raise ReleaseError("archive must contain index.html and .release-source.json")
|
||||
if PUBLIC_MARKER.as_posix() in names:
|
||||
raise ReleaseError("artifact must not provide the publisher-owned public marker")
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
destination = Path(temp)
|
||||
extract_members(archive_data, members, destination)
|
||||
marker = load_source_marker(destination)
|
||||
if marker["revision"] != revision:
|
||||
raise ReleaseError("archive revision does not match the requested revision")
|
||||
return archive_data, members, actual
|
||||
|
||||
|
||||
def extract_members(archive_data: bytes, members: list[tarfile.TarInfo], destination: Path) -> None:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_data), mode="r:gz") as tar:
|
||||
by_name = {member.name: member for member in tar.getmembers()}
|
||||
for inspected in members:
|
||||
member = by_name.get(inspected.name)
|
||||
if member is None or not member.isfile() or member.size != inspected.size:
|
||||
raise ReleaseError("archive changed while it was being inspected")
|
||||
target = destination.joinpath(*PurePosixPath(member.name).parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = tar.extractfile(member)
|
||||
if source is None:
|
||||
raise ReleaseError(f"cannot extract archive member: {member.name}")
|
||||
with source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
target.chmod(0o644)
|
||||
|
||||
|
||||
def validate_verify_url(url: str) -> None:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
|
||||
raise ReleaseError("verify URL must be an HTTP(S) marker URL without query or fragment")
|
||||
if not parsed.path.endswith("/.well-known/release.json"):
|
||||
raise ReleaseError("verify URL must end with /.well-known/release.json")
|
||||
|
||||
|
||||
def read_url_json(url: str, timeout: float) -> dict:
|
||||
request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
if response.status != 200:
|
||||
raise ReleaseError(f"public marker returned HTTP {response.status}")
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseError(f"public marker verification failed: {exc}") from exc
|
||||
|
||||
|
||||
def verify_public(url: str, revision: str, digest: str, timeout: float = 10.0) -> dict:
|
||||
validate_revision(revision)
|
||||
validate_digest(digest)
|
||||
validate_verify_url(url)
|
||||
marker = read_url_json(url, timeout)
|
||||
if marker.get("revision") != revision or marker.get("artifact_digest") != digest:
|
||||
raise ReleaseError("public marker does not identify the expected release")
|
||||
|
||||
index_url = url[: -len(PUBLIC_MARKER.as_posix())] + "index.html"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(index_url, headers={"Cache-Control": "no-cache"}),
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200 or not response.read(1):
|
||||
raise ReleaseError("public index is missing or empty")
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise ReleaseError(f"public index verification failed: {exc}") from exc
|
||||
return marker
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def release_lock(root: Path):
|
||||
if (
|
||||
not root.is_absolute()
|
||||
or root == Path("/")
|
||||
or root.resolve(strict=False) != root
|
||||
or not root.is_dir()
|
||||
or root.is_symlink()
|
||||
):
|
||||
raise ReleaseError(f"release root must be a provisioned directory: {root}")
|
||||
with (root / ".deploy.lock").open("a+") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def current_release(root: Path) -> dict | None:
|
||||
pointer = root / "current"
|
||||
if not pointer.exists() and not pointer.is_symlink():
|
||||
return None
|
||||
if not pointer.is_symlink():
|
||||
raise ReleaseError(f"current target must be a managed symlink: {pointer}")
|
||||
resolved = pointer.resolve(strict=True)
|
||||
releases = (root / "releases").resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(releases)
|
||||
except ValueError as exc:
|
||||
raise ReleaseError("current pointer escapes the managed releases directory") from exc
|
||||
if len(relative.parts) != 1 or not re.fullmatch(r"[0-9a-f]{64}", relative.name):
|
||||
raise ReleaseError("current pointer has an invalid release target")
|
||||
marker = load_public_marker(resolved)
|
||||
if marker["artifact_digest"] != f"sha256:{relative.name}":
|
||||
raise ReleaseError("current pointer and public marker digest disagree")
|
||||
return marker
|
||||
|
||||
|
||||
def switch(root: Path, digest: str) -> None:
|
||||
digest_hex = digest.removeprefix("sha256:")
|
||||
temporary = root / f".current.next-{os.getpid()}"
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
temporary.unlink()
|
||||
temporary.symlink_to(Path("releases") / digest_hex)
|
||||
os.replace(temporary, root / "current")
|
||||
|
||||
|
||||
def record(
|
||||
root: Path,
|
||||
action: str,
|
||||
revision: str,
|
||||
digest: str,
|
||||
previous: dict | None,
|
||||
outcome: str,
|
||||
actor: str | None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
entry = {
|
||||
"at": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"action": action,
|
||||
"actor": actor or os.environ.get("GITHUB_ACTOR") or os.environ.get("USER") or "unknown",
|
||||
"artifact_digest": digest,
|
||||
"revision": revision,
|
||||
"previous_revision": previous["revision"] if previous else None,
|
||||
"previous_artifact_digest": previous["artifact_digest"] if previous else None,
|
||||
"target": str(root / "current"),
|
||||
"outcome": outcome,
|
||||
"desired_commit": order["desired_commit"] if order else None,
|
||||
"desired_generation": order["desired_generation"] if order else None,
|
||||
}
|
||||
with (root / ".deployments.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def promote(
|
||||
root: Path,
|
||||
revision: str,
|
||||
digest: str,
|
||||
*,
|
||||
verify_url: str | None,
|
||||
action: str,
|
||||
actor: str | None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
validate_revision(revision)
|
||||
validate_digest(digest)
|
||||
release = root / "releases" / digest.removeprefix("sha256:")
|
||||
if not release.is_dir():
|
||||
raise ReleaseError(f"release is not staged: {digest}")
|
||||
source = load_source_marker(release)
|
||||
marker = load_public_marker(release)
|
||||
if source["revision"] != revision or marker != {"revision": revision, "artifact_digest": digest}:
|
||||
raise ReleaseError("staged release identity mismatch")
|
||||
|
||||
previous = current_release(root)
|
||||
switch(root, digest)
|
||||
try:
|
||||
if verify_url:
|
||||
verify_public(verify_url, revision, digest)
|
||||
record(root, action, revision, digest, previous, "verified", actor, order)
|
||||
except BaseException:
|
||||
if previous is None:
|
||||
(root / "current").unlink(missing_ok=True)
|
||||
else:
|
||||
switch(root, previous["artifact_digest"])
|
||||
record(root, action, revision, digest, previous, "rolled-back", actor, order)
|
||||
raise
|
||||
if order is not None:
|
||||
write_desired_state(root, revision, digest, order)
|
||||
|
||||
|
||||
def publish(
|
||||
archive: Path,
|
||||
checksum: Path,
|
||||
root: Path,
|
||||
revision: str,
|
||||
verify_url: str | None,
|
||||
actor: str | None = None,
|
||||
order: dict | None = None,
|
||||
) -> None:
|
||||
if order is not None:
|
||||
validate_order(order)
|
||||
archive_data, members, digest = inspect_archive(archive, checksum, revision)
|
||||
with release_lock(root):
|
||||
if order is not None:
|
||||
try:
|
||||
check_order(root, revision, digest, order)
|
||||
except SupersededError:
|
||||
record(root, "publish", revision, digest, current_release(root), "superseded", actor, order)
|
||||
raise
|
||||
releases = root / "releases"
|
||||
releases.mkdir(exist_ok=True)
|
||||
destination = releases / digest.removeprefix("sha256:")
|
||||
if destination.exists():
|
||||
marker = load_public_marker(destination)
|
||||
if marker != {"revision": revision, "artifact_digest": digest}:
|
||||
raise ReleaseError("existing immutable release has the wrong identity")
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix=".incoming-", dir=releases) as temp:
|
||||
incoming = Path(temp)
|
||||
extract_members(archive_data, members, incoming)
|
||||
source = load_source_marker(incoming)
|
||||
if source["revision"] != revision:
|
||||
raise ReleaseError("extracted release revision mismatch")
|
||||
marker_path = incoming / PUBLIC_MARKER
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text(
|
||||
json.dumps({"revision": revision, "artifact_digest": digest}, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
load_public_marker(incoming)
|
||||
os.replace(incoming, destination)
|
||||
promote(
|
||||
root,
|
||||
revision,
|
||||
digest,
|
||||
verify_url=verify_url,
|
||||
action="publish",
|
||||
actor=actor,
|
||||
order=order,
|
||||
)
|
||||
|
||||
|
||||
def rollback(
|
||||
root: Path,
|
||||
revision: str,
|
||||
digest: str,
|
||||
verify_url: str | None,
|
||||
actor: str | None = None,
|
||||
) -> None:
|
||||
with release_lock(root):
|
||||
promote(root, revision, digest, verify_url=verify_url, action="rollback", actor=actor)
|
||||
|
||||
|
||||
def retention(root: Path, keep: int) -> dict:
|
||||
"""Report releases outside the retention set. Never deletes anything.
|
||||
|
||||
Kept: the current release, the last applied desired selection, and the
|
||||
``keep`` most recent distinct verified releases from the deployment log.
|
||||
"""
|
||||
if keep < 1:
|
||||
raise ReleaseError("keep must be at least 1")
|
||||
releases = root / "releases"
|
||||
present = sorted(
|
||||
path.name for path in releases.iterdir()
|
||||
if path.is_dir() and re.fullmatch(r"[0-9a-f]{64}", path.name)
|
||||
) if releases.is_dir() else []
|
||||
reasons: dict[str, list[str]] = {}
|
||||
|
||||
def keep_digest(digest: str | None, reason: str) -> None:
|
||||
if digest:
|
||||
reasons.setdefault(digest.removeprefix("sha256:"), []).append(reason)
|
||||
|
||||
current = current_release(root)
|
||||
keep_digest(current and current["artifact_digest"], "current")
|
||||
state = load_desired_state(root)
|
||||
keep_digest(state and state["artifact_digest"], "desired-state")
|
||||
verified: list[str] = []
|
||||
log = root / ".deployments.jsonl"
|
||||
if log.is_file():
|
||||
for line in reversed(log.read_text(encoding="utf-8").splitlines()):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
digest = entry.get("artifact_digest")
|
||||
if entry.get("outcome") == "verified" and digest not in verified:
|
||||
verified.append(digest)
|
||||
for digest in verified[:keep]:
|
||||
keep_digest(digest, "recent-verified")
|
||||
return {
|
||||
"root": str(root),
|
||||
"keep": {digest: reasons[digest] for digest in present if digest in reasons},
|
||||
"candidates": [digest for digest in present if digest not in reasons],
|
||||
}
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
command = argparse.ArgumentParser(description=__doc__)
|
||||
sub = command.add_subparsers(dest="command", required=True)
|
||||
|
||||
build_cmd = sub.add_parser("build")
|
||||
build_cmd.add_argument("--source", type=Path, required=True)
|
||||
build_cmd.add_argument("--output", type=Path, required=True)
|
||||
build_cmd.add_argument("--revision", required=True)
|
||||
|
||||
package_cmd = sub.add_parser("package")
|
||||
package_cmd.add_argument("--directory", type=Path, required=True)
|
||||
package_cmd.add_argument("--archive", type=Path, required=True)
|
||||
package_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
|
||||
inspect_cmd = sub.add_parser("inspect")
|
||||
inspect_cmd.add_argument("--archive", type=Path, required=True)
|
||||
inspect_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
inspect_cmd.add_argument("--revision", required=True)
|
||||
|
||||
publish_cmd = sub.add_parser("publish")
|
||||
publish_cmd.add_argument("--archive", type=Path, required=True)
|
||||
publish_cmd.add_argument("--checksum", type=Path, required=True)
|
||||
publish_cmd.add_argument("--root", type=Path, required=True)
|
||||
publish_cmd.add_argument("--revision", required=True)
|
||||
publish_cmd.add_argument("--verify-url")
|
||||
publish_cmd.add_argument("--actor")
|
||||
|
||||
verify_cmd = sub.add_parser("verify")
|
||||
verify_cmd.add_argument("--url", required=True)
|
||||
verify_cmd.add_argument("--revision", required=True)
|
||||
verify_cmd.add_argument("--digest", required=True)
|
||||
|
||||
rollback_cmd = sub.add_parser("rollback")
|
||||
rollback_cmd.add_argument("--root", type=Path, required=True)
|
||||
rollback_cmd.add_argument("--revision", required=True)
|
||||
rollback_cmd.add_argument("--digest", required=True)
|
||||
rollback_cmd.add_argument("--verify-url")
|
||||
rollback_cmd.add_argument("--actor")
|
||||
|
||||
retention_cmd = sub.add_parser("retention", help="report prune candidates; never deletes")
|
||||
retention_cmd.add_argument("--root", type=Path, required=True)
|
||||
retention_cmd.add_argument("--keep", type=int, default=5)
|
||||
return command
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
if args.command == "build":
|
||||
build(args.source, args.output, args.revision)
|
||||
elif args.command == "package":
|
||||
package(args.directory, args.archive, args.checksum)
|
||||
elif args.command == "inspect":
|
||||
inspect_archive(args.archive, args.checksum, args.revision)
|
||||
elif args.command == "publish":
|
||||
publish(args.archive, args.checksum, args.root, args.revision, args.verify_url, args.actor)
|
||||
elif args.command == "verify":
|
||||
verify_public(args.url, args.revision, args.digest)
|
||||
elif args.command == "rollback":
|
||||
rollback(args.root, args.revision, args.digest, args.verify_url, args.actor)
|
||||
elif args.command == "retention":
|
||||
print(json.dumps(retention(args.root, args.keep), indent=2, sort_keys=True))
|
||||
except ReleaseError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user