From a224109868c36fde5f00c19fc458ab4f4073e7a8 Mon Sep 17 00:00:00 2001 From: Evelyn Chen Date: Sat, 29 Aug 2026 22:42:37 +0000 Subject: [PATCH] fix(site-publish): close split migration boundaries Authored-By: OpenAI (GPT-5) --- README.md | 7 +++- scripts/deploy.py | 86 +++++++++++++++++++++++++++++++++++---- scripts/utils.py | 10 ++++- templates/ingress.yaml.j2 | 6 +++ tests/test_contract.py | 71 +++++++++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6a2c886..281eda3 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,15 @@ any route's mutable objects change. 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 retain each bucket's prior route. When a move +places that retired prefix inside the new sync scope, only its declared +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. Artifact input directories must be pairwise disjoint after filesystem resolution. Publication stops before build or upload if one contains another or -escapes the repository. Descendant symlinks are also rejected, preventing +escapes the repository. Symlinked roots, components, and descendants are also rejected, preventing protected input from entering a public artifact through dereference. Split storage endpoints are pinned to Garage, and each website authority is derived from its bucket; a site cannot expose an arbitrary backend. diff --git a/scripts/deploy.py b/scripts/deploy.py index 025189d..1ec9276 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -12,6 +12,8 @@ from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +import yaml + from utils import ( NAMESPACE, clone_apps, @@ -176,7 +178,32 @@ def publish_route_immutables(artifact, route, site_dir, credential_env_names=Non publish_immutable_rule(artifact, route, rule, html_dir, aws_env) -def s3_sync(artifact, route, site_dir, credential_env_names=None): +def retired_immutable_filters(artifact, route, html_dir, previous_path): + """Protect immutable keys only when an old route falls inside the new scope.""" + if not previous_path or previous_path == route["path"]: + return [] + current_prefix = route["path"].strip("/") + previous_prefix = previous_path.strip("/") + if current_prefix: + marker = f"{current_prefix}/" + if not previous_prefix.startswith(marker): + return [] + previous_prefix = previous_prefix[len(marker):] + filters = [] + for rule in artifact["cache_rules"]: + if not _is_immutable(rule): + continue + retired_path = "/".join(part for part in (previous_prefix, rule["path"]) if part) + collision = html_dir / retired_path + 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}" + ) + filters.extend(("--exclude", f"{retired_path}/*")) + return filters + + +def s3_sync(artifact, route, site_dir, credential_env_names=None, previous_path=None): endpoint = artifact["s3_endpoint"] html_dir = site_dir / artifact["build_dir"] aws_env = publication_aws_env(artifact, credential_env_names) @@ -204,12 +231,13 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None): # so a fresh upload always carries the right MIME type. 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}/*")] + retired_filters = retired_immutable_filters(artifact, route, html_dir, previous_path) run(["aws", "--endpoint-url", endpoint, "s3", "cp", f"{html_dir}/", destination, "--recursive", "--only-show-errors", "--cache-control", default_cache, - *default_filters, *exclude_args], env=aws_env) + *default_filters, *retired_filters, *exclude_args], env=aws_env) run(["aws", "--endpoint-url", endpoint, "s3", "sync", f"{html_dir}/", destination, "--delete", "--only-show-errors", "--cache-control", default_cache, - *default_filters, *exclude_args], env=aws_env) + *default_filters, *retired_filters, *exclude_args], env=aws_env) for rule in artifact["cache_rules"]: if not rule["path"]: continue @@ -297,6 +325,44 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg): render_templates(action_dir, template_vars, app_dir, manifests_dir) +def previous_route_contracts(app_dir): + """Read bucket-keyed route history from generated Ingresses.""" + contracts = {} + manifests = app_dir / "manifests" + if not manifests.exists(): + return contracts + for path in sorted(manifests.glob("ingress*.yaml")): + document = yaml.safe_load(path.read_text()) or {} + annotations = document.get("metadata", {}).get("annotations", {}) + 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") + values = (artifact, access, bucket, route_path) + if all(value is None for value in values): + continue + 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}") + if bucket in contracts: + raise RuntimeError(f"duplicate site-publish route history for bucket {bucket}") + contracts[bucket] = {"path": route_path, "access": access, "artifact": artifact} + return contracts + + +def validate_route_migrations(cfg, previous_contracts): + artifacts = {artifact["name"]: artifact for artifact in cfg["artifacts"]} + for route in cfg["routes"]: + artifact = artifacts[route["artifact"]] + previous = previous_contracts.get(artifact["bucket"]) + if (previous and previous["access"] == "protected" and route["access"] == "public" + ): + 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): artifact_by_name = {artifact["name"]: artifact for artifact in cfg["artifacts"]} credential_env_names = { @@ -305,6 +371,11 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg): validate_publication_environment(cfg) for artifact in cfg["artifacts"]: validate_artifact_output(site_dir, artifact) + apps_dir = clone_apps(token) + app_dir = apps_dir / "sjc001" / "websites" / site_name + manifests_dir = app_dir / "manifests" + previous_contracts = previous_route_contracts(app_dir) + validate_route_migrations(cfg, previous_contracts) # Complete immutable work across the whole publication before any route's # mutable pointers can change. Partial immutable success is safe; mixing a # new route with an old route after a later immutable failure is not. @@ -313,14 +384,13 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg): artifact_by_name[route["artifact"]], route, site_dir, credential_env_names, ) for route in cfg["routes"]: - s3_sync(artifact_by_name[route["artifact"]], route, site_dir, credential_env_names) + s3_sync( + artifact_by_name[route["artifact"]], route, site_dir, credential_env_names, + (previous_contracts.get(artifact_by_name[route["artifact"]]["bucket"]) or {}).get("path"), + ) if cfg["compatibility"]: ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN")) - apps_dir = clone_apps(token) - app_dir = apps_dir / "sjc001" / "websites" / site_name - manifests_dir = app_dir / "manifests" - render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg) commit_and_push(apps_dir, f"Deploy {site_name}", token) diff --git a/scripts/utils.py b/scripts/utils.py index 8376cd5..b12a079 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -432,7 +432,15 @@ def validate_artifact_inputs(site_dir, cfg): root = Path(site_dir).resolve() sources = [] for artifact in cfg["artifacts"]: - source = (root / artifact["content_dir"]).resolve() + declared = root + for component in Path(artifact["content_dir"]).parts: + declared /= component + if declared.is_symlink(): + raise ConfigError( + f"artifact {artifact['name']} content_dir contains symlink component: " + f"{declared.relative_to(root)}" + ) + source = declared.resolve() if source != root and root not in source.parents: raise ConfigError( f"artifact {artifact['name']} content_dir resolves outside the repository" diff --git a/templates/ingress.yaml.j2 b/templates/ingress.yaml.j2 index 30ab0b9..de6d918 100644 --- a/templates/ingress.yaml.j2 +++ b/templates/ingress.yaml.j2 @@ -4,6 +4,12 @@ metadata: name: {{ route.resource_name }} namespace: {{ namespace }} annotations: +{%- if not compatibility %} + site-publish.fritzlab.net/artifact: {{ route.artifact | tojson }} + site-publish.fritzlab.net/access: {{ route.access | tojson }} + site-publish.fritzlab.net/bucket: {{ route.artifact_config.bucket | tojson }} + site-publish.fritzlab.net/route-path: {{ route.path | tojson }} +{%- 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 %} spec: ingressClassName: traefik diff --git a/tests/test_contract.py b/tests/test_contract.py index a8c239f..01050ec 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -230,6 +230,16 @@ class ConfigContractTests(unittest.TestCase): with self.assertRaisesRegex(ConfigError, "build input contains symlink"): validate_artifact_inputs(root, cfg) + def test_artifact_root_symlink_is_rejected_before_resolution(self): + cfg = normalize_site_config(self.raw, "baseline.fritzlab.net") + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "dist-real").mkdir() + (root / "dist").symlink_to(root / "dist-real") + (root / "portal" / "build").mkdir(parents=True) + with self.assertRaisesRegex(ConfigError, "content_dir contains symlink component"): + validate_artifact_inputs(root, cfg) + class GenerationTests(unittest.TestCase): def render(self, raw): @@ -263,6 +273,17 @@ class GenerationTests(unittest.TestCase): self.assertNotIn("passhostheader", files["manifests/ingress-portal.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): raw = fixture("split-site.yaml") first_tmp, _, first = self.render(raw) @@ -286,12 +307,14 @@ class GenerationTests(unittest.TestCase): self.assertFalse(stale.exists()) def test_legacy_names_and_garage_s3_target_are_preserved(self): - tmp, _, files = self.render(fixture("legacy-site.yaml")) + tmp, app_dir, files = self.render(fixture("legacy-site.yaml")) self.addCleanup(tmp.cleanup) self.assertIn("manifests/service.yaml", files) self.assertIn("manifests/ingress.yaml", files) self.assertIn("garage-s3.storage.svc.k8s.sjc001.fritzlab.net", files["manifests/service.yaml"]) self.assertNotIn("passhostheader", files["manifests/ingress.yaml"]) + self.assertNotIn("site-publish.fritzlab.net", files["manifests/ingress.yaml"]) + self.assertEqual({}, deploy.previous_route_contracts(app_dir)) class BuildTests(unittest.TestCase): @@ -429,12 +452,56 @@ class PublishingTests(unittest.TestCase): immutable_publish.call_args.args[2]["cache_control"], ) + def test_root_move_preserves_only_actual_retired_immutable_prefix(self): + cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") + artifact = next(item for item in cfg["artifacts"] if item["name"] == "distributions") + route = {**next(item for item in cfg["routes"] if item["artifact"] == "distributions"), + "path": "/"} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + html = root / artifact["build_dir"] + (html / "releases").mkdir(parents=True) + (html / "channels").mkdir() + (html / "docs" / "releases").mkdir(parents=True) + (html / "channels" / "stable.json").write_text("channel") + (html / "docs" / "releases" / "index.html").write_text("mutable") + commands = [] + with patch.dict(os.environ, { + "DIST_S3_ACCESS_KEY": "dist-key", "DIST_S3_SECRET_KEY": "dist-secret" + }, clear=False), patch.object( + deploy, "run", side_effect=lambda command, **_: commands.append(command) + ): + deploy.s3_sync(artifact, route, root, previous_path="/foo") + rendered = [" ".join(command) for command in commands] + self.assertTrue(all("foo/releases/*" in command for command in rendered[:2])) + 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 = { + "baseline-dist": { + "path": "/dist", "access": "protected", "artifact": "old-name", + } + } + with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"): + deploy.validate_route_migrations(cfg, previous) + renamed = copy.deepcopy(cfg) + 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) + 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: root = Path(tmp) with patch.object(deploy, "validate_publication_environment"), \ - patch.object(deploy, "validate_artifact_output"), patch.object( + patch.object(deploy, "validate_artifact_output"), \ + patch.object(deploy, "clone_apps", return_value=root / "apps"), patch.object( deploy, "publish_route_immutables", side_effect=[None, RuntimeError("immutable failed")], ) as immutable_publish, patch.object(deploy, "s3_sync") as mutable_sync, \