feat(gandalf): declare the not-found page in bunny.toml
All checks were successful
Test / test (push) Successful in 6s

How a site answers missing paths now belongs to its repository like its
edge rules: bunny.toml declares [site] not_found as "default", "spa" or
a page from the build such as "/404.html". The action reads and sends
the table on start and finalize; the gateway validates it before the
upload and applies it to the Storage Zone at finalization, recording
and purging only when it changed. A deployment without [site] resets
the default; older action versions leave the setting alone.

The console's Settings tab only shows the applied value, and the admin
API no longer sets it. The maintainer guide and llms.txt describe the
setting under Configuration.
This commit is contained in:
Eric Wendland 2026-10-02 23:57:33 +02:00
commit ffb46edc65
4 changed files with 72 additions and 28 deletions

View file

@ -106,7 +106,8 @@ deployed files needs the password.
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
`Content-Type` from its extension. With the gateway, a site declares Bunny edge `Content-Type` from its extension. With the gateway, a site declares Bunny edge
rules in a TOML file kept in its repository, outside the build directory: rules and site settings in a TOML file kept in its repository, outside the build
directory:
```yaml ```yaml
with: with:
@ -179,6 +180,20 @@ Pull Zones, removes rules dropped from the file, and purges the cache when a
rule changed. A deployment through the gateway without `edge-rules` removes the rule changed. A deployment through the gateway without `edge-rules` removes the
rules of earlier deployments; rules set up by other means are never touched. rules of earlier deployments; rules set up by other means are never touched.
### Site settings
The same file may hold a `[site]` table:
```toml
[site]
not_found = "/404.html" # or "spa", or "default" (also when left out)
```
`not_found` decides how a missing path is answered: Bunny's 404 page, a page
from the build with status 404, or the build's `/index.html` with status 200 for
single-page apps. Each deployment applies it to the Storage Zone and purges the
cache when it changed.
## Command line ## Command line
The script can also be used directly with Python 3.11 or newer: The script can also be used directly with Python 3.11 or newer:

View file

@ -19,9 +19,10 @@ inputs:
default: "" default: ""
edge-rules: edge-rules:
description: >- description: >-
Optional TOML file with [[edge_rules]] (redirects, status codes, headers, Optional bunny.toml with the site's [[edge_rules]] (redirects, status
cache times) for the site's Pull Zones. Needs purge-endpoint. Through the codes, headers, cache times) and [site] settings (not_found). Needs
gateway, leaving it empty removes rules from earlier deployments. purge-endpoint. Through the gateway, leaving it empty removes rules and
resets settings that earlier deployments declared.
required: false required: false
default: "" default: ""
dry-run: dry-run:

View file

