Files
blog/delivery/scripts/deploy_contract.py
T

247 lines
8.4 KiB
Python
Executable File

#!/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/<name>")
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())