1 Commits
Author SHA1 Message Date
Evelyn Chen 70febd3269 fix(site-publish): close split migration boundaries
Test / contract (pull_request) Successful in 7s
Authored-By: OpenAI (GPT-5) <noreply@openai.com>
2026-08-29 22:49:45 +00:00
4 changed files with 45 additions and 11 deletions
+2
View File
@@ -136,6 +136,8 @@ 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
+29 -11
View File
@@ -325,25 +325,42 @@ 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_paths(app_dir): def previous_route_contracts(app_dir):
"""Read artifact-to-route history from generated Ingress annotations.""" """Read artifact route, access, and bucket history from generated Ingresses."""
paths = {} contracts = {}
manifests = app_dir / "manifests" manifests = app_dir / "manifests"
if not manifests.exists(): if not manifests.exists():
return paths return contracts
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")
if artifact is None and route_path is None: values = (artifact, access, bucket, route_path)
if all(value is None for value in values):
continue continue
if not isinstance(artifact, str) or not isinstance(route_path, str) or not route_path.startswith("/"): if (not all(isinstance(value, str) for value in values)
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 paths: if artifact in contracts:
raise RuntimeError(f"duplicate site-publish route history for artifact {artifact}") raise RuntimeError(f"duplicate site-publish route history for artifact {artifact}")
paths[artifact] = route_path contracts[artifact] = {"path": route_path, "access": access, "bucket": bucket}
return paths return contracts
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):
@@ -357,7 +374,8 @@ 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_paths = previous_route_paths(app_dir) previous_contracts = previous_route_contracts(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.
@@ -368,7 +386,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_paths.get(route["artifact"]), (previous_contracts.get(route["artifact"]) or {}).get("path"),
) )
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,6 +5,8 @@ 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,6 +463,18 @@ 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: