feat(static-sites): add scoped Bunny purge gateway
All checks were successful
Test / test (push) Successful in 12s
All checks were successful
Test / test (push) Successful in 12s
This commit is contained in:
parent
d40d2837f5
commit
86964aa187
4 changed files with 123 additions and 7 deletions
|
|
@ -37,6 +37,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
directory: dist
|
directory: dist
|
||||||
storage-zone: example-org
|
storage-zone: example-org
|
||||||
|
purge-endpoint: https://tionis-static-site-purge.bunny.run
|
||||||
env:
|
env:
|
||||||
BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
|
BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
|
||||||
```
|
```
|
||||||
|
|
@ -44,6 +45,12 @@ 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
|
||||||
|
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
|
## Command line
|
||||||
|
|
||||||
The script can also be used directly with Python 3.11 or newer:
|
The script can also be used directly with Python 3.11 or newer:
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ inputs:
|
||||||
description: Regional Bunny Storage API hostname.
|
description: Regional Bunny Storage API hostname.
|
||||||
required: false
|
required: false
|
||||||
default: storage.bunnycdn.com
|
default: storage.bunnycdn.com
|
||||||
|
purge-endpoint:
|
||||||
|
description: Optional trusted static-site purge gateway base URL.
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
dry-run:
|
dry-run:
|
||||||
description: Report changes without uploading or deleting files.
|
description: Report changes without uploading or deleting files.
|
||||||
required: false
|
required: false
|
||||||
|
|
@ -27,6 +31,7 @@ runs:
|
||||||
STATIC_SITE_DIRECTORY: ${{ inputs.directory }}
|
STATIC_SITE_DIRECTORY: ${{ inputs.directory }}
|
||||||
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 }}
|
||||||
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
|
||||||
|
|
@ -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
|
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]:
|
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)
|
files = collect_files(args.directory)
|
||||||
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())
|
||||||
|
|
@ -210,15 +247,19 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
"delete": delete,
|
"delete": delete,
|
||||||
"purged": False,
|
"purged": False,
|
||||||
}
|
}
|
||||||
if args.dry_run or not result["changed"]:
|
if args.dry_run:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
for path in upload:
|
if result["changed"]:
|
||||||
storage.upload(files[path])
|
for path in upload:
|
||||||
for path in delete:
|
storage.upload(files[path])
|
||||||
storage.delete(path)
|
for path in delete:
|
||||||
storage.upload_manifest(manifest_for(files))
|
storage.delete(path)
|
||||||
if args.pull_zone_id:
|
storage.upload_manifest(manifest_for(files))
|
||||||
|
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)
|
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
|
||||||
result["purged"] = True
|
result["purged"] = True
|
||||||
return result
|
return result
|
||||||
|
|
@ -278,6 +319,9 @@ def parser() -> argparse.ArgumentParser:
|
||||||
bunny.add_argument(
|
bunny.add_argument(
|
||||||
"--pull-zone-id", default=os.environ.get("BUNNY_PULL_ZONE_ID", "")
|
"--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.add_argument("--dry-run", action="store_true")
|
||||||
bunny.set_defaults(
|
bunny.set_defaults(
|
||||||
handler=deploy_bunny,
|
handler=deploy_bunny,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py"
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py"
|
||||||
SPEC = importlib.util.spec_from_file_location("static_site_deploy", MODULE_PATH)
|
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
|
collect_files = static_site.collect_files
|
||||||
manifest_for = static_site.manifest_for
|
manifest_for = static_site.manifest_for
|
||||||
parse_manifest = static_site.parse_manifest
|
parse_manifest = static_site.parse_manifest
|
||||||
|
purge_bunny_gateway = static_site.purge_bunny_gateway
|
||||||
|
|
||||||
|
|
||||||
def write_site(root: Path) -> None:
|
def write_site(root: Path) -> None:
|
||||||
|
|
@ -77,6 +79,64 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
remote = manifest_for(files)["files"]
|
remote = manifest_for(files)["files"]
|
||||||
self.assertEqual(bunny_plan(files, remote), ([], []))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue