feat(static-sites): add scoped Bunny purge gateway
All checks were successful
Test / test (push) Successful in 12s

This commit is contained in:
Eric Wendland 2026-07-17 19:50:31 +02:00
commit 86964aa187
4 changed files with 123 additions and 7 deletions

View file

@ -37,6 +37,7 @@ jobs:
with:
directory: dist
storage-zone: example-org
purge-endpoint: https://tionis-static-site-purge.bunny.run
env:
BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
```
@ -44,6 +45,12 @@ jobs:
Only run deployments for trusted branches. A contributor able to change a
workflow that receives production secrets can attempt to expose those secrets.
`purge-endpoint` is optional. The Gandalf-managed gateway reuses the dedicated
Storage Zone password to authorize purging only Pull Zones linked to that
allowlisted zone. It avoids exposing the account-wide Bunny API key to project
repositories. A configured purge is attempted after every non-dry-run sync,
including a retry where Storage already matches the local build.
## Command line
The script can also be used directly with Python 3.11 or newer:

View file

@ -13,6 +13,10 @@ inputs:
description: Regional Bunny Storage API hostname.
required: false
default: storage.bunnycdn.com
purge-endpoint:
description: Optional trusted static-site purge gateway base URL.
required: false
default: ""
dry-run:
description: Report changes without uploading or deleting files.
required: false
@ -27,6 +31,7 @@ runs:
STATIC_SITE_DIRECTORY: ${{ inputs.directory }}
BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }}
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
run: |
set -euo pipefail

View file

@ -197,7 +197,44 @@ def purge_bunny_pull_zone(pull_zone_id: str, api_key: str) -> None:
raise DeployError(f"Bunny cache purge failed: {exc.reason}") from exc
def purge_bunny_gateway(endpoint: str, zone: str, password: str) -> None:
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")
url = (
f"https://{parsed.hostname}/v1/purge/"
f"{urllib.parse.quote(zone, safe='')}"
)
request = urllib.request.Request(
url,
data=b"",
headers={"Authorization": f"Bearer {password}", "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=60):
return
except urllib.error.HTTPError as exc:
exc.read()
raise DeployError(f"Bunny purge gateway failed: HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise DeployError(f"Bunny purge gateway failed: {exc.reason}") from exc
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)
storage = BunnyStorage(args.zone, args.password, args.endpoint)
remote = parse_manifest(storage.get_manifest())
@ -210,15 +247,19 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"delete": delete,
"purged": False,
}
if args.dry_run or not result["changed"]:
if args.dry_run:
return result
if result["changed"]:
for path in upload:
storage.upload(files[path])
for path in delete:
storage.delete(path)
storage.upload_manifest(manifest_for(files))
if args.pull_zone_id:
if args.purge_endpoint:
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
result["purged"] = True
elif args.pull_zone_id:
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
result["purged"] = True
return result
@ -278,6 +319,9 @@ def parser() -> argparse.ArgumentParser:
bunny.add_argument(
"--pull-zone-id", default=os.environ.get("BUNNY_PULL_ZONE_ID", "")
)
bunny.add_argument(
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
)
bunny.add_argument("--dry-run", action="store_true")
bunny.set_defaults(
handler=deploy_bunny,

View file

@ -4,6 +4,7 @@ from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py"
SPEC = importlib.util.spec_from_file_location("static_site_deploy", MODULE_PATH)
@ -17,6 +18,7 @@ bunny_plan = static_site.bunny_plan
collect_files = static_site.collect_files
manifest_for = static_site.manifest_for
parse_manifest = static_site.parse_manifest
purge_bunny_gateway = static_site.purge_bunny_gateway
def write_site(root: Path) -> None:
@ -77,6 +79,64 @@ class StaticSiteTests(unittest.TestCase):
remote = manifest_for(files)["files"]
self.assertEqual(bunny_plan(files, remote), ([], []))
def test_gateway_requires_a_plain_https_origin(self) -> None:
invalid = (
"http://purge.example",
"https://purge.example/path",
"https://user@purge.example",
"https://purge.example?token=secret",
)
for endpoint in invalid:
with self.subTest(endpoint=endpoint):
with self.assertRaisesRegex(DeployError, "HTTPS origin"):
purge_bunny_gateway(endpoint, "example-org", "password")
@mock.patch.object(static_site.urllib.request, "urlopen")
def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None:
urlopen.return_value.__enter__.return_value = object()
purge_bunny_gateway(
"https://purge.example", "example-org", "storage-password"
)
request = urlopen.call_args.args[0]
self.assertEqual(
request.full_url,
"https://purge.example/v1/purge/example-org",
)
self.assertEqual(request.method, "POST")
self.assertEqual(
request.get_header("Authorization"), "Bearer storage-password"
)
@mock.patch.object(static_site, "purge_bunny_gateway")
@mock.patch.object(static_site, "BunnyStorage")
def test_unchanged_deployment_retries_gateway_purge(
self, storage_class: mock.Mock, purge_gateway: mock.Mock
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
write_site(root)
files = collect_files(root)
storage_class.return_value.get_manifest.return_value = json.dumps(
manifest_for(files)
).encode()
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,
)
result = static_site.deploy_bunny(args)
self.assertFalse(result["changed"])
self.assertTrue(result["purged"])
purge_gateway.assert_called_once_with(
"https://purge.example", "example-org", "storage-password"
)
if __name__ == "__main__":
unittest.main()