feat(static-sites): let project sites declare Bunny edge rules
All checks were successful
Test / test (push) Successful in 6s

Project sites can pass a TOML file of edge rules (redirects, status
codes, blocking, response headers, cache times) to the deploy action.
The gateway validates them when a deployment starts and reconciles its
own rules on the site's Pull Zones when it finalizes, deleting dropped
rules and purging when a rule changed. Actions that could reach beyond
the site, such as origin changes, are not available.

The purge retry is limited to recorded purge failures, so a deployment
whose rules failed to apply cannot be completed by a purge alone.
This commit is contained in:
Eric Wendland 2026-10-02 17:14:42 +02:00
commit 4490557f13
4 changed files with 263 additions and 9 deletions

View file

@ -14,6 +14,7 @@ import subprocess
import sys
import tarfile
import time
import tomllib
import urllib.error
import urllib.parse
import urllib.request
@ -27,7 +28,6 @@ MANIFEST_VERSION = 1
BUNNY_RETRY_DELAYS = (1, 2, 4, 8, 16)
BUNNY_TRANSIENT_STATUS_CODES = {401, 408, 429, 500, 502, 503, 504}
DEFAULT_BUNNY_TRANSFER_WORKERS = 16
T = TypeVar("T")
@ -35,6 +35,34 @@ class DeployError(RuntimeError):
"""A static-site deployment could not be completed safely."""
def load_edge_rules(path: Path) -> list[dict[str, Any]]:
"""Read the [[edge_rules]] tables of a TOML file.
Only the structure is checked here; the Gandalf gateway validates the rules
when a deployment starts, before anything is uploaded.
"""
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise DeployError(f"cannot read edge rules {path}: {exc}") from exc
except tomllib.TOMLDecodeError as exc:
raise DeployError(f"edge rules {path} are not valid TOML: {exc}") from exc
unknown = set(data) - {"edge_rules"}
if unknown:
raise DeployError(
f"edge rules {path} has unknown keys: {', '.join(sorted(unknown))}"
)
rules = data.get("edge_rules", [])
if not isinstance(rules, list) or not all(isinstance(rule, dict) for rule in rules):
raise DeployError(f"edge rules {path}: edge_rules must be [[edge_rules]] tables")
try:
# Dates and times have no JSON form and no meaning in a rule.
json.dumps(rules)
except TypeError as exc:
raise DeployError(f"edge rules {path} contain an unsupported value: {exc}") from exc
return rules
def positive_int(value: str) -> int:
try:
parsed = int(value)
@ -363,14 +391,19 @@ def start_bunny_deployment(
password: str,
idempotency_key: str,
revision: str | None,
edge_rules: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"idempotency_key": idempotency_key, "revision": revision}
if edge_rules is not None:
# Sent here as well so the gateway rejects broken rules before the upload.
payload["edge_rules"] = edge_rules
result = deployment_gateway_request(
endpoint,
zone,
password,
"POST",
"deployments",
{"idempotency_key": idempotency_key, "revision": revision},
payload,
)
deployment = result.get("deployment")
if not isinstance(deployment, dict) or not deployment.get("deployment_id"):
@ -386,23 +419,27 @@ def finalize_bunny_deployment(
idempotency_key: str,
revision: str | None,
manifest_sha256: str,
edge_rules: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"idempotency_key": idempotency_key,
"revision": revision,
"manifest_sha256": manifest_sha256,
}
if edge_rules is not None:
payload["edge_rules"] = edge_rules
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,
},
payload,
)
deployment = result.get("deployment")
if not isinstance(deployment, dict):
raise DeployError("Bunny deployment gateway returned an invalid finalization")
return deployment
return {**deployment, "edge_rules_changed": bool(result.get("edge_rules_changed"))}
def abort_bunny_deployment(
@ -427,12 +464,20 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
raise DeployError(
"BUNNY_PURGE_ENDPOINT and BUNNY_PULL_ZONE_ID are mutually exclusive"
)
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")
# Through the gateway, no file means no rules: it removes rules from earlier deploys.
edge_rules = load_edge_rules(edge_rules_path) if edge_rules_path else []
files = collect_files(args.directory)
manifest = manifest_for(files)
manifest_sha256 = hashlib.sha256(manifest_bytes(manifest)).hexdigest()
revision = args.revision.strip() or None
rules_sha256 = hashlib.sha256(
json.dumps(edge_rules, sort_keys=True).encode()
).hexdigest()
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
f"{args.zone}\0{revision or ''}\0{manifest_sha256}".encode()
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())
@ -445,6 +490,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"delete": delete,
"purged": False,
"deployment": None,
"edge_rules": [rule.get("name") for rule in edge_rules],
}
if args.dry_run:
return result
@ -456,6 +502,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
args.password,
idempotency_key,
revision,
edge_rules,
)
try:
if result["changed"]:
@ -468,6 +515,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
idempotency_key,
revision,
manifest_sha256,
edge_rules,
)
except Exception:
try:
@ -559,6 +607,12 @@ def parser() -> argparse.ArgumentParser:
or os.environ.get("CI_COMMIT_SHA", "")
),
)
bunny.add_argument(
"--edge-rules",
type=Path,
default=os.environ.get("STATIC_SITE_EDGE_RULES") or None,
help="TOML file with [[edge_rules]] for the site's Pull Zones (needs the gateway).",
)
bunny.add_argument(
"--idempotency-key",
default=os.environ.get("STATIC_SITE_IDEMPOTENCY_KEY", ""),