247 lines
8.4 KiB
Python
Executable File
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())
|