diff --git a/README.md b/README.md index ed3d440..cf1b630 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Static Site Deploy -A small, dependency-free Forgejo Action that synchronizes a completed static -website build to a dedicated Bunny Storage Zone. +A small, dependency-free action for Forgejo Actions and GitHub Actions that +synchronizes a completed static website build to a dedicated Bunny Storage Zone. The runner needs Bash and Python 3.11 or newer. The action itself installs no packages and executes no code from the Gandalf repository. @@ -54,6 +54,54 @@ 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. +## Deploying with the repository's identity + +With the Gandalf gateway, a site linked to its repository needs no secret at +all. Leave `BUNNY_STORAGE_PASSWORD` unset and let the job request an OIDC +identity token: the action presents it to the gateway, which checks the +repository and branch and hands out the Storage Zone password for this one +deployment. The action masks it in the log, and the gateway replaces it once the +deployment is done (unless the site also allows password deployments). + +Forgejo: + +```yaml +jobs: + deploy: + runs-on: docker + enable-openid-connect: true + steps: + - uses: https://data.forgejo.org/actions/checkout@v6 + - run: npm ci && npm run build + - uses: https://forge.tionis.dev/actions/static-site-deploy@ + with: + directory: dist + storage-zone: example-org + purge-endpoint: https://gandalf.tionis.dev +``` + +GitHub, using a GitHub mirror of this repository: + +```yaml +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - run: npm ci && npm run build + - uses: /static-site-deploy@ + with: + directory: dist + storage-zone: example-org + purge-endpoint: https://gandalf.tionis.dev +``` + +A dry run in this mode only checks the build locally; comparing with the +deployed files needs the password. + ## Edge rules Bunny Storage has no `_headers` or `_redirects` file and picks a file's diff --git a/action.yml b/action.yml index 917537f..190036f 100644 --- a/action.yml +++ b/action.yml @@ -56,4 +56,5 @@ runs: echo "dry-run must be true or false" >&2 exit 2 fi - python3 "$FORGEJO_ACTION_PATH/static_site.py" "${args[@]}" + # Forgejo and GitHub name the action's checkout differently. + python3 "${FORGEJO_ACTION_PATH:-$GITHUB_ACTION_PATH}/static_site.py" "${args[@]}" diff --git a/static_site.py b/static_site.py index 11caabd..d6c5f53 100755 --- a/static_site.py +++ b/static_site.py @@ -385,6 +385,38 @@ def deployment_gateway_request( raise DeployError(f"Bunny deployment gateway failed: {exc.reason}") from exc +def request_identity_token(audience: str) -> str: + """The running workflow's OIDC identity token, from Forgejo or GitHub.""" + url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "") + token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if not url or not token: + raise DeployError( + "no BUNNY_STORAGE_PASSWORD and no workflow identity: let the job " + "request an identity token (Forgejo: enable-openid-connect: true; " + "GitHub: permissions: id-token: write) or set the password" + ) + separator = "&" if "?" in url else "?" + request = urllib.request.Request( + f"{url}{separator}audience={urllib.parse.quote(audience, safe='')}", + headers={"Authorization": f"bearer {token}", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + value = json.loads(response.read()).get("value") + except urllib.error.HTTPError as exc: + exc.read() + raise DeployError( + f"the workflow identity token could not be requested: HTTP {exc.code}" + ) from exc + except (urllib.error.URLError, TimeoutError, ValueError, AttributeError) as exc: + raise DeployError( + f"the workflow identity token could not be requested: {exc}" + ) from exc + if not isinstance(value, str) or not value: + raise DeployError("the CI provider returned no identity token") + return value + + def start_bunny_deployment( endpoint: str, zone: str, @@ -408,7 +440,12 @@ def start_bunny_deployment( 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 + # A deployment by repository identity receives the Storage Zone password. + return { + **deployment, + "storage_password": result.get("storage_password"), + "storage_endpoint": result.get("storage_endpoint"), + } def finalize_bunny_deployment( @@ -464,6 +501,14 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]: raise DeployError( "BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive" ) + # Without the password, the workflow's identity token authenticates, and + # the gateway hands out the password for this deployment only. + identity = not args.password + if identity and not args.purge_endpoint: + raise DeployError( + "BUNNY_STORAGE_PASSWORD is required without the Gandalf gateway " + "(BUNNY_PURGE_ENDPOINT)" + ) edge_rules_path = getattr(args, "edge_rules", None) if edge_rules_path and not args.purge_endpoint: raise DeployError("edge rules can only be applied through BUNNY_PURGE_ENDPOINT") @@ -479,38 +524,73 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]: idempotency_key = args.idempotency_key.strip() or hashlib.sha256( f"{args.zone}\0{revision or ''}\0{manifest_sha256}\0{rules_sha256}".encode() ).hexdigest() - storage = BunnyStorage(args.zone, args.password, args.endpoint) - remote = parse_manifest(storage.get_manifest()) - upload, delete = bunny_plan(files, remote) - result = { - "backend": "bunny", - "changed": bool(upload or delete), - "dry_run": args.dry_run, - "upload": upload, - "delete": delete, - "purged": False, - "deployment": None, - "edge_rules": [rule.get("name") for rule in edge_rules], - } - if args.dry_run: - return result + + def report(upload: list[str] | None, delete: list[str] | None) -> dict[str, Any]: + return { + "backend": "bunny", + "changed": None if upload is None else bool(upload or delete), + "dry_run": args.dry_run, + "upload": upload, + "delete": delete, + "purged": False, + "deployment": None, + "edge_rules": [rule.get("name") for rule in edge_rules], + } + + def plan(storage: BunnyStorage) -> tuple[list[str], list[str]]: + return bunny_plan(files, parse_manifest(storage.get_manifest())) + + if identity: + if args.dry_run: + # Comparing with the deployed files needs the password. + return {**report(None, None), "files": len(files)} + storage = None + result = report(None, None) + else: + storage = BunnyStorage(args.zone, args.password, args.endpoint) + upload, delete = plan(storage) + result = report(upload, delete) + if args.dry_run: + return result if args.purge_endpoint: + audience = gateway_origin(args.purge_endpoint) + + def credential() -> str: + # Identity tokens are short-lived; each request gets a fresh one. + return args.password or request_identity_token(audience) + lease = start_bunny_deployment( args.purge_endpoint, args.zone, - args.password, + credential(), idempotency_key, revision, edge_rules, ) try: + if identity: + password = str(lease.get("storage_password") or "") + if not password: + raise DeployError( + "the gateway handed out no storage credential for this deployment" + ) + # Keeps the password out of the CI log. + print(f"::add-mask::{password}", flush=True) + storage = BunnyStorage( + args.zone, + password, + str(lease.get("storage_endpoint") or args.endpoint), + ) + upload, delete = plan(storage) + result = report(upload, delete) + assert storage is not None if result["changed"]: apply_bunny_plan(storage, files, upload, delete, args.workers) deployment = finalize_bunny_deployment( args.purge_endpoint, args.zone, - args.password, + credential(), str(lease["deployment_id"]), idempotency_key, revision, @@ -522,7 +602,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]: abort_bunny_deployment( args.purge_endpoint, args.zone, - args.password, + credential(), str(lease["deployment_id"]), idempotency_key, ) @@ -532,11 +612,13 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]: result["deployment"] = deployment result["purged"] = bool(deployment.get("purged")) elif args.pull_zone_id: + assert storage is not None 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"]: + assert storage is not None apply_bunny_plan(storage, files, upload, delete, args.workers) return result diff --git a/tests/test_static_site.py b/tests/test_static_site.py index f0959e3..f6f5279 100644 --- a/tests/test_static_site.py +++ b/tests/test_static_site.py @@ -1,5 +1,6 @@ +from contextlib import redirect_stdout import importlib.util -from io import BytesIO +from io import BytesIO, StringIO import json from pathlib import Path import sys @@ -407,3 +408,95 @@ class EdgeRuleTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +def identity_args(root: Path) -> Any: + args = gateway_args(root, None) + args.password = "" + args.idempotency_key = "test-run-identity" + return args + + +class IdentityTests(unittest.TestCase): + @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, "request_identity_token") + def test_identity_deploys_with_the_password_the_gateway_hands_out( + self, + request_token: mock.Mock, + storage_class: mock.Mock, + start_deployment: mock.Mock, + finalize_deployment: mock.Mock, + ) -> None: + request_token.side_effect = ["jwt-start", "jwt-finalize"] + start_deployment.return_value = { + "deployment_id": "deployment-id", + "storage_password": "zone-password", + "storage_endpoint": "la.storage.bunnycdn.com", + } + storage_class.return_value.get_manifest.return_value = None + finalize_deployment.return_value = {"purged": True} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_site(root) + output = StringIO() + with redirect_stdout(output), mock.patch.object(static_site, "apply_bunny_plan"): + result = static_site.deploy_bunny(identity_args(root)) + + request_token.assert_called_with("https://purge.example") + self.assertEqual(start_deployment.call_args.args[2], "jwt-start") + self.assertEqual(finalize_deployment.call_args.args[2], "jwt-finalize") + storage_class.assert_called_once_with( + "example-org", "zone-password", "la.storage.bunnycdn.com" + ) + self.assertIn("::add-mask::zone-password", output.getvalue()) + self.assertNotIn("zone-password", json.dumps(result)) + self.assertTrue(result["purged"]) + + def test_identity_needs_the_gateway(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_site(root) + args = identity_args(root) + args.purge_endpoint = "" + with self.assertRaisesRegex(DeployError, "required without the Gandalf gateway"): + static_site.deploy_bunny(args) + + @mock.patch.object(static_site, "start_bunny_deployment") + def test_identity_dry_run_stays_local(self, start_deployment: mock.Mock) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + write_site(root) + args = identity_args(root) + args.dry_run = True + result = static_site.deploy_bunny(args) + start_deployment.assert_not_called() + self.assertEqual(result["files"], 2) + self.assertIsNone(result["changed"]) + + def test_identity_token_request_explains_missing_permissions(self) -> None: + with mock.patch.dict(static_site.os.environ, {}, clear=True): + with self.assertRaisesRegex(DeployError, "id-token: write"): + static_site.request_identity_token("https://gandalf.example") + + @mock.patch.object(static_site.urllib.request, "urlopen") + def test_identity_token_request_asks_for_the_gateway_audience( + self, urlopen: mock.Mock + ) -> None: + urlopen.return_value.__enter__.return_value.read.return_value = ( + b'{"count": 1, "value": "jwt"}' + ) + environment = { + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://ci.example/token?api-version=2", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + } + with mock.patch.dict(static_site.os.environ, environment, clear=True): + token = static_site.request_identity_token("https://gandalf.example") + self.assertEqual(token, "jwt") + request = urlopen.call_args.args[0] + self.assertEqual( + request.full_url, + "https://ci.example/token?api-version=2&audience=https%3A%2F%2Fgandalf.example", + ) + self.assertEqual(request.get_header("Authorization"), "bearer request-token")