fix(delivery): reject incompatible release selection

This commit is contained in:
2026-10-21 11:50:40 +08:00
parent cf43ae6c75
commit d66d42d021
3 changed files with 182 additions and 1 deletions
+145
View File
@@ -62,6 +62,150 @@ def test_activation_refuses_checked_binding_that_differs_from_explicit_request(m
installer.activate(release, expected, batch)
@pytest.fixture
def selection_model(tmp_path, monkeypatch):
# Owned ordinary fixture files only. Root, configuration proofs, stopped
# services and ports are modeled; actual switching still requires the VM.
import socket
release, batch, expected = "sd-123456789abc", "cfg-" + "1" * 24, "1" * 64
prior = {
"release_id": "sd-abcdefabcdef",
"configuration_id": "cfg-" + "2" * 24,
"manifest_sha256": "2" * 64,
"receipt_sha256": "3" * 64,
}
current = {
"platform": policy.PLATFORM,
"toolchains": dict(policy.TOOLCHAINS),
"images": {"gnu": {"image_digest": "4" * 64}},
"locks": {"backend/uv.lock": "5" * 64},
"files": [
{
"path": "backend/migrations/versions/001.py",
"bytes": 12,
"sha256": "6" * 64,
"mode": "0444",
}
],
}
target = json.loads(json.dumps(current))
active, pointer = tmp_path / "active.json", tmp_path / "current"
active.write_bytes(b"ordinary prior selection model\n")
receipt = tmp_path / "receipt.json"
receipt.write_bytes(b"ordinary target receipt model\n")
reads, stopped, ports = [], [], []
def verify(root, fingerprint):
reads.append((root, fingerprint))
return target if root.name == release else current
class Socket:
def __enter__(self):
return self
def __exit__(self, *args):
pass
def bind(self, address):
ports.append(address)
original_path = Path
def fixture_path(value):
if str(value).startswith("/var/lib/kf-judge/"):
return tmp_path / "unused" / original_path(value).name
return original_path(value)
monkeypatch.setattr(installer.os, "geteuid", lambda: 0)
monkeypatch.setattr(installer.preflight, "protected", lambda *a, **k: None)
monkeypatch.setattr(installer, "RELEASES", tmp_path / "releases")
monkeypatch.setattr(installer, "ACTIVE", active)
monkeypatch.setattr(installer, "CURRENT", pointer)
monkeypatch.setattr(installer, "Path", fixture_path)
monkeypatch.setattr(installer, "require_stopped", lambda: stopped.append(True))
monkeypatch.setattr(installer, "selection", lambda **k: dict(prior))
monkeypatch.setattr(configuration, "installed_receipt_path", lambda *a: receipt)
monkeypatch.setattr(
configuration,
"check_installed",
lambda p: {
"release_id": release,
"manifest_sha256": expected,
"configuration_id": batch,
"source_sha": "123456789abc" + "d" * 28,
},
)
monkeypatch.setattr(manifest, "verify", verify)
monkeypatch.setattr(socket, "socket", lambda *a, **k: Socket())
return SimpleNamespace(
current=current,
target=target,
active=active,
pointer=pointer,
arguments=(release, expected, batch),
reads=reads,
stopped=stopped,
ports=ports,
prior=prior,
)
@pytest.mark.parametrize("changed", ["migration", "empty_migrations", "images", "locks"])
def test_activation_requires_compatible_schema_and_runtime_before_pointer_writes(
selection_model, changed
):
model = selection_model
if changed == "migration":
model.target["files"][0]["sha256"] = "7" * 64
elif changed == "empty_migrations":
model.target["files"] = []
else:
model.target[changed] = {}
before = model.active.read_bytes()
with pytest.raises(policy.DeliveryError, match="selection requires identical"):
installer.activate(*model.arguments)
assert model.active.read_bytes() == before and not model.pointer.is_symlink()
assert model.stopped and len(model.reads) == 2
def test_compatible_selection_keeps_explicit_batch_and_does_not_start_services(selection_model):
model = selection_model
result = installer.activate(*model.arguments)
selected = json.loads(model.active.read_bytes())
assert selected["release_id"] == model.arguments[0]
assert selected["configuration_id"] == model.arguments[2]
assert result["activated"] and not result["services_started"]
assert not result["execution_verified"]
assert model.stopped and model.ports == [("127.0.0.1", 8000), ("127.0.0.1", 8100)]
assert len(model.reads) == 2
def test_first_selection_does_not_require_a_previous_release(selection_model):
model = selection_model
model.active.unlink()
result = installer.activate(*model.arguments)
assert result["activated"] and not result["services_started"]
assert model.reads == []
def test_selection_drift_refuses_before_pointer_writes(selection_model, monkeypatch):
model = selection_model
calls = 0
def selection(**options):
nonlocal calls
calls += 1
assert options["check_configuration"] is False
return {**model.prior, "receipt_sha256": str(min(calls, 9)) * 64}
monkeypatch.setattr(installer, "selection", selection)
before = model.active.read_bytes()
with pytest.raises(policy.DeliveryError, match="binding changed before activation"):
installer.activate(*model.arguments)
assert model.active.read_bytes() == before and not model.pointer.is_symlink()
@pytest.mark.parametrize("mode,owner", [(stat.S_IFREG, 0), (stat.S_IFSOCK, 1000)])
def test_system_bus_preflight_refuses_unprotected_endpoint(monkeypatch, mode, owner):
monkeypatch.setattr(installer.preflight, "protected", lambda *args, **kwargs: None)
@@ -1085,6 +1229,7 @@ def test_proxy_temp_directory_conflicts_are_not_overwritten(monkeypatch, tmp_pat
path = tmp_path / "body"
if bad == "wrong-mode":
path.mkdir(mode=0o755)
path.chmod(0o755)
elif bad == "link":
path.symlink_to(tmp_path, target_is_directory=True)
else:
+10
View File
@@ -104,6 +104,16 @@ factory socket, and empty task/identity/capture resources. Root journals and
batches are retained. Interruption between pointer writes requires inspection.
`check-active` checks code/configuration selection and the measurement snapshot.
When replacing an existing selection, `activate` fully verifies both releases
and requires identical nonempty Backend migration inventories, runtime images,
platform, toolchains and dependency/contract lock identities. A compatible
software rollback uses the same explicit command with its previously installed
release and configuration batch. An incompatible selection is refused before
either pointer is written. First installation needs no prior selection.
Migration-changing selection is unavailable through this command; it never
downgrades a database or runs Alembic. Normal startup and full readiness remain
separate checks, and activation success does not establish recovery success.
## PostgreSQL and RabbitMQ
### Prepare dependencies
+27 -1
View File
@@ -469,6 +469,25 @@ def require_stopped() -> None:
raise DeliveryError("delegated task groups remain; cleanup proof required")
def _compatible_selection(current: dict, target: dict) -> None:
"""Select software only across the same declared schema and runtime ABI."""
if any(current[key] != target[key] for key in ("platform", "toolchains", "images", "locks")):
raise DeliveryError(
"release selection requires identical runtime/toolchain/lock identities"
)
def migrations(value: dict) -> dict:
return {
item["path"]: item
for item in value["files"]
if item["path"].startswith("backend/migrations/")
}
previous = migrations(current)
if not previous or previous != migrations(target):
raise DeliveryError("release selection requires identical nonempty Backend migrations")
@manifest.verified_operation
def activate(release_id: str, expected: str, batch: str) -> dict:
"""Only files + separately installed exact configuration may be selected.
@@ -517,7 +536,14 @@ def activate(release_id: str, expected: str, batch: str) -> dict:
if ACTIVE.exists() or ACTIVE.is_symlink() or CURRENT.exists() or CURRENT.is_symlink():
# Retiring a byte-bound old batch is allowed after a kernel change.
# It is never accepted for execution under that changed environment.
selection(check_configuration=False)
previous = selection(check_configuration=False)
current_data = manifest.verify(
RELEASES / previous["release_id"], previous["manifest_sha256"]
)
target_data = manifest.verify(release, expected)
_compatible_selection(current_data, target_data)
if selection(check_configuration=False) != previous:
raise DeliveryError("selected binding changed before activation")
active = {
"schema_version": 1,
"release_id": release_id,