feat: coordinate static site deployments through Gandalf
All checks were successful
Test / test (push) Successful in 8s
All checks were successful
Test / test (push) Successful in 8s
This commit is contained in:
parent
d3a6b4bb0a
commit
411010ef5a
4 changed files with 257 additions and 17 deletions
13
README.md
13
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
186
static_site.py
186
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 args.purge_endpoint:
|
||||
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)
|
||||
if args.purge_endpoint:
|
||||
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
|
||||
result["purged"] = True
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue