feat: coordinate static site deployments through Gandalf
All checks were successful
Test / test (push) Successful in 8s

This commit is contained in:
Eric Wendland 2026-07-21 23:26:34 +02:00
commit 411010ef5a
4 changed files with 257 additions and 17 deletions

View file

@ -84,6 +84,10 @@ def manifest_for(files: dict[str, LocalFile]) -> dict[str, Any]:
}
def manifest_bytes(manifest: dict[str, Any]) -> bytes:
return (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
def parse_manifest(raw: bytes | None) -> dict[str, dict[str, Any]]:
if raw is None:
return {}
@ -182,7 +186,7 @@ class BunnyStorage:
)
def upload_manifest(self, manifest: dict[str, Any]) -> None:
body = (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
body = manifest_bytes(manifest)
self._request(
"PUT",
MANIFEST_NAME,
@ -297,12 +301,139 @@ def purge_bunny_gateway(endpoint: str, zone: str, password: str) -> None:
raise DeployError(f"Bunny purge gateway failed: {exc.reason}") from exc
def gateway_origin(endpoint: str) -> str:
parsed = urllib.parse.urlsplit(endpoint)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.port is not None
or parsed.path not in ("", "/")
or parsed.query
or parsed.fragment
):
raise DeployError("BUNNY_PURGE_ENDPOINT must be an HTTPS origin without a path")
return f"https://{parsed.hostname}"
def deployment_gateway_request(
endpoint: str,
zone: str,
password: str,
method: str,
path: str,
payload: dict[str, Any],
) -> dict[str, Any]:
url = (
f"{gateway_origin(endpoint)}/v1/static-sites/"
f"{urllib.parse.quote(zone, safe='')}/{path.lstrip('/')}"
)
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {password}",
"Accept": "application/json",
"Content-Type": "application/json",
},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
detail = exc.read()
try:
message = str(json.loads(detail).get("error") or "")
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
message = ""
suffix = f": {message}" if message else ""
raise DeployError(
f"Bunny deployment gateway {method} failed: HTTP {exc.code}{suffix}"
) from exc
except urllib.error.URLError as exc:
raise DeployError(f"Bunny deployment gateway failed: {exc.reason}") from exc
def start_bunny_deployment(
endpoint: str,
zone: str,
password: str,
idempotency_key: str,
revision: str | None,
) -> dict[str, Any]:
result = deployment_gateway_request(
endpoint,
zone,
password,
"POST",
"deployments",
{"idempotency_key": idempotency_key, "revision": revision},
)
deployment = result.get("deployment")
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
raise DeployError("Bunny deployment gateway returned an invalid lease")
return deployment
def finalize_bunny_deployment(
endpoint: str,
zone: str,
password: str,
deployment_id: str,
idempotency_key: str,
revision: str | None,
manifest_sha256: str,
) -> dict[str, Any]:
result = deployment_gateway_request(
endpoint,
zone,
password,
"POST",
f"deployments/{urllib.parse.quote(deployment_id, safe='')}/finalize",
{
"idempotency_key": idempotency_key,
"revision": revision,
"manifest_sha256": manifest_sha256,
},
)
deployment = result.get("deployment")
if not isinstance(deployment, dict):
raise DeployError("Bunny deployment gateway returned an invalid finalization")
return deployment
def abort_bunny_deployment(
endpoint: str,
zone: str,
password: str,
deployment_id: str,
idempotency_key: str,
) -> None:
deployment_gateway_request(
endpoint,
zone,
password,
"DELETE",
f"deployments/{urllib.parse.quote(deployment_id, safe='')}",
{"idempotency_key": idempotency_key},
)
def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
if args.purge_endpoint and args.pull_zone_id:
raise DeployError(
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
)
files = collect_files(args.directory)
manifest = manifest_for(files)
manifest_sha256 = hashlib.sha256(manifest_bytes(manifest)).hexdigest()
revision = args.revision.strip() or None
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
f"{args.zone}\0{revision or ''}\0{manifest_sha256}".encode()
).hexdigest()
storage = BunnyStorage(args.zone, args.password, args.endpoint)
remote = parse_manifest(storage.get_manifest())
upload, delete = bunny_plan(files, remote)
@ -313,18 +444,52 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"upload": upload,
"delete": delete,
"purged": False,
"deployment": None,
}
if args.dry_run:
return result
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
if args.purge_endpoint:
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
result["purged"] = True
lease = start_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
idempotency_key,
revision,
)
try:
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
deployment = finalize_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
str(lease["deployment_id"]),
idempotency_key,
revision,
manifest_sha256,
)
except Exception:
try:
abort_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
str(lease["deployment_id"]),
idempotency_key,
)
except DeployError:
pass
raise
result["deployment"] = deployment
result["purged"] = bool(deployment.get("purged"))
elif args.pull_zone_id:
if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
result["purged"] = True
elif result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
return result
@ -385,6 +550,19 @@ def parser() -> argparse.ArgumentParser:
bunny.add_argument(
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
)
bunny.add_argument(
"--revision",
default=(
os.environ.get("STATIC_SITE_REVISION")
or os.environ.get("GITHUB_SHA")
or os.environ.get("FORGEJO_SHA")
or os.environ.get("CI_COMMIT_SHA", "")
),
)
bunny.add_argument(
"--idempotency-key",
default=os.environ.get("STATIC_SITE_IDEMPOTENCY_KEY", ""),
)
bunny.add_argument(
"--workers",
type=positive_int,