diff --git a/README.md b/README.md index b4fe520..fe0a54f 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,24 @@ Selection scopes the build and the S3 publication only. Ingresses, Services, Certificates and the immutable-path history are always rendered from the whole `site.yaml`, so a scoped run cannot retire another artifact's route or delete its bucket contents. Credentials are resolved for the selected artifacts alone, -so a workflow need not carry secrets for artifacts it does not publish. An -undeclared name fails before the first bucket is touched, and `enabled: false` -refuses a selection because decommissioning is whole-site. +so a workflow need not carry secrets for artifacts it does not publish. + +What a scoped run may **not** do is move an artifact it is not publishing. The +generated route contract in `site-publish-history.yaml` is written from the +whole `site.yaml`, and `access` there is a replacement rather than a union — so +recording a change nothing published would turn intent into a fact that +`validate_route_migrations` then refuses to undo. A run whose `site.yaml` +changes an unselected artifact's route path, access or artifact name is refused +before the first bucket is touched, naming both contracts: publish that artifact +in the same run. For the same reason a scoped run cannot introduce an artifact +that has no published history yet. + +Two more refusals, both before any bucket changes: an undeclared name, and +`enabled: false` with a selection, because decommissioning is whole-site. + +One consequence to know: CORS reconciliation is scoped too, since a scoped run +holds no credential for the other bucket. A `cors_origins:` change lands with +that artifact's next publish, not on the merge that edits `site.yaml`. ## Tools diff --git a/scripts/deploy.py b/scripts/deploy.py index c43b0c7..e3a0eb5 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -535,6 +535,43 @@ def validate_route_migrations(cfg, previous_contracts): ) +def validate_scoped_history(cfg, previous_contracts): + """A scoped run may not record a route contract it did not publish. + + render_site_manifests advances the stored contract for every route in + site.yaml, and `access` is overwritten rather than unioned the way + immutable_paths is. Without this, a catalogue-only publish could write a + protected access for the distributions bucket that nothing published, and + validate_route_migrations would then refuse to put that bucket back — + unpublished intent turned into an irreversible fact. + """ + chosen = set(cfg["selected"]) + if len(chosen) == len(cfg["artifacts"]): + return + artifact_by_name = {artifact["name"]: artifact for artifact in cfg["artifacts"]} + for route in cfg["routes"]: + if route["artifact"] in chosen: + continue + artifact = artifact_by_name[route["artifact"]] + previous = previous_contracts.get(artifact["bucket"]) + if previous is None: + raise RuntimeError( + f"a scoped run cannot introduce artifact {route['artifact']}; " + f"publish it in the same run" + ) + current = { + "path": route["path"], + "access": "public" if route["access"] == "legacy" else route["access"], + "artifact": route["artifact"], + } + recorded = {key: previous[key] for key in current} + if recorded != current: + raise RuntimeError( + f"a scoped run may not change unselected artifact {route['artifact']}'s route " + f"contract ({recorded} -> {current}); publish it in the same run" + ) + + 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 = { @@ -552,6 +589,7 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg): manifests_dir = app_dir / "manifests" previous_contracts = previous_route_contracts(app_dir) validate_route_migrations(cfg, previous_contracts) + validate_scoped_history(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. diff --git a/tests/test_contract.py b/tests/test_contract.py index 648a0a5..2a07b30 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -475,6 +475,22 @@ class ArtifactSelectionTests(unittest.TestCase): apps_dir = root / "apps" app_dir = apps_dir / "sjc001" / "websites" / "baseline.fritzlab.net" app_dir.mkdir(parents=True) + # A scoped run refuses an artifact it has no published history for, so + # the fixture starts from the whole publication this one narrows. + (app_dir / "site-publish-history.yaml").write_text(yaml.safe_dump({ + "version": 1, + "buckets": { + artifact["bucket"]: { + "path": route["path"], + "access": "public" if route["access"] == "legacy" else route["access"], + "artifact": route["artifact"], + "immutable_paths": deploy.immutable_key_prefixes(artifact, route), + } + for route in cfg["routes"] + for artifact in [next(item for item in cfg["artifacts"] + if item["name"] == route["artifact"])] + }, + }, sort_keys=True)) for artifact in cfg["artifacts"]: if artifact["name"] not in set(selection): continue @@ -520,6 +536,72 @@ class ArtifactSelectionTests(unittest.TestCase): ["dist/releases"], history["buckets"]["baseline-dist"]["immutable_paths"] ) + def test_scoped_run_refuses_to_record_a_contract_it_did_not_publish(self): + """The reviewer's scenario: distributions goes protected in site.yaml while + a catalogue-only run publishes. Recording that access would make the + bucket unreturnable through validate_route_migrations.""" + cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") + published = { + "baseline-portal": { + "path": "/", "access": "protected", "artifact": "portal", "immutable_paths": [], + }, + "baseline-dist": { + "path": "/dist", "access": "public", "artifact": "distributions", + "immutable_paths": ["dist/releases"], + }, + } + # A whole publish agrees with what is recorded, scoped or not. + deploy.validate_scoped_history(cfg, published) + cfg["selected"] = ["portal"] + deploy.validate_scoped_history(cfg, published) + + moved = copy.deepcopy(cfg) + route = next(item for item in moved["routes"] if item["artifact"] == "distributions") + route["access"] = "protected" + route["access_middleware"] = "authentik-forwardauth" + with self.assertRaises(RuntimeError) as refused: + deploy.validate_scoped_history(moved, published) + self.assertIn("may not change unselected artifact distributions", str(refused.exception)) + # The same change published in the same run is allowed to proceed. + moved["selected"] = ["distributions", "portal"] + deploy.validate_scoped_history(moved, published) + + def test_scoped_run_refuses_an_artifact_with_no_published_history(self): + cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") + cfg["selected"] = ["portal"] + with self.assertRaises(RuntimeError) as refused: + deploy.validate_scoped_history(cfg, {}) + self.assertIn("cannot introduce artifact distributions", str(refused.exception)) + + def test_scoped_deploy_runs_the_history_guard_before_touching_a_bucket(self): + cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") + cfg["selected"] = ["portal"] + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + apps_dir = root / "apps" + app_dir = apps_dir / "sjc001" / "websites" / "baseline.fritzlab.net" + app_dir.mkdir(parents=True) + html = root / next( + item for item in cfg["artifacts"] if item["name"] == "portal" + )["build_dir"] + html.mkdir(parents=True) + (html / "index.html").write_text("portal") + with patch.dict(os.environ, { + "PORTAL_S3_ACCESS_KEY": "portal-key", "PORTAL_S3_SECRET_KEY": "portal-secret", + }, clear=False), \ + patch.object(deploy, "clone_apps", return_value=apps_dir), \ + patch.object(deploy, "commit_and_push"), \ + patch.object(deploy, "publish_route_immutables") as immutables, \ + patch.object(deploy, "reconcile_artifact_cors") as cors, \ + patch.object(deploy, "s3_sync") as sync, \ + redirect_stdout(io.StringIO()), \ + self.assertRaises(RuntimeError): + deploy.deploy_static("baseline.fritzlab.net", root, ROOT, "token", cfg) + immutables.assert_not_called() + cors.assert_not_called() + sync.assert_not_called() + def test_disabled_site_refuses_a_partial_selection(self): cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net") cfg["enabled"] = False