diff --git a/README.md b/README.md index b03cd13..e8acf99 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,14 @@ 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. +`purge-endpoint` is the backward-compatible name of the optional Gandalf +deployment gateway input. Before uploading, the action acquires an expiring +per-zone lease. After publishing the manifest, Gandalf verifies its SHA-256 +digest, records the revision and deployment outcome, and purges the mapped Pull +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 diff --git a/action.yml b/action.yml index 8c9ed1f..f836d74 100644 --- a/action.yml +++ b/action.yml @@ -14,7 +14,7 @@ inputs: required: false default: storage.bunnycdn.com purge-endpoint: - description: Optional trusted static-site purge gateway base URL. + description: Optional trusted Gandalf deployment gateway base URL. required: false default: "" dry-run: @@ -36,6 +36,7 @@ runs: BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }} BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }} BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }} + STATIC_SITE_REVISION: ${{ github.sha }} STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }} STATIC_SITE_WORKERS: ${{ inputs.workers }} run: | diff --git a/static_site.py b/static_site.py index 79fbceb..c185bef 100755 --- a/static_site.py +++ b/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, diff --git a/tests/test_static_site.py b/tests/test_static_site.py index 7366e4f..58ae511 100644 --- a/tests/test_static_site.py +++ b/tests/test_static_site.py @@ -203,10 +203,14 @@ class StaticSiteTests(unittest.TestCase): 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") - def test_unchanged_deployment_retries_gateway_purge( - self, storage_class: mock.Mock, purge_gateway: mock.Mock + def test_unchanged_deployment_is_finalized_without_forcing_a_purge( + self, + storage_class: mock.Mock, + start_deployment: mock.Mock, + finalize_deployment: mock.Mock, ) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -215,6 +219,12 @@ class StaticSiteTests(unittest.TestCase): storage_class.return_value.get_manifest.return_value = json.dumps( manifest_for(files) ).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( directory=root, zone="example-org", @@ -224,13 +234,61 @@ class StaticSiteTests(unittest.TestCase): pull_zone_id="", api_key="", dry_run=False, + workers=4, + revision="abc1234", + idempotency_key="test-run-123", ) 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" + self.assertFalse(result["purged"]) + start_deployment.assert_called_once() + 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", )