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 16 additions and 34 deletions
+3 -3
View File
@@ -133,11 +133,11 @@ any route's mutable objects change.
Mutable default and override partitions receive their final cache policy before Mutable default and override partitions receive their final cache policy before
the matching prefix-scoped stale deletion, so publication never exposes a the matching prefix-scoped stale deletion, so publication never exposes a
provisional cache policy or a pointer to a missing immutable target. provisional cache policy or a pointer to a missing immutable target.
Generated Ingress annotations retain each bucket'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 bucket-keyed history rejects a protected-to-public transition even when the The same history rejects a protected-to-public transition that reuses its
artifact is renamed; publishing that artifact publicly requires a new bucket. 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
+7 -7
View File
@@ -326,7 +326,7 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
def previous_route_contracts(app_dir): def previous_route_contracts(app_dir):
"""Read bucket-keyed route history from generated Ingresses.""" """Read artifact route, access, and bucket history from generated Ingresses."""
contracts = {} contracts = {}
manifests = app_dir / "manifests" manifests = app_dir / "manifests"
if not manifests.exists(): if not manifests.exists():
@@ -344,19 +344,19 @@ def previous_route_contracts(app_dir):
if (not all(isinstance(value, str) for value in values) if (not all(isinstance(value, str) for value in values)
or access not in {"public", "protected"} 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 bucket in contracts: if artifact in contracts:
raise RuntimeError(f"duplicate site-publish route history for bucket {bucket}") raise RuntimeError(f"duplicate site-publish route history for artifact {artifact}")
contracts[bucket] = {"path": route_path, "access": access, "artifact": artifact} contracts[artifact] = {"path": route_path, "access": access, "bucket": bucket}
return contracts return contracts
def validate_route_migrations(cfg, previous_contracts): def validate_route_migrations(cfg, previous_contracts):
artifacts = {artifact["name"]: artifact for artifact in cfg["artifacts"]} artifacts = {artifact["name"]: artifact for artifact in cfg["artifacts"]}
for route in cfg["routes"]: for route in cfg["routes"]:
previous = previous_contracts.get(route["artifact"])
artifact = artifacts[route["artifact"]] artifact = artifacts[route["artifact"]]
previous = previous_contracts.get(artifact["bucket"])
if (previous and previous["access"] == "protected" and route["access"] == "public" if (previous and previous["access"] == "protected" and route["access"] == "public"
): and previous["bucket"] == artifact["bucket"]):
raise RuntimeError( raise RuntimeError(
f"artifact {route['artifact']} cannot become public while reusing protected " f"artifact {route['artifact']} cannot become public while reusing protected "
f"bucket {artifact['bucket']}" f"bucket {artifact['bucket']}"
@@ -386,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_contracts.get(artifact_by_name[route["artifact"]]["bucket"]) or {}).get("path"), (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"))
+3 -3
View File
@@ -4,9 +4,9 @@ metadata:
name: {{ route.resource_name }} name: {{ route.resource_name }}
namespace: {{ namespace }} namespace: {{ namespace }}
annotations: annotations:
site-publish.fritzlab.net/artifact: {{ route.artifact | tojson }} site-publish.fritzlab.net/artifact: {{ route.artifact }}
site-publish.fritzlab.net/access: {{ route.access | tojson }} site-publish.fritzlab.net/access: {{ route.access }}
site-publish.fritzlab.net/bucket: {{ route.artifact_config.bucket | tojson }} 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:
+3 -21
View File
@@ -273,17 +273,6 @@ class GenerationTests(unittest.TestCase):
self.assertNotIn("passhostheader", files["manifests/ingress-portal.yaml"]) self.assertNotIn("passhostheader", files["manifests/ingress-portal.yaml"])
self.assertIn("baseline-dist.web.sjc001.fritzlab.net", files["manifests/service-distributions.yaml"]) self.assertIn("baseline-dist.web.sjc001.fritzlab.net", files["manifests/service-distributions.yaml"])
def test_yaml_ambiguous_artifact_name_stays_a_string_annotation(self):
raw = fixture("split-site.yaml")
raw["artifacts"][0]["name"] = "yes"
raw["routes"][0]["artifact"] = "yes"
tmp, _, files = self.render(raw)
self.addCleanup(tmp.cleanup)
ingress = yaml.safe_load(files["manifests/ingress-portal.yaml"])
self.assertEqual(
"yes", ingress["metadata"]["annotations"]["site-publish.fritzlab.net/artifact"],
)
def test_generation_is_deterministic_when_input_lists_are_reversed(self): def test_generation_is_deterministic_when_input_lists_are_reversed(self):
raw = fixture("split-site.yaml") raw = fixture("split-site.yaml")
first_tmp, _, first = self.render(raw) first_tmp, _, first = self.render(raw)
@@ -477,20 +466,13 @@ class PublishingTests(unittest.TestCase):
def test_protected_bucket_cannot_become_public_across_deployments(self): def test_protected_bucket_cannot_become_public_across_deployments(self):
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
previous = { previous = {
"baseline-dist": { "distributions": {
"path": "/dist", "access": "protected", "artifact": "old-name", "path": "/dist", "access": "protected", "bucket": "baseline-dist",
} }
} }
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"): with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
deploy.validate_route_migrations(cfg, previous) deploy.validate_route_migrations(cfg, previous)
renamed = copy.deepcopy(cfg) previous["distributions"]["bucket"] = "retired-protected-bucket"
artifact = next(item for item in renamed["artifacts"] if item["name"] == "distributions")
artifact["name"] = "downloads"
route = next(item for item in renamed["routes"] if item["artifact"] == "distributions")
route["artifact"] = "downloads"
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
deploy.validate_route_migrations(renamed, previous)
previous = {"retired-protected-bucket": previous["baseline-dist"]}
deploy.validate_route_migrations(cfg, previous) 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):