diff --git a/README.md b/README.md index a0b4767..7a21f77 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,9 @@ Mutable default and override partitions receive their final cache policy before the matching prefix-scoped stale deletion, so publication never exposes a provisional cache policy or a pointer to a missing immutable target. Generated Ingress annotations and `site-publish-history.yaml` retain every -seen bucket's access, prior route, and cumulative bucket-relative immutable -paths, including while an artifact is absent. When a move -places that retired prefix inside the new sync scope, only its declared +seen bucket's access, prior route, and cumulative bucket-relative immutable key +prefixes, including while an artifact is absent. Removed or renamed rules stay +recorded. When a move places a retired prefix inside the new sync scope, its immutable subtrees are excluded; a current-file collision fails publication. The bucket-keyed history rejects a protected-to-public transition even when the artifact is renamed; publishing that artifact publicly requires a new bucket. diff --git a/scripts/deploy.py b/scripts/deploy.py index 2a7a1fe..9900d17 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -178,26 +178,40 @@ def publish_route_immutables(artifact, route, site_dir, credential_env_names=Non publish_immutable_rule(artifact, route, rule, html_dir, aws_env) +def immutable_key_prefixes(artifact, route): + """Return immutable partitions as bucket-relative key prefixes.""" + route_prefix = route["path"].strip("/") + return [ + "/".join(part for part in (route_prefix, rule["path"]) if part) + for rule in artifact["cache_rules"] if _is_immutable(rule) + ] + + +def retained_immutable_paths(artifact, route, previous_contract): + """Carry all bucket history forward so later route moves cannot delete it.""" + previous_paths = previous_contract["immutable_paths"] if previous_contract else [] + return sorted(set(previous_paths) | set(immutable_key_prefixes(artifact, route))) + + def retired_immutable_filters(artifact, route, html_dir, previous_contract): - """Protect recorded bucket keys when they fall inside the current sync scope.""" + """Protect historical immutable keys that fall inside the current sync scope.""" if not previous_contract: return [] current_prefix = route["path"].strip("/") filters = [] - current_immutable = { - "/".join(part for part in (current_prefix, rule["path"]) if part) - for rule in artifact["cache_rules"] if _is_immutable(rule) - } + current_immutable = set(immutable_key_prefixes(artifact, route)) for immutable_path in previous_contract["immutable_paths"]: - retired_path = immutable_path + if immutable_path in current_immutable: + continue if current_prefix: marker = f"{current_prefix}/" - if not retired_path.startswith(marker): + if not immutable_path.startswith(marker): continue - retired_path = retired_path[len(marker):] + retired_path = immutable_path[len(marker):] + else: + retired_path = immutable_path collision = html_dir / retired_path - if (immutable_path not in current_immutable and collision.exists() - and any(path.is_file() for path in collision.rglob("*"))): + if collision.exists() and any(path.is_file() for path in collision.rglob("*")): raise RuntimeError( f"current artifact collides with retired immutable partition: {retired_path}" ) @@ -305,8 +319,9 @@ def ensure_bucket_aliases(site_name, aliases, admin_token): raise -def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, - previous_contracts=None): +def render_site_manifests( + site_name, action_dir, app_dir, manifests_dir, cfg, previous_contracts=None, +): """Always re-render manifests from current site.yaml. Templates own domain + aliases, so changes propagate without manual edits.""" manifests_dir.mkdir(parents=True, exist_ok=True) @@ -318,23 +333,17 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, for route in cfg["routes"]: artifact = artifact_by_name[route["artifact"]] resource_name = k8s_name(site_name) if cfg["compatibility"] else f"{k8s_name(site_name)}-{route['name']}" - route_prefix = route["path"].strip("/") - immutable_paths = { - "/".join(part for part in (route_prefix, rule["path"]) if part) - for rule in artifact["cache_rules"] if _is_immutable(rule) - } previous = previous_contracts.get(artifact["bucket"]) - if previous: - immutable_paths.update(previous["immutable_paths"]) + immutable_paths = retained_immutable_paths(artifact, route, previous) next_contracts[artifact["bucket"]] = { "path": route["path"], "access": "public" if route["access"] == "legacy" else route["access"], "artifact": route["artifact"], - "immutable_paths": sorted(immutable_paths), + "immutable_paths": immutable_paths, } routes.append({ **route, "resource_name": resource_name, "artifact_config": artifact, - "immutable_paths_json": json.dumps(sorted(immutable_paths), separators=(",", ":")), + "immutable_paths_json": json.dumps(immutable_paths, separators=(",", ":")), }) template_vars = { "site": site_name, diff --git a/tests/test_contract.py b/tests/test_contract.py index a64c65b..02a7b64 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -485,7 +485,7 @@ class PublishingTests(unittest.TestCase): previous = { "baseline-dist": { "path": "/dist", "access": "protected", "artifact": "old-name", - "immutable_paths": ["releases"], + "immutable_paths": ["dist/releases"], } } with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"): @@ -594,6 +594,41 @@ class PublishingTests(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "invalid site-publish route history"): deploy.previous_route_contracts(app_dir) + def test_retired_immutable_history_survives_a_route_move(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") + route["path"] = "/" + previous = { + "baseline-dist": { + "path": "/foo", "access": "public", "artifact": "distributions", + "immutable_paths": ["foo/releases"], + } + } + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + app_dir = root / "app" + manifests = app_dir / "manifests" + app_dir.mkdir() + deploy.render_site_manifests( + "baseline.fritzlab.net", ROOT, app_dir, manifests, cfg, previous, + ) + following = deploy.previous_route_contracts(app_dir) + self.assertEqual(["foo/releases"], following["baseline-dist"]["immutable_paths"]) + html = root / "html" + html.mkdir() + first_filters = deploy.retired_immutable_filters( + artifact, route, html, previous["baseline-dist"], + ) + following_filters = deploy.retired_immutable_filters( + artifact, route, html, following["baseline-dist"], + ) + self.assertEqual(["--exclude", "foo/releases/*"], first_filters) + self.assertEqual(first_filters, following_filters) + def test_later_route_immutable_failure_stops_all_mutable_publication(self): cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") with tempfile.TemporaryDirectory() as tmp: