feat(static-site-deploy): deploy with the workflow's OIDC identity
All checks were successful
Test / test (push) Successful in 9s
All checks were successful
Test / test (push) Successful in 9s
Without BUNNY_STORAGE_PASSWORD, the action now requests the running workflow's OIDC identity token (Forgejo with enable-openid-connect, GitHub with id-token: write) for the gateway's audience and starts the deployment with it. It takes the Storage Zone password from the gateway's answer, masks it in the log, plans and uploads with it, and finalizes or aborts with a fresh token. A dry run in this mode stays local. Password deployments work as before. action.yml finds its script under FORGEJO_ACTION_PATH or GITHUB_ACTION_PATH, so the action also runs on GitHub Actions.
This commit is contained in:
parent
4490557f13
commit
c242db40a1
4 changed files with 247 additions and 23 deletions
52
README.md
52
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# Static Site Deploy
|
# Static Site Deploy
|
||||||
|
|
||||||
A small, dependency-free Forgejo Action that synchronizes a completed static
|
A small, dependency-free action for Forgejo Actions and GitHub Actions that
|
||||||
website build to a dedicated Bunny Storage Zone.
|
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
|
The runner needs Bash and Python 3.11 or newer. The action itself installs no
|
||||||
packages and executes no code from the Gandalf repository.
|
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
|
minutes. The dedicated Storage Zone password authorizes this workflow without
|
||||||
exposing an account-wide API key to the project.
|
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@<commit-sha>
|
||||||
|
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: <owner>/static-site-deploy@<commit-sha>
|
||||||
|
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
|
## Edge rules
|
||||||
|
|
||||||
Bunny Storage has no `_headers` or `_redirects` file and picks a file's
|
Bunny Storage has no `_headers` or `_redirects` file and picks a file's
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,5 @@ runs:
|
||||||
echo "dry-run must be true or false" >&2
|
echo "dry-run must be true or false" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
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[@]}"
|
||||||
|
|
|
||||||
100
static_site.py
100
static_site.py
|
|
@ -385,6 +385,38 @@ def deployment_gateway_request(
|
||||||
raise DeployError(f"Bunny deployment gateway failed: {exc.reason}") from exc
|
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(
|
def start_bunny_deployment(
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
zone: str,
|
zone: str,
|
||||||
|
|
@ -408,7 +440,12 @@ def start_bunny_deployment(
|
||||||
deployment = result.get("deployment")
|
deployment = result.get("deployment")
|
||||||
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
|
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
|
||||||
raise DeployError("Bunny deployment gateway returned an invalid lease")
|
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(
|
def finalize_bunny_deployment(
|
||||||
|
|
@ -464,6 +501,14 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
raise DeployError(
|
raise DeployError(
|
||||||
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
|
"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)
|
edge_rules_path = getattr(args, "edge_rules", None)
|
||||||
if edge_rules_path and not args.purge_endpoint:
|
if edge_rules_path and not args.purge_endpoint:
|
||||||
raise DeployError("edge rules can only be applied through BUNNY_PURGE_ENDPOINT")
|
raise DeployError("edge rules can only be applied through BUNNY_PURGE_ENDPOINT")
|
||||||
|
|
@ -479,12 +524,11 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
|
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
|
||||||
f"{args.zone}\0{revision or ''}\0{manifest_sha256}\0{rules_sha256}".encode()
|
f"{args.zone}\0{revision or ''}\0{manifest_sha256}\0{rules_sha256}".encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
storage = BunnyStorage(args.zone, args.password, args.endpoint)
|
|
||||||
remote = parse_manifest(storage.get_manifest())
|
def report(upload: list[str] | None, delete: list[str] | None) -> dict[str, Any]:
|
||||||
upload, delete = bunny_plan(files, remote)
|
return {
|
||||||
result = {
|
|
||||||
"backend": "bunny",
|
"backend": "bunny",
|
||||||
"changed": bool(upload or delete),
|
"changed": None if upload is None else bool(upload or delete),
|
||||||
"dry_run": args.dry_run,
|
"dry_run": args.dry_run,
|
||||||
"upload": upload,
|
"upload": upload,
|
||||||
"delete": delete,
|
"delete": delete,
|
||||||
|
|
@ -492,25 +536,61 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
"deployment": None,
|
"deployment": None,
|
||||||
"edge_rules": [rule.get("name") for rule in edge_rules],
|
"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:
|
if args.dry_run:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if args.purge_endpoint:
|
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(
|
lease = start_bunny_deployment(
|
||||||
args.purge_endpoint,
|
args.purge_endpoint,
|
||||||
args.zone,
|
args.zone,
|
||||||
args.password,
|
credential(),
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
revision,
|
revision,
|
||||||
edge_rules,
|
edge_rules,
|
||||||
)
|
)
|
||||||
try:
|
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"]:
|
if result["changed"]:
|
||||||
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
||||||
deployment = finalize_bunny_deployment(
|
deployment = finalize_bunny_deployment(
|
||||||
args.purge_endpoint,
|
args.purge_endpoint,
|
||||||
args.zone,
|
args.zone,
|
||||||
args.password,
|
credential(),
|
||||||
str(lease["deployment_id"]),
|
str(lease["deployment_id"]),
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
revision,
|
revision,
|
||||||
|
|
@ -522,7 +602,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
abort_bunny_deployment(
|
abort_bunny_deployment(
|
||||||
args.purge_endpoint,
|
args.purge_endpoint,
|
||||||
args.zone,
|
args.zone,
|
||||||
args.password,
|
credential(),
|
||||||
str(lease["deployment_id"]),
|
str(lease["deployment_id"]),
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
)
|
)
|
||||||
|
|
@ -532,11 +612,13 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
result["deployment"] = deployment
|
result["deployment"] = deployment
|
||||||
result["purged"] = bool(deployment.get("purged"))
|
result["purged"] = bool(deployment.get("purged"))
|
||||||
elif args.pull_zone_id:
|
elif args.pull_zone_id:
|
||||||
|
assert storage is not None
|
||||||
if result["changed"]:
|
if result["changed"]:
|
||||||
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
||||||
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
|
||||||
elif result["changed"]:
|
elif result["changed"]:
|
||||||
|
assert storage is not None
|
||||||
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
from contextlib import redirect_stdout
|
||||||
import importlib.util
|
import importlib.util
|
||||||
from io import BytesIO
|
from io import BytesIO, StringIO
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -407,3 +408,95 @@ class EdgeRuleTests(unittest.TestCase):
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue