feat: coordinate static site deployments through Gandalf
All checks were successful
Test / test (push) Successful in 8s
All checks were successful
Test / test (push) Successful in 8s
This commit is contained in:
parent
d3a6b4bb0a
commit
411010ef5a
4 changed files with 257 additions and 17 deletions
188
static_site.py
188
static_site.py
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue