diff --git a/README.md b/README.md index 6752ea5..f5fb2f8 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/action.yml b/action.yml index ea64df8..2f28abb 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/static_site.py b/static_site.py index ae8c368..e8f1680 100755 --- a/static_site.py +++ b/static_site.py @@ -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 - 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 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.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, diff --git a/tests/test_static_site.py b/tests/test_static_site.py index 9487054..64f1a39 100644 --- a/tests/test_static_site.py +++ b/tests/test_static_site.py @@ -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()