1 Commits
Author SHA1 Message Date
Evelyn Chen 328d94c44f fix(site-publish): close split migration boundaries
Test / contract (pull_request) Successful in 6s
Authored-By: OpenAI (GPT-5) <noreply@openai.com>
2026-08-29 22:47:24 +00:00
4 changed files with 11 additions and 45 deletions
-2
View File
@@ -136,8 +136,6 @@ provisional cache policy or a pointer to a missing immutable target.
Generated Ingress annotations retain each artifact's prior route. When a move Generated Ingress annotations retain each artifact's prior route. When a move
places that retired prefix inside the new sync scope, only its declared places that retired prefix inside the new sync scope, only its declared
immutable subtrees are excluded; a current-file collision fails publication. immutable subtrees are excluded; a current-file collision fails publication.
The same history rejects a protected-to-public transition that reuses its
protected bucket; publishing that artifact publicly requires a new bucket.
Artifact input directories must be pairwise disjoint after filesystem Artifact input directories must be pairwise disjoint after filesystem
resolution. Publication stops before build or upload if one contains another or resolution. Publication stops before build or upload if one contains another or
+11 -29
View File
@@ -325,42 +325,25 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
render_templates(action_dir, template_vars, app_dir, manifests_dir) render_templates(action_dir, template_vars, app_dir, manifests_dir)
def previous_route_contracts(app_dir): def previous_route_paths(app_dir):
"""Read artifact route, access, and bucket history from generated Ingresses.""" """Read artifact-to-route history from generated Ingress annotations."""
contracts = {} paths = {}
manifests = app_dir / "manifests" manifests = app_dir / "manifests"
if not manifests.exists(): if not manifests.exists():
return contracts return paths
for path in sorted(manifests.glob("ingress*.yaml")): for path in sorted(manifests.glob("ingress*.yaml")):
document = yaml.safe_load(path.read_text()) or {} document = yaml.safe_load(path.read_text()) or {}
annotations = document.get("metadata", {}).get("annotations", {}) annotations = document.get("metadata", {}).get("annotations", {})
artifact = annotations.get("site-publish.fritzlab.net/artifact") artifact = annotations.get("site-publish.fritzlab.net/artifact")
access = annotations.get("site-publish.fritzlab.net/access")
bucket = annotations.get("site-publish.fritzlab.net/bucket")
route_path = annotations.get("site-publish.fritzlab.net/route-path") route_path = annotations.get("site-publish.fritzlab.net/route-path")
values = (artifact, access, bucket, route_path) if artifact is None and route_path is None:
if all(value is None for value in values):
continue continue
if (not all(isinstance(value, str) for value in values) if not isinstance(artifact, str) or not isinstance(route_path, str) or not route_path.startswith("/"):
or access not in {"public", "protected"} or not route_path.startswith("/")):
raise RuntimeError(f"invalid site-publish route history in {path}") raise RuntimeError(f"invalid site-publish route history in {path}")
if artifact in contracts: if artifact in paths:
raise RuntimeError(f"duplicate site-publish route history for artifact {artifact}") raise RuntimeError(f"duplicate site-publish route history for artifact {artifact}")
contracts[artifact] = {"path": route_path, "access": access, "bucket": bucket} paths[artifact] = route_path
return contracts return paths
def validate_route_migrations(cfg, previous_contracts):
artifacts = {artifact["name"]: artifact for artifact in cfg["artifacts"]}
for route in cfg["routes"]:
previous = previous_contracts.get(route["artifact"])
artifact = artifacts[route["artifact"]]
if (previous and previous["access"] == "protected" and route["access"] == "public"
and previous["bucket"] == artifact["bucket"]):
raise RuntimeError(
f"artifact {route['artifact']} cannot become public while reusing protected "
f"bucket {artifact['bucket']}"
)
def deploy_static(site_name, site_dir, action_dir, token, cfg): def deploy_static(site_name, site_dir, action_dir, token, cfg):
@@ -374,8 +357,7 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
apps_dir = clone_apps(token) apps_dir = clone_apps(token)
app_dir = apps_dir / "sjc001" / "websites" / site_name app_dir = apps_dir / "sjc001" / "websites" / site_name
manifests_dir = app_dir / "manifests" manifests_dir = app_dir / "manifests"
previous_contracts = previous_route_contracts(app_dir) previous_paths = previous_route_paths(app_dir)
validate_route_migrations(cfg, previous_contracts)
# Complete immutable work across the whole publication before any route's # Complete immutable work across the whole publication before any route's
# mutable pointers can change. Partial immutable success is safe; mixing a # mutable pointers can change. Partial immutable success is safe; mixing a
# new route with an old route after a later immutable failure is not. # new route with an old route after a later immutable failure is not.
@@ -386,7 +368,7 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
for route in cfg["routes"]: for route in cfg["routes"]:
s3_sync( s3_sync(
artifact_by_name[route["artifact"]], route, site_dir, credential_env_names, artifact_by_name[route["artifact"]], route, site_dir, credential_env_names,
(previous_contracts.get(route["artifact"]) or {}).get("path"), previous_paths.get(route["artifact"]),
) )
if cfg["compatibility"]: if cfg["compatibility"]:
ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN")) ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN"))
-2
View File
@@ -5,8 +5,6 @@ metadata:
namespace: {{ namespace }} namespace: {{ namespace }}
annotations: annotations:
site-publish.fritzlab.net/artifact: {{ route.artifact }} site-publish.fritzlab.net/artifact: {{ route.artifact }}
site-publish.fritzlab.net/access: {{ route.access }}
site-publish.fritzlab.net/bucket: {{ route.artifact_config.bucket }}
site-publish.fritzlab.net/route-path: {{ route.path | tojson }} site-publish.fritzlab.net/route-path: {{ route.path | tojson }}
traefik.ingress.kubernetes.io/router.middlewares: https-redirect@file,retry-upstream@file{% if route.access_middleware %},{{ route.access_middleware }}@file{% endif %}{% for m in route.middlewares %},{{ m }}@file{% endfor %} traefik.ingress.kubernetes.io/router.middlewares: https-redirect@file,retry-upstream@file{% if route.access_middleware %},{{ route.access_middleware }}@file{% endif %}{% for m in route.middlewares %},{{ m }}@file{% endfor %}
spec: spec:
-12
View File
@@ -463,18 +463,6 @@ class PublishingTests(unittest.TestCase):
self.assertTrue(all("foo/releases/*" in command for command in rendered[:2])) self.assertTrue(all("foo/releases/*" in command for command in rendered[:2]))
self.assertTrue(all("*/releases/*" not in command for command in rendered)) self.assertTrue(all("*/releases/*" not in command for command in rendered))
def test_protected_bucket_cannot_become_public_across_deployments(self):
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
previous = {
"distributions": {
"path": "/dist", "access": "protected", "bucket": "baseline-dist",
}
}
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
deploy.validate_route_migrations(cfg, previous)
previous["distributions"]["bucket"] = "retired-protected-bucket"
deploy.validate_route_migrations(cfg, previous)
def test_later_route_immutable_failure_stops_all_mutable_publication(self): def test_later_route_immutable_failure_stops_all_mutable_publication(self):
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp: