#!/usr/bin/env python3 """Validate and prepare the project-owned static deployment request.""" from __future__ import annotations import argparse import base64 from dataclasses import dataclass import json import os from pathlib import Path import re import sys try: from .generic_package import PackageError, package_file_url, request from .static_release import ( ReleaseError, inspect_archive, validate_digest, validate_order, validate_revision, ) except ImportError: # Direct script execution adds scripts/ to sys.path. from generic_package import PackageError, package_file_url, request from static_release import ( ReleaseError, inspect_archive, validate_digest, validate_order, validate_revision, ) REGISTRY = "https://git.librete.ch" OWNER = "libretech" PACKAGE = "donatella-site" PUBLIC_HOST = "donatella.static.librete.ch" TARGET = "/srv/libretech-static/donatella" SERVE_ROOT = f"{TARGET}/current" VERIFY_URL = f"https://{PUBLIC_HOST}/.well-known/release.json" RELEASE_FIELDS = ("revision", "artifact_url", "artifact_digest", "target", "verify_url") ORDER_FIELDS = ("desired_commit", "desired_generation") SITE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") class ContractError(RuntimeError): pass @dataclass(frozen=True) class Site: """One enrolled static site: its package, release root and public marker.""" name: str owner: str package: str target: str verify_url: str registry: str = REGISTRY DONATELLA = Site("donatella", OWNER, PACKAGE, TARGET, VERIFY_URL) def load_site(path: Path | None) -> Site: """Read a reviewed site.json; without one, the receiver serves Donatella.""" if path is None: return DONATELLA try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ContractError(f"invalid site configuration: {exc}") from exc fields = {"schema", "name", "owner", "package", "target", "verify_url"} if not isinstance(document, dict) or set(document) != fields or document["schema"] != 1: raise ContractError("site configuration has an unsupported shape or schema") site = Site(**{key: document[key] for key in fields - {"schema"}}) if not all(isinstance(value, str) for value in vars(site).values()): raise ContractError("site configuration values must be strings") if not SITE_NAME_RE.fullmatch(site.name) or not SITE_NAME_RE.fullmatch(site.owner): raise ContractError("site name and owner must be lowercase identifiers") if not SITE_NAME_RE.fullmatch(site.package): raise ContractError("package must be a lowercase identifier") if site.target != f"/srv/libretech-static/{site.name}": raise ContractError("site target must be /srv/libretech-static/") if not site.verify_url.startswith("https://") or not site.verify_url.endswith( "/.well-known/release.json", ): raise ContractError("site verify_url must be an HTTPS release marker") return site def validate_contract( *, revision: str, artifact_url: str, artifact_digest: str, target: str, verify_url: str, registry: str | None = None, allow_http: bool = False, site: Site = DONATELLA, ) -> dict[str, str]: values = { "revision": revision, "artifact_url": artifact_url, "artifact_digest": artifact_digest, "target": target, "verify_url": verify_url, } if any(not isinstance(value, str) or not value for value in values.values()): raise ContractError("all deployment contract fields must be non-empty strings") try: validate_revision(revision) validate_digest(artifact_digest) expected_url = package_file_url( registry or site.registry, site.owner, site.package, revision, "site.tar.gz", allow_http=allow_http, ) except (ReleaseError, PackageError) as exc: raise ContractError(str(exc)) from exc if artifact_url != expected_url: raise ContractError("artifact URL is not the immutable project package for this revision") if target != site.target: raise ContractError(f"target must be exactly {site.target}") if verify_url != site.verify_url: raise ContractError(f"verification URL must be exactly {site.verify_url}") return values def validate_request(*, site: Site = DONATELLA, **fields) -> dict[str, str]: """Validate a release coordinate plus the GitOps selection that chose it.""" if set(fields) != set(RELEASE_FIELDS) | set(ORDER_FIELDS): raise ContractError("deployment request must contain exactly the seven declared fields") contract = validate_contract(site=site, **{key: fields[key] for key in RELEASE_FIELDS}) order = {key: fields[key] for key in ORDER_FIELDS} try: validate_order(order) except ReleaseError as exc: raise ContractError(str(exc)) from exc return {**contract, **order} def order_of(request: dict[str, str]) -> dict[str, str]: return {key: request[key] for key in ORDER_FIELDS} def request_token(contract: dict[str, str]) -> str: encoded = base64.urlsafe_b64encode( json.dumps(contract, separators=(",", ":")).encode("utf-8"), ).decode("ascii") return encoded.rstrip("=") def prepare( contract: dict[str, str], *, archive: Path, checksum: Path, token_output: Path, username: str | None = None, package_token: str | None = None, ) -> None: status, content = request( "GET", contract["artifact_url"], username=username, token=package_token, ) if status != 200: raise ContractError(f"artifact download returned HTTP {status}") archive.parent.mkdir(parents=True, exist_ok=True) checksum.parent.mkdir(parents=True, exist_ok=True) token_output.parent.mkdir(parents=True, exist_ok=True) archive.write_bytes(content) checksum.write_text( f"{contract['artifact_digest']} {archive.name}\n", encoding="ascii", ) try: inspect_archive(archive, checksum, contract["revision"]) except ReleaseError as exc: raise ContractError(str(exc)) from exc token_output.write_text(request_token(contract) + "\n", encoding="ascii") def add_contract_arguments(command: argparse.ArgumentParser) -> None: command.add_argument("--revision", required=True) command.add_argument("--artifact-url", required=True) command.add_argument("--artifact-digest", required=True) command.add_argument("--target", required=True) command.add_argument("--verify-url", required=True) command.add_argument("--desired-commit", required=True) command.add_argument("--desired-generation", required=True) command.add_argument("--site-config", type=Path) def parser() -> argparse.ArgumentParser: command = argparse.ArgumentParser(description=__doc__) sub = command.add_subparsers(dest="command", required=True) validate_cmd = sub.add_parser("validate") add_contract_arguments(validate_cmd) prepare_cmd = sub.add_parser("prepare") add_contract_arguments(prepare_cmd) prepare_cmd.add_argument("--archive", type=Path, required=True) prepare_cmd.add_argument("--checksum", type=Path, required=True) prepare_cmd.add_argument("--request-token", type=Path, required=True) return command def main() -> int: args = parser().parse_args() try: contract = validate_request( site=load_site(args.site_config), revision=args.revision, artifact_url=args.artifact_url, artifact_digest=args.artifact_digest, target=args.target, verify_url=args.verify_url, desired_commit=args.desired_commit, desired_generation=args.desired_generation, ) if args.command == "prepare": prepare( contract, archive=args.archive, checksum=args.checksum, token_output=args.request_token, username=os.environ.get("STATIC_PACKAGE_READ_USER") or None, package_token=os.environ.get("STATIC_PACKAGE_READ_TOKEN") or None, ) else: print(json.dumps(contract, separators=(",", ":"))) except (ContractError, PackageError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())