feat(static-sites): let project sites declare Bunny edge rules
All checks were successful
Test / test (push) Successful in 6s
All checks were successful
Test / test (push) Successful in 6s
Project sites can pass a TOML file of edge rules (redirects, status codes, blocking, response headers, cache times) to the deploy action. The gateway validates them when a deployment starts and reconciles its own rules on the site's Pull Zones when it finalizes, deleting dropped rules and purging when a rule changed. Actions that could reach beyond the site, such as origin changes, are not available. The purge retry is limited to recorded purge failures, so a deployment whose rules failed to apply cannot be completed by a purge alone.
This commit is contained in:
parent
411010ef5a
commit
4490557f13
4 changed files with 263 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ import sys
|
|||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "static_site.py"
|
||||
|
|
@ -292,5 +293,117 @@ class StaticSiteTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
MARKDOWN_RULES = '''
|
||||
[[edge_rules]]
|
||||
name = "markdown"
|
||||
url = "*.md"
|
||||
response_headers = { "Content-Type" = "text/markdown; charset=utf-8" }
|
||||
|
||||
[[edge_rules]]
|
||||
name = "old-blog"
|
||||
url = ["*/blog/*"]
|
||||
redirect = { to = "https://example.org{{path}}", status = 301 }
|
||||
'''
|
||||
|
||||
|
||||
def gateway_args(root: Path, edge_rules: Path | None) -> Any:
|
||||
return static_site.argparse.Namespace(
|
||||
directory=root,
|
||||
zone="example-org",
|
||||
password="storage-password",
|
||||
endpoint="storage.bunnycdn.com",
|
||||
purge_endpoint="https://purge.example",
|
||||
pull_zone_id="",
|
||||
api_key="",
|
||||
dry_run=False,
|
||||
workers=4,
|
||||
revision="abc1234",
|
||||
idempotency_key="",
|
||||
edge_rules=edge_rules,
|
||||
)
|
||||
|
||||
|
||||
class EdgeRuleTests(unittest.TestCase):
|
||||
def write_rules(self, directory: str, text: str) -> Path:
|
||||
path = Path(directory) / "bunny.toml"
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def test_loads_declared_rules(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rules = static_site.load_edge_rules(
|
||||
self.write_rules(directory, MARKDOWN_RULES)
|
||||
)
|
||||
self.assertEqual(
|
||||
rules,
|
||||
[
|
||||
{
|
||||
"name": "markdown",
|
||||
"url": "*.md",
|
||||
"response_headers": {
|
||||
"Content-Type": "text/markdown; charset=utf-8"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "old-blog",
|
||||
"url": ["*/blog/*"],
|
||||
"redirect": {"to": "https://example.org{{path}}", "status": 301},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_malformed_files(self) -> None:
|
||||
# Rule contents are validated by the gateway; see handler_test.ts.
|
||||
cases = {
|
||||
"unknown keys": 'headers = "x"\n' + MARKDOWN_RULES,
|
||||
"must be \\[\\[edge_rules\\]\\] tables": 'edge_rules = ["x"]\n',
|
||||
"unsupported value": MARKDOWN_RULES + "since = 2026-10-02\n",
|
||||
"not valid TOML": "[[edge_rules]\n",
|
||||
}
|
||||
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))
|
||||
|
||||
def test_edge_rules_need_the_gateway(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
write_site(root)
|
||||
args = gateway_args(root, self.write_rules(directory, MARKDOWN_RULES))
|
||||
args.purge_endpoint = ""
|
||||
with self.assertRaisesRegex(DeployError, "BUNNY_PURGE_ENDPOINT"):
|
||||
static_site.deploy_bunny(args)
|
||||
|
||||
@mock.patch.object(static_site, "finalize_bunny_deployment")
|
||||
@mock.patch.object(static_site, "start_bunny_deployment")
|
||||
@mock.patch.object(static_site, "BunnyStorage")
|
||||
def test_finalization_sends_declared_rules_or_none(
|
||||
self,
|
||||
storage_class: mock.Mock,
|
||||
start_deployment: mock.Mock,
|
||||
finalize_deployment: mock.Mock,
|
||||
) -> None:
|
||||
storage_class.return_value.get_manifest.return_value = None
|
||||
start_deployment.return_value = {"deployment_id": "deployment-id"}
|
||||
finalize_deployment.return_value = {"purged": True}
|
||||
keys = []
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
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, [])):
|
||||
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)
|
||||
self.assertEqual(result["edge_rules"], expected)
|
||||
keys.append(start_deployment.call_args.args[3])
|
||||
# Changing only the rules must not replay the previous deployment.
|
||||
self.assertNotEqual(keys[0], keys[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue