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
|
Only run deployments for trusted branches. A contributor able to change a
|
||||||
workflow that receives production secrets can attempt to expose those secrets.
|
workflow that receives production secrets can attempt to expose those secrets.
|
||||||
|
|
||||||
`purge-endpoint` is optional. The Gandalf-managed gateway reuses the dedicated
|
`purge-endpoint` is the backward-compatible name of the optional Gandalf
|
||||||
Storage Zone password to authorize purging only Pull Zones linked to that
|
deployment gateway input. Before uploading, the action acquires an expiring
|
||||||
allowlisted zone. It avoids exposing the account-wide Bunny API key to project
|
per-zone lease. After publishing the manifest, Gandalf verifies its SHA-256
|
||||||
repositories. A configured purge is attempted after every non-dry-run sync,
|
digest, records the revision and deployment outcome, and purges the mapped Pull
|
||||||
including a retry where Storage already matches the local build.
|
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
|
## Command line
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ inputs:
|
||||||
required: false
|
required: false
|
||||||
default: storage.bunnycdn.com
|
default: storage.bunnycdn.com
|
||||||
purge-endpoint:
|
purge-endpoint:
|
||||||
description: Optional trusted static-site purge gateway base URL.
|
description: Optional trusted Gandalf deployment gateway base URL.
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
dry-run:
|
dry-run:
|
||||||
|
|
@ -36,6 +36,7 @@ runs:
|
||||||
BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }}
|
BUNNY_STORAGE_ZONE: ${{ inputs.storage-zone }}
|
||||||
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
|
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
|
||||||
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
|
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
|
||||||
|
STATIC_SITE_REVISION: ${{ github.sha }}
|
||||||
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
||||||
STATIC_SITE_WORKERS: ${{ inputs.workers }}
|
STATIC_SITE_WORKERS: ${{ inputs.workers }}
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
188
static_site.py
188
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]]:
|
def parse_manifest(raw: bytes | None) -> dict[str, dict[str, Any]]:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -182,7 +186,7 @@ class BunnyStorage:
|
||||||
)
|
)
|
||||||
|
|
||||||
def upload_manifest(self, manifest: dict[str, Any]) -> None:
|
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(
|
self._request(
|
||||||
"PUT",
|
"PUT",
|
||||||
MANIFEST_NAME,
|
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
|
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]:
|
def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
if args.purge_endpoint and args.pull_zone_id:
|
if args.purge_endpoint and args.pull_zone_id:
|
||||||
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"
|
||||||
)
|
)
|
||||||
files = collect_files(args.directory)
|
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)
|
storage = BunnyStorage(args.zone, args.password, args.endpoint)
|
||||||
remote = parse_manifest(storage.get_manifest())
|
remote = parse_manifest(storage.get_manifest())
|
||||||
upload, delete = bunny_plan(files, remote)
|
upload, delete = bunny_plan(files, remote)
|
||||||
|
|
@ -313,18 +444,52 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
"upload": upload,
|
"upload": upload,
|
||||||
"delete": delete,
|
"delete": delete,
|
||||||
"purged": False,
|
"purged": False,
|
||||||
|
"deployment": None,
|
||||||
}
|
}
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if result["changed"]:
|
|
||||||
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
|
||||||
if args.purge_endpoint:
|
if args.purge_endpoint:
|
||||||
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
|
lease = start_bunny_deployment(
|
||||||
result["purged"] = True
|
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:
|
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)
|
purge_bunny_pull_zone(args.pull_zone_id, args.api_key)
|
||||||
result["purged"] = True
|
result["purged"] = True
|
||||||
|
elif result["changed"]:
|
||||||
|
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -385,6 +550,19 @@ def parser() -> argparse.ArgumentParser:
|
||||||
bunny.add_argument(
|
bunny.add_argument(
|
||||||
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
|
"--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(
|
bunny.add_argument(
|
||||||
"--workers",
|
"--workers",
|
||||||
type=positive_int,
|
type=positive_int,
|
||||||
|
|
|
||||||
|
|
@ -203,10 +203,14 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
request.get_header("Authorization"), "Bearer storage-password"
|
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")
|
@mock.patch.object(static_site, "BunnyStorage")
|
||||||
def test_unchanged_deployment_retries_gateway_purge(
|
def test_unchanged_deployment_is_finalized_without_forcing_a_purge(
|
||||||
self, storage_class: mock.Mock, purge_gateway: mock.Mock
|
self,
|
||||||
|
storage_class: mock.Mock,
|
||||||
|
start_deployment: mock.Mock,
|
||||||
|
finalize_deployment: mock.Mock,
|
||||||
) -> None:
|
) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
@ -215,6 +219,12 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
storage_class.return_value.get_manifest.return_value = json.dumps(
|
storage_class.return_value.get_manifest.return_value = json.dumps(
|
||||||
manifest_for(files)
|
manifest_for(files)
|
||||||
).encode()
|
).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(
|
args = static_site.argparse.Namespace(
|
||||||
directory=root,
|
directory=root,
|
||||||
zone="example-org",
|
zone="example-org",
|
||||||
|
|
@ -224,13 +234,61 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
pull_zone_id="",
|
pull_zone_id="",
|
||||||
api_key="",
|
api_key="",
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
|
workers=4,
|
||||||
|
revision="abc1234",
|
||||||
|
idempotency_key="test-run-123",
|
||||||
)
|
)
|
||||||
result = static_site.deploy_bunny(args)
|
result = static_site.deploy_bunny(args)
|
||||||
|
|
||||||
self.assertFalse(result["changed"])
|
self.assertFalse(result["changed"])
|
||||||
self.assertTrue(result["purged"])
|
self.assertFalse(result["purged"])
|
||||||
purge_gateway.assert_called_once_with(
|
start_deployment.assert_called_once()
|
||||||
"https://purge.example", "example-org", "storage-password"
|
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