feat(gandalf): declare the not-found page in bunny.toml
All checks were successful
Test / test (push) Successful in 6s
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:
parent
c242db40a1
commit
ffb46edc65
4 changed files with 72 additions and 28 deletions
17
README.md
17
README.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue