1 Commits
Author SHA1 Message Date
Evelyn Chen a224109868 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 23:01:18 +00:00
4 changed files with 19 additions and 82 deletions
+1 -4
View File
@@ -133,14 +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 and immutable paths. When a move Generated Ingress annotations retain each bucket'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 bucket-keyed history rejects a protected-to-public transition even when the
artifact is renamed; publishing that artifact publicly requires a new bucket. artifact is renamed; publishing that artifact publicly requires a new bucket.
Legacy single-surface is public for this downgrade check. Removing or renaming
an immutable rule preserves its prior URLs; current mutable content at one of
those paths is rejected instead of replacing it.
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
+17 -39
View File
@@ -178,29 +178,24 @@ def publish_route_immutables(artifact, route, site_dir, credential_env_names=Non
publish_immutable_rule(artifact, route, rule, html_dir, aws_env) publish_immutable_rule(artifact, route, rule, html_dir, aws_env)
def retired_immutable_filters(artifact, route, html_dir, previous_contract): def retired_immutable_filters(artifact, route, html_dir, previous_path):
"""Protect immutable keys only when an old route falls inside the new scope.""" """Protect immutable keys only when an old route falls inside the new scope."""
if not previous_contract: if not previous_path or previous_path == route["path"]:
return [] return []
current_prefix = route["path"].strip("/") current_prefix = route["path"].strip("/")
previous_prefix = previous_contract["path"].strip("/") previous_prefix = previous_path.strip("/")
if current_prefix: if current_prefix:
marker = f"{current_prefix}/" marker = f"{current_prefix}/"
if previous_prefix == current_prefix: if not previous_prefix.startswith(marker):
previous_prefix = ""
elif not previous_prefix.startswith(marker):
return [] return []
else: previous_prefix = previous_prefix[len(marker):]
previous_prefix = previous_prefix[len(marker):]
filters = [] filters = []
current_immutable = { for rule in artifact["cache_rules"]:
rule["path"] for rule in artifact["cache_rules"] if _is_immutable(rule) if not _is_immutable(rule):
} continue
for immutable_path in previous_contract["immutable_paths"]: retired_path = "/".join(part for part in (previous_prefix, rule["path"]) if part)
retired_path = "/".join(part for part in (previous_prefix, immutable_path) if part)
collision = html_dir / retired_path collision = html_dir / retired_path
if (immutable_path not in current_immutable and collision.exists() if collision.exists() and any(path.is_file() for path in collision.rglob("*")):
and any(path.is_file() for path in collision.rglob("*"))):
raise RuntimeError( raise RuntimeError(
f"current artifact collides with retired immutable partition: {retired_path}" f"current artifact collides with retired immutable partition: {retired_path}"
) )
@@ -208,7 +203,7 @@ def retired_immutable_filters(artifact, route, html_dir, previous_contract):
return filters return filters
def s3_sync(artifact, route, site_dir, credential_env_names=None, previous_contract=None): def s3_sync(artifact, route, site_dir, credential_env_names=None, previous_path=None):
endpoint = artifact["s3_endpoint"] endpoint = artifact["s3_endpoint"]
html_dir = site_dir / artifact["build_dir"] html_dir = site_dir / artifact["build_dir"]
aws_env = publication_aws_env(artifact, credential_env_names) aws_env = publication_aws_env(artifact, credential_env_names)
@@ -236,7 +231,7 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None, previous_contr
# so a fresh upload always carries the right MIME type. # so a fresh upload always carries the right MIME type.
specific_paths = [rule["path"] for rule in artifact["cache_rules"] if rule["path"]] specific_paths = [rule["path"] for rule in artifact["cache_rules"] if rule["path"]]
default_filters = [arg for path in specific_paths for arg in ("--exclude", f"{path}/*")] default_filters = [arg for path in specific_paths for arg in ("--exclude", f"{path}/*")]
retired_filters = retired_immutable_filters(artifact, route, html_dir, previous_contract) retired_filters = retired_immutable_filters(artifact, route, html_dir, previous_path)
run(["aws", "--endpoint-url", endpoint, "s3", "cp", f"{html_dir}/", destination, run(["aws", "--endpoint-url", endpoint, "s3", "cp", f"{html_dir}/", destination,
"--recursive", "--only-show-errors", "--cache-control", default_cache, "--recursive", "--only-show-errors", "--cache-control", default_cache,
*default_filters, *retired_filters, *exclude_args], env=aws_env) *default_filters, *retired_filters, *exclude_args], env=aws_env)
@@ -317,13 +312,7 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
for route in cfg["routes"]: for route in cfg["routes"]:
artifact = artifact_by_name[route["artifact"]] artifact = artifact_by_name[route["artifact"]]
resource_name = k8s_name(site_name) if cfg["compatibility"] else f"{k8s_name(site_name)}-{route['name']}" resource_name = k8s_name(site_name) if cfg["compatibility"] else f"{k8s_name(site_name)}-{route['name']}"
immutable_paths = [ routes.append({**route, "resource_name": resource_name, "artifact_config": artifact})
rule["path"] for rule in artifact["cache_rules"] if _is_immutable(rule)
]
routes.append({
**route, "resource_name": resource_name, "artifact_config": artifact,
"immutable_paths_json": json.dumps(immutable_paths, separators=(",", ":")),
})
template_vars = { template_vars = {
"site": site_name, "site": site_name,
"site_k8s": k8s_name(site_name), "site_k8s": k8s_name(site_name),
@@ -348,26 +337,16 @@ def previous_route_contracts(app_dir):
artifact = annotations.get("site-publish.fritzlab.net/artifact") artifact = annotations.get("site-publish.fritzlab.net/artifact")
access = annotations.get("site-publish.fritzlab.net/access") access = annotations.get("site-publish.fritzlab.net/access")
bucket = annotations.get("site-publish.fritzlab.net/bucket") bucket = annotations.get("site-publish.fritzlab.net/bucket")
immutable_paths_json = annotations.get("site-publish.fritzlab.net/immutable-paths")
route_path = annotations.get("site-publish.fritzlab.net/route-path") route_path = annotations.get("site-publish.fritzlab.net/route-path")
values = (artifact, access, bucket, immutable_paths_json, route_path) values = (artifact, access, bucket, route_path)
if all(value is None for value in values): if all(value is None for value in values):
continue continue
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}")
try:
immutable_paths = json.loads(immutable_paths_json)
except json.JSONDecodeError as exc:
raise RuntimeError(f"invalid site-publish route history in {path}") from exc
if not isinstance(immutable_paths, list) or any(not isinstance(item, str) for item in immutable_paths):
raise RuntimeError(f"invalid site-publish route history in {path}")
if bucket in contracts: if bucket in contracts:
raise RuntimeError(f"duplicate site-publish route history for bucket {bucket}") raise RuntimeError(f"duplicate site-publish route history for bucket {bucket}")
contracts[bucket] = { contracts[bucket] = {"path": route_path, "access": access, "artifact": artifact}
"path": route_path, "access": access, "artifact": artifact,
"immutable_paths": immutable_paths,
}
return contracts return contracts
@@ -376,8 +355,7 @@ def validate_route_migrations(cfg, previous_contracts):
for route in cfg["routes"]: for route in cfg["routes"]:
artifact = artifacts[route["artifact"]] artifact = artifacts[route["artifact"]]
previous = previous_contracts.get(artifact["bucket"]) previous = previous_contracts.get(artifact["bucket"])
if (previous and previous["access"] == "protected" if (previous and previous["access"] == "protected" and route["access"] == "public"
and route["access"] in {"public", "legacy"}
): ):
raise RuntimeError( raise RuntimeError(
f"artifact {route['artifact']} cannot become public while reusing protected " f"artifact {route['artifact']} cannot become public while reusing protected "
@@ -408,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"]), (previous_contracts.get(artifact_by_name[route["artifact"]]["bucket"]) 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"))
-1
View File
@@ -8,7 +8,6 @@ metadata:
site-publish.fritzlab.net/artifact: {{ route.artifact | tojson }} site-publish.fritzlab.net/artifact: {{ route.artifact | tojson }}
site-publish.fritzlab.net/access: {{ route.access | tojson }} site-publish.fritzlab.net/access: {{ route.access | tojson }}
site-publish.fritzlab.net/bucket: {{ route.artifact_config.bucket | tojson }} site-publish.fritzlab.net/bucket: {{ route.artifact_config.bucket | tojson }}
site-publish.fritzlab.net/immutable-paths: {{ route.immutable_paths_json | tojson }}
site-publish.fritzlab.net/route-path: {{ route.path | tojson }} site-publish.fritzlab.net/route-path: {{ route.path | tojson }}
{%- endif %} {%- endif %}
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 %}
+1 -38
View File
@@ -471,10 +471,7 @@ class PublishingTests(unittest.TestCase):
}, clear=False), patch.object( }, clear=False), patch.object(
deploy, "run", side_effect=lambda command, **_: commands.append(command) deploy, "run", side_effect=lambda command, **_: commands.append(command)
): ):
deploy.s3_sync(artifact, route, root, previous_contract={ deploy.s3_sync(artifact, route, root, previous_path="/foo")
"path": "/foo", "access": "public", "artifact": "distributions",
"immutable_paths": ["releases"],
})
rendered = [" ".join(command) for command in commands] rendered = [" ".join(command) for command in commands]
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))
@@ -484,7 +481,6 @@ class PublishingTests(unittest.TestCase):
previous = { previous = {
"baseline-dist": { "baseline-dist": {
"path": "/dist", "access": "protected", "artifact": "old-name", "path": "/dist", "access": "protected", "artifact": "old-name",
"immutable_paths": ["releases"],
} }
} }
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"): with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
@@ -499,39 +495,6 @@ class PublishingTests(unittest.TestCase):
previous = {"retired-protected-bucket": previous["baseline-dist"]} previous = {"retired-protected-bucket": previous["baseline-dist"]}
deploy.validate_route_migrations(cfg, previous) deploy.validate_route_migrations(cfg, previous)
def test_protected_split_bucket_cannot_become_legacy_public(self):
cfg = normalize_site_config(fixture("legacy-site.yaml"), "baseline.fritzlab.net")
previous = {
"baseline.fritzlab.net": {
"path": "/portal", "access": "protected", "artifact": "portal",
"immutable_paths": [],
}
}
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
deploy.validate_route_migrations(cfg, previous)
def test_removed_immutable_rule_preserves_prior_keys(self):
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
artifact = next(item for item in cfg["artifacts"] if item["name"] == "distributions")
artifact["cache_rules"] = [
rule for rule in artifact["cache_rules"] if rule["path"] != "releases"
]
route = next(item for item in cfg["routes"] if item["artifact"] == "distributions")
with tempfile.TemporaryDirectory() as tmp:
html = Path(tmp)
filters = deploy.retired_immutable_filters(artifact, route, html, {
"path": "/dist", "access": "public", "artifact": "distributions",
"immutable_paths": ["releases"],
})
self.assertEqual(["--exclude", "releases/*"], filters)
(html / "releases").mkdir()
(html / "releases" / "replacement.js").write_text("mutable")
with self.assertRaisesRegex(RuntimeError, "collides with retired immutable"):
deploy.retired_immutable_filters(artifact, route, html, {
"path": "/dist", "access": "public", "artifact": "distributions",
"immutable_paths": ["releases"],
})
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: