Files
blog/delivery/scripts/static_release.py
T

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())