@ -35,32 +35,33 @@ class DeployError(RuntimeError):
"""A static-site deployment could not be completed safely.""" """A static-site deployment could not be completed safely."""
def load_edge_rules(path: Path) -> list[dict[str, Any]]: def load_site_config(path: Path) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Read the [[edge_rules]] tables of a TOML file. """Read a site's bunny.toml: its [[edge_rules]] and its [site] settings.
Only the structure is checked here; the Gandalf gateway validates the rules Only the structure is checked here; the Gandalf gateway validates both when
when a deployment starts, before anything is uploaded. a deployment starts, before anything is uploaded.
""" """
try: try:
data = tomllib.loads(path.read_text(encoding="utf-8")) data = tomllib.loads(path.read_text(encoding="utf-8"))
except OSError as exc: except OSError as exc:
raise DeployError(f"cannot read edge rules {path}: {exc}") from exc raise DeployError(f"cannot read {path}: {exc}") from exc
except tomllib.TOMLDecodeError as exc: except tomllib.TOMLDecodeError as exc:
raise DeployError(f"edge rules {path} are not valid TOML: {exc}") from exc raise DeployError(f"{path} is not valid TOML: {exc}") from exc
unknown = set(data) - {"edge_rules"} unknown = set(data) - {"edge_rules", "site"}
if unknown: if unknown:
raise DeployError( raise DeployError(f"{path} has unknown keys: {', '.join(sorted(unknown))}")
f"edge rules {path} has unknown keys: {', '.join(sorted(unknown))}"
)
rules = data.get("edge_rules", []) rules = data.get("edge_rules", [])
if not isinstance(rules, list) or not all(isinstance(rule, dict) for rule in 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") raise DeployError(f"{path}: edge_rules must be [[edge_rules]] tables")
site = data.get("site", {})
if not isinstance(site, dict):
raise DeployError(f"{path}: site must be a [site] table")
try: try:
# Dates and times have no JSON form and no meaning in a rule. # Dates and times have no JSON form and no meaning here.
json.dumps(rules) json.dumps([rules, site])
except TypeError as exc: except TypeError as exc:
raise DeployError(f"edge rules {path} contain an unsupported value: {exc}") from exc raise DeployError(f"{path} contains an unsupported value: {exc}") from exc
return rules return rules, site
def positive_int(value: str) -> int: def positive_int(value: str) -> int:
@ -424,11 +425,14 @@ def start_bunny_deployment(
idempotency_key: str, idempotency_key: str,
revision: str | None, revision: str | None,
edge_rules: list[dict[str, Any]] | None = None, edge_rules: list[dict[str, Any]] | None = None,
site: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload: dict[str, Any] = {"idempotency_key": idempotency_key, "revision": revision} payload: dict[str, Any] = {"idempotency_key": idempotency_key, "revision": revision}
if edge_rules is not None: if edge_rules is not None:
# Sent here as well so the gateway rejects broken rules before the upload. # Sent here as well so the gateway rejects broken rules before the upload.
payload["edge_rules"] = edge_rules payload["edge_rules"] = edge_rules
if site is not None:
payload["site"] = site
result = deployment_gateway_request( result = deployment_gateway_request(
endpoint, endpoint,
zone, zone,
@ -457,6 +461,7 @@ def finalize_bunny_deployment(
revision: str | None, revision: str | None,
manifest_sha256: str, manifest_sha256: str,
edge_rules: list[dict[str, Any]] | None = None, edge_rules: list[dict[str, Any]] | None = None,
site: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"idempotency_key": idempotency_key, "idempotency_key": idempotency_key,
@ -465,6 +470,8 @@ def finalize_bunny_deployment(
} }
if edge_rules is not None: if edge_rules is not None:
payload["edge_rules"] = edge_rules payload["edge_rules"] = edge_rules
if site is not None:
payload["site"] = site
result = deployment_gateway_request( result = deployment_gateway_request(
endpoint, endpoint,
zone, zone,
@ -512,14 +519,17 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
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")
# Through the gateway, no file means no rules: it removes rules from earlier deploys. # Through the gateway, no file means no rules and default site settings:
edge_rules = load_edge_rules(edge_rules_path) if edge_rules_path else [] # it removes what earlier deployments declared.
edge_rules, site_settings = (
load_site_config(edge_rules_path) if edge_rules_path else ([], {})
)
files = collect_files(args.directory) files = collect_files(args.directory)
manifest = manifest_for(files) manifest = manifest_for(files)
manifest_sha256 = hashlib.sha256(manifest_bytes(manifest)).hexdigest() manifest_sha256 = hashlib.sha256(manifest_bytes(manifest)).hexdigest()
revision = args.revision.strip() or None revision = args.revision.strip() or None
rules_sha256 = hashlib.sha256( rules_sha256 = hashlib.sha256(
json.dumps(edge_rules, sort_keys=True).encode() json.dumps([edge_rules, site_settings], sort_keys=True).encode()
).hexdigest() ).hexdigest()
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()
@ -535,6 +545,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
"purged": False, "purged": False,
"deployment": None, "deployment": None,
"edge_rules": [rule.get("name") for rule in edge_rules], "edge_rules": [rule.get("name") for rule in edge_rules],
"site": site_settings,
} }
def plan(storage: BunnyStorage) -> tuple[list[str], list[str]]: def plan(storage: BunnyStorage) -> tuple[list[str], list[str]]:
@ -567,6 +578,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
idempotency_key, idempotency_key,
revision, revision,
edge_rules, edge_rules,
site_settings,
) )
try: try:
if identity: if identity:
@ -596,6 +608,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
revision, revision,
manifest_sha256, manifest_sha256,
edge_rules, edge_rules,
site_settings,
) )
except Exception: except Exception:
try: try:
@ -693,7 +706,7 @@ def parser() -> argparse.ArgumentParser:
"--edge-rules", "--edge-rules",
type=Path, type=Path,
default=os.environ.get("STATIC_SITE_EDGE_RULES") or None, 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).", help="The site's bunny.toml: [[edge_rules]] and [site] settings (needs the gateway).",
) )
bunny.add_argument( bunny.add_argument(
"--idempotency-key", "--idempotency-key",

View file

@ -307,6 +307,12 @@ redirect = { to = "https://example.org{{path}}", status = 301 }
''' '''
SITE_SECTION = '''
[site]
not_found = "/404.html"
'''
def gateway_args(root: Path, edge_rules: Path | None) -> Any: def gateway_args(root: Path, edge_rules: Path | None) -> Any:
return static_site.argparse.Namespace( return static_site.argparse.Namespace(
directory=root, directory=root,
@ -332,9 +338,10 @@ class EdgeRuleTests(unittest.TestCase):
def test_loads_declared_rules(self) -> None: def test_loads_declared_rules(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
rules = static_site.load_edge_rules( rules, site = static_site.load_site_config(
self.write_rules(directory, MARKDOWN_RULES) self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
) )
self.assertEqual(site, {"not_found": "/404.html"})
self.assertEqual( self.assertEqual(
rules, rules,
[ [
@ -357,6 +364,7 @@ class EdgeRuleTests(unittest.TestCase):
# Rule contents are validated by the gateway; see handler_test.ts. # Rule contents are validated by the gateway; see handler_test.ts.
cases = { cases = {
"unknown keys": 'headers = "x"\n' + MARKDOWN_RULES, "unknown keys": 'headers = "x"\n' + MARKDOWN_RULES,
"must be a \\[site\\] table": 'site = "spa"\n',
"must be \\[\\[edge_rules\\]\\] tables": 'edge_rules = ["x"]\n', "must be \\[\\[edge_rules\\]\\] tables": 'edge_rules = ["x"]\n',
"unsupported value": MARKDOWN_RULES + "since = 2026-10-02\n", "unsupported value": MARKDOWN_RULES + "since = 2026-10-02\n",
"not valid TOML": "[[edge_rules]\n", "not valid TOML": "[[edge_rules]\n",
@ -364,7 +372,7 @@ class EdgeRuleTests(unittest.TestCase):
for message, text in cases.items(): for message, text in cases.items():
with self.subTest(message), tempfile.TemporaryDirectory() as directory: with self.subTest(message), tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(DeployError, message): with self.assertRaisesRegex(DeployError, message):
static_site.load_edge_rules(self.write_rules(directory, text)) static_site.load_site_config(self.write_rules(directory, text))
def test_edge_rules_need_the_gateway(self) -> None: def test_edge_rules_need_the_gateway(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@ -392,14 +400,21 @@ class EdgeRuleTests(unittest.TestCase):
root = Path(directory) / "dist" root = Path(directory) / "dist"
root.mkdir() root.mkdir()
write_site(root) write_site(root)
rules = self.write_rules(directory, MARKDOWN_RULES) rules = self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
for path, expected in ((rules, ["markdown", "old-blog"]), (None, [])): cases = (
(rules, ["markdown", "old-blog"], {"not_found": "/404.html"}),
(None, [], {}),
)
for path, expected, site in cases:
result = static_site.deploy_bunny(gateway_args(root, path)) result = static_site.deploy_bunny(gateway_args(root, path))
for sent in ( for sent in (
start_deployment.call_args.args[5], start_deployment.call_args.args[5],
finalize_deployment.call_args.args[7], finalize_deployment.call_args.args[7],
): ):
self.assertEqual([rule["name"] for rule in sent], expected) self.assertEqual([rule["name"] for rule in sent], expected)
# No [site] means the defaults, which reset earlier settings.
self.assertEqual(start_deployment.call_args.args[6], site)
self.assertEqual(finalize_deployment.call_args.args[8], site)
self.assertEqual(result["edge_rules"], expected) self.assertEqual(result["edge_rules"], expected)
keys.append(start_deployment.call_args.args[3]) keys.append(start_deployment.call_args.args[3])
# Changing only the rules must not replay the previous deployment. # Changing only the rules must not replay the previous deployment.