647 lines
26 KiB
Python
Executable File
647 lines
26 KiB
Python
Executable File
#!/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())
|