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
`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
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
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
The script can also be used directly with Python 3.11 or newer:

View file

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

View file

@ -35,32 +35,33 @@ 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.
def load_site_config(path: Path) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""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
when a deployment starts, before anything is uploaded.
Only the structure is checked here; the Gandalf gateway validates both 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
raise DeployError(f"cannot read {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"}
raise DeployError(f"{path} is not valid TOML: {exc}") from exc
unknown = set(data) - {"edge_rules", "site"}
if unknown:
raise DeployError(
f"edge rules {path} has unknown keys: {', '.join(sorted(unknown))}"
)
raise DeployError(f"{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")
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:
# Dates and times have no JSON form and no meaning in a rule.
json.dumps(rules)
# Dates and times have no JSON form and no meaning here.
json.dumps([rules, site])
except TypeError as exc:
raise DeployError(f"edge rules {path} contain an unsupported value: {exc}") from exc
return rules
raise DeployError(f"{path} contains an unsupported value: {exc}") from exc
return rules, site
def positive_int(value: str) -> int:
@ -424,11 +425,14 @@ def start_bunny_deployment(
idempotency_key: str,
revision: str | None,
edge_rules: list[dict[str, Any]] | None = None,
site: 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
if site is not None:
payload["site"] = site
result = deployment_gateway_request(
endpoint,
zone,
@ -457,6 +461,7 @@ def finalize_bunny_deployment(
revision: str | None,
manifest_sha256: str,
edge_rules: list[dict[str, Any]] | None = None,
site: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"idempotency_key": idempotency_key,
@ -465,6 +470,8 @@ def finalize_bunny_deployment(
}
if edge_rules is not None:
payload["edge_rules"] = edge_rules
if site is not None:
payload["site"] = site
result = deployment_gateway_request(
endpoint,
zone,
@ -512,14 +519,17 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
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 []
# Through the gateway, no file means no rules and default site settings:
# 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)
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()
json.dumps([edge_rules, site_settings], sort_keys=True).encode()
).hexdigest()
idempotency_key = args.idempotency_key.strip() or hashlib.sha256(
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,
"deployment": None,
"edge_rules": [rule.get("name") for rule in edge_rules],
"site": site_settings,
}
def plan(storage: BunnyStorage) -> tuple[list[str], list[str]]:
@ -567,6 +578,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
idempotency_key,
revision,
edge_rules,
site_settings,
)
try:
if identity:
@ -596,6 +608,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
revision,
manifest_sha256,
edge_rules,
site_settings,
)
except Exception:
try:
@ -693,7 +706,7 @@ def parser() -> argparse.ArgumentParser:
"--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).",
help="The site's bunny.toml: [[edge_rules]] and [site] settings (needs the gateway).",
)
bunny.add_argument(
"--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:
return static_site.argparse.Namespace(
directory=root,
@ -332,9 +338,10 @@ class EdgeRuleTests(unittest.TestCase):
def test_loads_declared_rules(self) -> None:
with tempfile.TemporaryDirectory() as directory:
rules = static_site.load_edge_rules(
self.write_rules(directory, MARKDOWN_RULES)
rules, site = static_site.load_site_config(
self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
)
self.assertEqual(site, {"not_found": "/404.html"})
self.assertEqual(
rules,
[
@ -357,6 +364,7 @@ class EdgeRuleTests(unittest.TestCase):
# Rule contents are validated by the gateway; see handler_test.ts.
cases = {
"unknown keys": 'headers = "x"\n' + MARKDOWN_RULES,
"must be a \\[site\\] table": 'site = "spa"\n',
"must be \\[\\[edge_rules\\]\\] tables": 'edge_rules = ["x"]\n',
"unsupported value": MARKDOWN_RULES + "since = 2026-10-02\n",
"not valid TOML": "[[edge_rules]\n",
@ -364,7 +372,7 @@ class EdgeRuleTests(unittest.TestCase):
for message, text in cases.items():
with self.subTest(message), tempfile.TemporaryDirectory() as directory:
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:
with tempfile.TemporaryDirectory() as directory:
@ -392,14 +400,21 @@ class EdgeRuleTests(unittest.TestCase):
root = Path(directory) / "dist"
root.mkdir()
write_site(root)
rules = self.write_rules(directory, MARKDOWN_RULES)
for path, expected in ((rules, ["markdown", "old-blog"]), (None, [])):
rules = self.write_rules(directory, MARKDOWN_RULES + SITE_SECTION)
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))
for sent in (
start_deployment.call_args.args[5],
finalize_deployment.call_args.args[7],
):
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)
keys.append(start_deployment.call_args.args[3])
# Changing only the rules must not replay the previous deployment.