From ffb46edc65c30e0644de073e35c4d0f84ed3808e Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Fri, 2 Oct 2026 23:57:33 +0200 Subject: [PATCH] feat(gandalf): declare the not-found page in bunny.toml 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. --- README.md | 17 ++++++++++++- action.yml | 7 +++--- static_site.py | 51 ++++++++++++++++++++++++--------------- tests/test_static_site.py | 25 +++++++++++++++---- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index cf1b630..f330ebd 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/action.yml b/action.yml index 190036f..851407a 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/static_site.py b/static_site.py index d6c5f53..267d894 100755 --- a/static_site.py +++ b/static_site.py @@ -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", diff --git a/tests/test_static_site.py b/tests/test_static_site.py index f6f5279..92307a0 100644 --- a/tests/test_static_site.py +++ b/tests/test_static_site.py @@ -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.