wip: uncommitted work on kraftwerk, saved 9 Oct 2026 (netcup issue 86)
This commit is contained in:
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user