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.