From 1fe8958ba70d662f9b102b112aa58bbf7c4dde42 Mon Sep 17 00:00:00 2001 From: pcvantol Date: Fri, 18 Sep 2026 09:27:39 +0200 Subject: [PATCH] Make installed Forge updates repeatable --- .../repeat-update-fence-2.7.24.json | 16 ++++++ .../FORGE_INSTALLED_UPDATE_RUNBOOK.md | 20 ++++--- product-version.json | 2 +- scripts/update_installed_forge.py | 57 +++++++++++++++---- tests/test_installed_forge_update.py | 41 ++++++++++++- 5 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 .github/product-version-operations/repeat-update-fence-2.7.24.json diff --git a/.github/product-version-operations/repeat-update-fence-2.7.24.json b/.github/product-version-operations/repeat-update-fence-2.7.24.json new file mode 100644 index 0000000..aef030d --- /dev/null +++ b/.github/product-version-operations/repeat-update-fence-2.7.24.json @@ -0,0 +1,16 @@ +{ + "baseline_version": "2.7.23", + "classification_rationale": "installed-updater-repeatability-remediation", + "component": "product", + "event_lineage": "installed-updater-repeatability-remediation", + "expected_head": "6cd60f983d0cb8a9d68ad377d34fbe4c7a8a7a44", + "operation_id": "repeat-update-fence-2.7.24", + "policy_revision": "forge-bootstrap-release-cadence-v2", + "product": "forge", + "projection_paths": "product-version.json", + "release_class": "PATCH", + "requested_bump": "patch", + "requested_version": null, + "schema_version": "1", + "target_version": "2.7.24" +} diff --git a/docs/operations/FORGE_INSTALLED_UPDATE_RUNBOOK.md b/docs/operations/FORGE_INSTALLED_UPDATE_RUNBOOK.md index 31a41ed..73c583b 100644 --- a/docs/operations/FORGE_INSTALLED_UPDATE_RUNBOOK.md +++ b/docs/operations/FORGE_INSTALLED_UPDATE_RUNBOOK.md @@ -1,8 +1,8 @@ # Forge installed update controller Status: bounded product-owned maintenance provisioner for the selected Forge -2.7.21 to 2.7.22 schema-37-to-38 transition and the selected 2.7.22 to 2.7.23 -same-schema corrective transition. +2.7.21 to 2.7.22 schema-37-to-38 transition and the selected 2.7.22/2.7.23 to +2.7.23/2.7.24 same-schema corrective transitions. This controller closes one concrete product provisioning gap. It is not the universal Forge Platform installer, an installer UI, a new service supervisor, @@ -19,7 +19,7 @@ cannot be called by the installed runtime as a second self-installer. ## Supported operation The controller accepts only one explicitly bound existing installation and an -exact `forge-autonomy` wheel for one of those two transitions. Every invocation +exact `forge-autonomy` wheel for one of those bounded transitions. Every invocation binds: - operation, runtime, installation, and peer-configuration identities; @@ -40,9 +40,10 @@ interpreter, version, and bytes. 1. Read the wheel once through a no-follow descriptor; validate its digest, canonical RECORD, purelib tag, package metadata, member allowlist, and the exact terminal release receipt without importing it. The historical - 2.7.22 reconciliation receipt retains its dedicated validation; 2.7.23 must - have the normal protected release-complete publication/readback/cleanup - shape and cannot be presented to the older transition. + 2.7.22 reconciliation receipt retains its dedicated validation; 2.7.23 and + 2.7.24 must have the normal protected release-complete + publication/readback/cleanup shape and cannot be presented to an + unsupported transition. 2. Create an isolated versioned runtime slot outside the source checkout with the explicit Python interpreter. Extract only the already validated bytes into a pip-free virtual environment, then verify every installed file and @@ -66,7 +67,9 @@ interpreter, version, and bytes. reset-table additions and a fresh idle control row. The 38-to-38 route permits no table additions and requires the complete reset state and every domain/security/configuration row to remain byte-logically unchanged. -8. Point the stable resolver at a maintenance fence. Take an exclusive SQLite +8. Normalize the product-owned maintenance launcher to operation-independent + canonical bytes, accepting only the exact older operation-labelled shape, + then point the stable resolver at that fence. Take an exclusive SQLite writer boundary, prove the live database is still byte-logically identical to the backed-up snapshot, and atomically install the already Forge-migrated database copy. The replacement remains read-only until activation and final @@ -112,7 +115,8 @@ separate authority and compatibility proof. writer rejection, both bounded schema transitions, preservation of Missions, allocations, reviews, execution receipts, governance grants, configuration and identity, concurrent-operation exclusion, resolver adoption, -canonical receipt shape, path safety, exact slot contents, exclusive atomic +prior-operation fence normalization and tamper rejection, canonical receipt +shape, path safety, exact slot contents, exclusive atomic database replacement, late-writer rejection, and interruption before migration, after migration, and during activation. An opt-in test runs the entire route and replay against the exact published wheel and terminal release diff --git a/product-version.json b/product-version.json index 0efeb93..9aaf032 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "2.7.23" + "version": "2.7.24" } diff --git a/scripts/update_installed_forge.py b/scripts/update_installed_forge.py index 7aabb95..1133886 100644 --- a/scripts/update_installed_forge.py +++ b/scripts/update_installed_forge.py @@ -48,7 +48,14 @@ SUPPORTED_TRANSITIONS = { ("2.7.21", "2.7.22"): (37, 38), ("2.7.22", "2.7.23"): (38, 38), + ("2.7.22", "2.7.24"): (38, 38), + ("2.7.23", "2.7.24"): (38, 38), } +NORMAL_RELEASE_TRANSITIONS = frozenset({ + ("2.7.22", "2.7.23"), + ("2.7.22", "2.7.24"), + ("2.7.23", "2.7.24"), +}) PHASE_ORDER = { phase: index for index, phase in enumerate(( "PREPARED", "STAGED", "ADOPTED", "BACKED_UP", "MIGRATION_QUALIFIED", @@ -183,6 +190,24 @@ def _atomic_json(path: Path, value: object) -> None: temporary.unlink() +def _atomic_regular_file(path: Path, payload: bytes, *, mode: int) -> None: + """Replace one managed regular file without exposing partial bytes.""" + _safe_directory(path.parent, create=True) + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.tmp-", dir=path.parent) + temporary = Path(name) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + os.chmod(path, mode) + finally: + if temporary.exists(): + temporary.unlink() + + def _read_json(path: Path) -> dict[str, Any]: try: value = json.loads(_read_regular_bytes(path)) @@ -380,7 +405,7 @@ def _normal_release_evidence( request: UpdateRequest, receipt: Mapping[str, Any], manifest: Mapping[str, str], receipt_path: Path, ) -> dict[str, Any]: - """Validate the normal 2.7.23 release route without weakening 2.7.22 recovery.""" + """Validate current normal-release transitions without weakening 2.7.22 recovery.""" expected_name = f"forge_autonomy-{request.version}-py3-none-any.whl" sdist_name = f"forge_autonomy-{request.version}.tar.gz" qualification = receipt.get("qualification") @@ -399,7 +424,7 @@ def _normal_release_evidence( } exact_observed = {expected_name: request.wheel_sha256, sdist_name: sdist_digest} if ( - (request.existing_version, request.version) != ("2.7.22", "2.7.23") + (request.existing_version, request.version) not in NORMAL_RELEASE_TRANSITIONS or set(receipt) != expected_top or receipt.get("state") != "RELEASE_COMPLETE" or receipt.get("product") != "forge" @@ -459,7 +484,7 @@ def _qualified_artifact(request: UpdateRequest) -> tuple[dict[str, Any], bytes, raise InstalledForgeUpdateError("qualification receipt is malformed") from error if not isinstance(receipt, dict): raise InstalledForgeUpdateError("qualification receipt is malformed") - if (request.existing_version, request.version) == ("2.7.22", "2.7.23"): + if (request.existing_version, request.version) in NORMAL_RELEASE_TRANSITIONS: return ( _normal_release_evidence(request, receipt, manifest, receipt_path), wheel_bytes, @@ -1113,17 +1138,25 @@ def _adopt_resolver(self, state: dict[str, Any]) -> dict[str, Any]: raise InstalledForgeUpdateError("retained legacy entrypoint does not match the pinned resolver bytes") elif file_digest(self.legacy_entrypoint) != self.request.resolver_sha256: raise InstalledForgeUpdateError("retained legacy entrypoint changed") - fence = ( - "#!/bin/sh\n" - f"echo 'Forge installation maintenance is active: {self.request.operation_id}' >&2\n" - "exit 75\n" + # The fence is shared by every update operation. Its bytes therefore + # must be operation-independent. Releases before 2.7.24 embedded the + # first operation id and made every later update fail closed while + # trying to reuse the same managed launcher. Accept only that exact + # legacy shape and atomically normalize it; arbitrary launcher changes + # remain a hard failure. + fence = b"#!/bin/sh\necho 'Forge installation maintenance is active' >&2\nexit 75\n" + legacy_fence = re.compile( + rb"\A#!/bin/sh\necho 'Forge installation maintenance is active: " + rb"[A-Za-z0-9][A-Za-z0-9._-]{0,127}' >&2\nexit 75\n\Z" ) if not self.fenced_resolver.exists(): - descriptor = os.open(self.fenced_resolver, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(fence) - elif self.fenced_resolver.read_text(encoding="utf-8") != fence: - raise InstalledForgeUpdateError("maintenance fence launcher changed") + _atomic_regular_file(self.fenced_resolver, fence, mode=0o755) + else: + observed_fence = _read_regular_bytes(self.fenced_resolver) + if observed_fence != fence: + if legacy_fence.fullmatch(observed_fence) is None: + raise InstalledForgeUpdateError("maintenance fence launcher changed") + _atomic_regular_file(self.fenced_resolver, fence, mode=0o755) if not self.current.exists() and not self.current.is_symlink(): _replace_symlink(self.current, os.path.relpath(self.legacy_entrypoint, self.runtime_root)) if not self.stable_resolver.exists() and not self.stable_resolver.is_symlink(): diff --git a/tests/test_installed_forge_update.py b/tests/test_installed_forge_update.py index 5cc3216..2c8a071 100644 --- a/tests/test_installed_forge_update.py +++ b/tests/test_installed_forge_update.py @@ -230,8 +230,8 @@ def test_exact_release_complete_artifact_is_accepted_and_mismatch_rejected(self) with self.assertRaisesRegex(update.InstalledForgeUpdateError, "wheel digest"): update.validate_qualified_artifact(self.request) - def test_normal_2723_release_receipt_is_accepted_only_for_the_new_transition(self) -> None: - version = "2.7.23" + def test_normal_release_receipt_is_accepted_only_for_supported_normal_transitions(self) -> None: + version = "2.7.24" wheel = self.root / f"forge_autonomy-{version}-py3-none-any.whl" dist_info = f"forge_autonomy-{version}.dist-info" members = { @@ -290,7 +290,7 @@ def test_normal_2723_release_receipt_is_accepted_only_for_the_new_transition(sel }, sort_keys=True), encoding="utf-8") request = update.UpdateRequest(**{ **self.request.__dict__, - "operation_id": "forge-update-2723-test-002", + "operation_id": "forge-update-2724-test-002", "version": version, "product_source": source, "wheel": str(wheel), @@ -303,6 +303,10 @@ def test_normal_2723_release_receipt_is_accepted_only_for_the_new_transition(sel evidence = update.validate_qualified_artifact(request) self.assertEqual(evidence["release_route"], "NORMAL") + from_2723 = update.UpdateRequest(**{ + **request.__dict__, "existing_version": "2.7.23", + }) + self.assertEqual(update.validate_qualified_artifact(from_2723)["release_route"], "NORMAL") wrong_transition = update.UpdateRequest(**{ **request.__dict__, "existing_version": "2.7.21", }) @@ -446,6 +450,37 @@ def test_resolver_adoption_fences_without_editing_the_legacy_environment(self) - self.assertEqual(self.resolver.resolve(), controller.fenced_resolver.resolve()) self.assertEqual(controller.legacy_entrypoint.read_bytes(), legacy_bytes) + def test_resolver_adoption_normalizes_a_prior_operation_fence(self) -> None: + controller = self._controller() + controller.fenced_resolver.parent.mkdir(parents=True) + controller.fenced_resolver.write_text( + "#!/bin/sh\n" + "echo 'Forge installation maintenance is active: forge-update-older-001' >&2\n" + "exit 75\n", + encoding="utf-8", + ) + controller.fenced_resolver.chmod(0o755) + + with patch.object(update, "installed_identity", return_value={"version": "2.7.21"}): + controller._adopt_resolver(controller._state()) + + self.assertEqual( + controller.fenced_resolver.read_bytes(), + b"#!/bin/sh\necho 'Forge installation maintenance is active' >&2\nexit 75\n", + ) + self.assertEqual(controller.fenced_resolver.stat().st_mode & 0o777, 0o755) + + def test_resolver_adoption_rejects_an_unrecognized_existing_fence(self) -> None: + controller = self._controller() + controller.fenced_resolver.parent.mkdir(parents=True) + controller.fenced_resolver.write_text("#!/bin/sh\necho compromised\n", encoding="utf-8") + + with ( + patch.object(update, "installed_identity", return_value={"version": "2.7.21"}), + self.assertRaisesRegex(update.InstalledForgeUpdateError, "fence launcher changed"), + ): + controller._adopt_resolver(controller._state()) + def test_crash_before_migration_restores_the_legacy_route(self) -> None: controller = self._controller() state = controller._state()