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

@ -45,11 +45,14 @@ jobs:
Only run deployments for trusted branches. A contributor able to change a Only run deployments for trusted branches. A contributor able to change a
workflow that receives production secrets can attempt to expose those secrets. workflow that receives production secrets can attempt to expose those secrets.
`purge-endpoint` is optional. The Gandalf-managed gateway reuses the dedicated `purge-endpoint` is the backward-compatible name of the optional Gandalf
Storage Zone password to authorize purging only Pull Zones linked to that deployment gateway input. Before uploading, the action acquires an expiring
allowlisted zone. It avoids exposing the account-wide Bunny API key to project per-zone lease. After publishing the manifest, Gandalf verifies its SHA-256
repositories. A configured purge is attempted after every non-dry-run sync, digest, records the revision and deployment outcome, and purges the mapped Pull
including a retry where Storage already matches the local build. Zones only when the manifest changed from the previous successful deployment.
Interrupted uploads are aborted best-effort; abandoned leases expire after 30
minutes. The dedicated Storage Zone password authorizes this workflow without
exposing an account-wide API key to the project.
## Command line ## Command line

View file

@ -14,7 +14,7 @@ inputs:
required: false required: false
default: storage.bunnycdn.com default: storage.bunnycdn.com
purge-endpoint: purge-endpoint:
description: Optional trusted static-site purge gateway base URL. description: Optional trusted Gandalf deployment gateway base URL.
required: false required: false
default: "" default: ""
dry-run: dry-run:
@ -36,6 +36,7 @@ runs:
BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }} BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }}
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }} BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }} BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
STATIC_SITE_REVISION: ${{ github.sha }}
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }} STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
STATIC_SITE_WORKERS: ${{ inputs.workers }} STATIC_SITE_WORKERS: ${{ inputs.workers }}
run: | run: |

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]]: def parse_manifest(raw: bytes | None) -> dict[str, dict[str, Any]]:
if raw is None: if raw is None:
return {} return {}
@ -182,7 +186,7 @@ class BunnyStorage:
) )
def upload_manifest(self, manifest: dict[str, Any]) -> None: 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( self._request(
"PUT", "PUT",
MANIFEST_NAME, 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 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]: def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
if args.purge_endpoint and args.pull_zone_id: if args.purge_endpoint and args.pull_zone_id:
raise DeployError( raise DeployError(
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive" "BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
) )
files = collect_files(args.directory) 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) storage = BunnyStorage(args.zone, args.password, args.endpoint)
remote = parse_manifest(storage.get_manifest()) remote = parse_manifest(storage.get_manifest())
upload, delete = bunny_plan(files, remote) upload, delete = bunny_plan(files, remote)
@ -313,18 +444,52 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"upload": upload, "upload": upload,
"delete": delete, "delete": delete,
"purged": False, "purged": False,
"deployment": None,
} }
if args.dry_run: if args.dry_run:
return result return result
if args.purge_endpoint:
lease = start_bunny_deployment(
args.purge_endpoint,
args.zone,
args.password,
idempotency_key,
revision,
)
try:
if result["changed"]: if result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers) apply_bunny_plan(storage, files, upload, delete, args.workers)
if args.purge_endpoint: deployment = finalize_bunny_deployment(
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password) args.purge_endpoint,
result["purged"] = True 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: 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) purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
result["purged"] = True result["purged"] = True
elif result["changed"]:
apply_bunny_plan(storage, files, upload, delete, args.workers)
return result return result
@ -385,6 +550,19 @@ def parser() -> argparse.ArgumentParser:
bunny.add_argument( bunny.add_argument(
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "") "--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( bunny.add_argument(
"--workers", "--workers",
type=positive_int, type=positive_int,

View file

@ -203,10 +203,14 @@ class StaticSiteTests(unittest.TestCase):
request.get_header("Authorization"), "Bearer storage-password" request.get_header("Authorization"), "Bearer storage-password"
) )
@mock.patch.object(static_site, "purge_bunny_gateway") @mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "BunnyStorage") @mock.patch.object(static_site, "BunnyStorage")
def test_unchanged_deployment_retries_gateway_purge( def test_unchanged_deployment_is_finalized_without_forcing_a_purge(
self, storage_class: mock.Mock, purge_gateway: mock.Mock self,
storage_class: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
) -> None: ) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@ -215,6 +219,12 @@ class StaticSiteTests(unittest.TestCase):
storage_class.return_value.get_manifest.return_value = json.dumps( storage_class.return_value.get_manifest.return_value = json.dumps(
manifest_for(files) manifest_for(files)
).encode() ).encode()
start_deployment.return_value = {"deployment_id": "deployment-id"}
finalize_deployment.return_value = {
"deployment_id": "deployment-id",
"status": "unchanged",
"purged": False,
}
args = static_site.argparse.Namespace( args = static_site.argparse.Namespace(
directory=root, directory=root,
zone="example-org", zone="example-org",
@ -224,13 +234,61 @@ class StaticSiteTests(unittest.TestCase):
pull_zone_id="", pull_zone_id="",
api_key="", api_key="",
dry_run=False, dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
) )
result = static_site.deploy_bunny(args) result = static_site.deploy_bunny(args)
self.assertFalse(result["changed"]) self.assertFalse(result["changed"])
self.assertTrue(result["purged"]) self.assertFalse(result["purged"])
purge_gateway.assert_called_once_with( start_deployment.assert_called_once()
"https://purge.example", "example-org", "storage-password" finalize_deployment.assert_called_once()
storage_class.return_value.upload_manifest.assert_not_called()
@mock.patch.object(static_site, "abort_bunny_deployment")
@mock.patch.object(static_site, "finalize_bunny_deployment")
@mock.patch.object(static_site, "start_bunny_deployment")
@mock.patch.object(static_site, "apply_bunny_plan")
@mock.patch.object(static_site, "BunnyStorage")
def test_failed_upload_releases_the_deployment_lease(
self,
storage_class: mock.Mock,
apply_plan: mock.Mock,
start_deployment: mock.Mock,
finalize_deployment: mock.Mock,
abort_deployment: mock.Mock,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
storage_class.return_value.get_manifest.return_value = None
start_deployment.return_value = {"deployment_id": "deployment-id"}
apply_plan.side_effect = static_site.DeployError("upload failed")
args = static_site.argparse.Namespace(
directory=root,
zone="example-org",
password="storage-password",
endpoint="storage.bunnycdn.com",
purge_endpoint="https://purge.example",
pull_zone_id="",
api_key="",
dry_run=False,
workers=4,
revision="abc1234",
idempotency_key="test-run-123",
)
with self.assertRaisesRegex(static_site.DeployError, "upload failed"):
static_site.deploy_bunny(args)
finalize_deployment.assert_not_called()
abort_deployment.assert_called_once_with(
"https://purge.example",
"example-org",
"storage-password",
"deployment-id",
"test-run-123",
) )