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
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue