From 02c1dadf67192141477e4ac8d89d0d8cd4395dd6 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 20 Oct 2026 01:46:18 +0800 Subject: [PATCH] perf(delivery): reduce verified installation and recovery I/O --- backend/tests/test_delivery.py | 4 +- backend/tests/test_delivery_io.py | 202 ++++++++++++++++++++++++++ scripts/release/README.md | 10 ++ scripts/release/dependency_install.py | 11 +- scripts/release/file_copy.py | 81 +++++++++++ scripts/release/install.py | 23 ++- scripts/release/journal.py | 12 +- scripts/release/manifest.py | 131 +++++++++++++++++ scripts/release/restore.py | 29 ++-- scripts/release/service_install.py | 4 +- 10 files changed, 479 insertions(+), 28 deletions(-) create mode 100644 backend/tests/test_delivery_io.py create mode 100644 scripts/release/file_copy.py diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 1cf6141..b47b279 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -285,11 +285,11 @@ def test_frozen_copy_rejects_changed_source_against_trusted_records(tmp_path, mo (source / "backend").chmod(0o555) records = manifest.inventory(source) payload.chmod(0o600) - payload.write_bytes(b"changed bytes") + payload.write_bytes(b"changed data") payload.chmod(0o444) with pytest.raises(policy.DeliveryError, match="installed image readback differs"): installer.frozen_copy(source, target, verified_records=records) - assert (target / "backend/ordinary.txt").read_bytes() == b"changed bytes" + assert (target / "backend/ordinary.txt").read_bytes() == b"changed data" assert stat.S_IMODE(target.stat().st_mode) == 0o555 with pytest.raises(policy.DeliveryError, match="existing protected image differs"): installer.frozen_copy(source, target, verified_records=records) diff --git a/backend/tests/test_delivery_io.py b/backend/tests/test_delivery_io.py new file mode 100644 index 0000000..34f7796 --- /dev/null +++ b/backend/tests/test_delivery_io.py @@ -0,0 +1,202 @@ +"""Ordinary trusted byte fixtures/models; no VM, privilege or execution acceptance.""" + +import errno +import hashlib +import importlib +import json +import os +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +manifest = importlib.import_module("scripts.release.manifest") +policy = importlib.import_module("scripts.release.policy") +file_copy = importlib.import_module("scripts.release.file_copy") + + +@pytest.fixture +def ordinary_bundle(tmp_path): + """Only manifest-level data, deliberately never used as a runtime image.""" + bundle = tmp_path / "bundle" + payload = bundle / "payload" + payload.mkdir(parents=True) + required = { + "bin/kf-sandbox", + "bin/kf-worker", + "bin/kf-coordinator", + "python/bin/python3.13", + "backend/alembic.ini", + "frontend/index.html", + "frontend/admin/index.html", + } + for name in required: + path = payload / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"ordinary fixture data, never executed\n") + path.chmod(0o444) + contract = payload / "deploy/release/contract.v1.json" + contract.parent.mkdir(parents=True) + contract.write_bytes((ROOT / "deploy/release/contract.v1.json").read_bytes()) + contract.chmod(0o444) + images = {} + for name, toolchain, kind, version in ( + ("gnu", "gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1", "16.2.1+r23+gd564253eb6c8"), + ("python313", "cpython313-v1", "linux_cpython313_x86_64_v1", "3.13.16"), + ("java21", "openjdk21-v1", "linux_openjdk21_x86_64_v1", "21.0.12.1+1-LTS"), + ): + path = payload / "images" / name / "manifest.json" + path.parent.mkdir(parents=True) + path.write_bytes(json.dumps({"ordinary_inventory_probe": name}).encode()) + path.chmod(0o444) + images[name] = { + "toolchain_id": toolchain, + "image_kind": kind, + "image_revision": 1, + "runtime_version": version, + "image_digest": policy.digest(path), + } + for path in sorted(payload.rglob("*"), key=lambda p: len(p.parts), reverse=True): + if path.is_dir(): + path.chmod(0o555) + payload.chmod(0o555) + locks = { + name: hashlib.sha256(name.encode()).hexdigest() + for name in ( + "backend/uv.lock", + "judge/Cargo.lock", + "frontend/pnpm-lock.yaml", + "deploy/release/contract.v1.json", + ) + } + locks["deploy/release/contract.v1.json"] = policy.digest(contract) + manifest.write(bundle, "123456789abc" + "d" * 28, locks, images) + return bundle, policy.digest(bundle / "manifest.json") + + +def observed_digests(monkeypatch): + reads = [] + original = manifest.digest + + def observed(path): + reads.append(path) + return original(path) + + monkeypatch.setattr(manifest, "digest", observed) + return reads + + +def test_same_operation_reuses_bytes_but_not_mutable_metadata(ordinary_bundle, monkeypatch): + bundle, expected = ordinary_bundle + reads = observed_digests(monkeypatch) + with manifest.verification_scope(): + first = manifest.verify(bundle, expected) + first_reads = list(reads) + assert first_reads + original_files = list(first["files"]) + first["files"].clear() + with manifest.verification_scope(): + second = manifest.verify(bundle, expected) + assert second["files"] == original_files and reads == first_reads + assert manifest._READ_SCOPE.get() is None + manifest.verify(bundle, expected) + assert reads == first_reads * 2 + + +def test_failed_operation_cannot_keep_a_byte_proof(ordinary_bundle, monkeypatch): + bundle, expected = ordinary_bundle + reads = observed_digests(monkeypatch) + with pytest.raises(OSError, match="ordinary control-flow probe"): + with manifest.verification_scope(): + manifest.verify(bundle, expected) + raise OSError("ordinary control-flow probe") + first_reads = list(reads) + assert manifest._READ_SCOPE.get() is None + with manifest.verification_scope(): + manifest.verify(bundle, expected) + assert reads == first_reads * 2 + + +@pytest.mark.parametrize("index", range(9)) +@pytest.mark.parametrize("phase", ["reuse", "return"]) +def test_bundle_identity_model_is_required_through_operation_return( + ordinary_bundle, monkeypatch, index, phase +): + # Metadata models only. Actual filesystem mutation and process boundaries + # are exercised separately in the independently bounded offline VM. + bundle, expected = ordinary_bundle + original = manifest._bundle_signature + calls = 0 + + def changed(path): + nonlocal calls + calls += 1 + result = original(path) + if calls >= 3: + key = "payload/bin/kf-worker" + stamp = list(result[key]) + stamp[index] += 1 + result[key] = tuple(stamp) + return result + + monkeypatch.setattr(manifest, "_bundle_signature", changed) + with pytest.raises(policy.DeliveryError, match="verified bundle changed"): + with manifest.verification_scope(): + manifest.verify(bundle, expected) + if phase == "reuse": + manifest.verify(bundle, expected) + assert manifest._READ_SCOPE.get() is None + + +def test_inherited_scope_owner_model_requires_fresh_bytes(ordinary_bundle, monkeypatch): + bundle, expected = ordinary_bundle + reads = observed_digests(monkeypatch) + with manifest.verification_scope(): + manifest.verify(bundle, expected) + first_reads = list(reads) + manifest._READ_SCOPE.get().pid = os.getpid() - 1 + manifest.verify(bundle, expected) + assert reads == first_reads * 2 + + +@pytest.mark.parametrize( + "limit", ["_MAX_SCOPED_BUNDLES", "_MAX_SCOPED_NODES", "_MAX_SCOPED_METADATA"] +) +def test_scope_capacity_model_falls_back_to_full_reads(ordinary_bundle, monkeypatch, limit): + bundle, expected = ordinary_bundle + reads = observed_digests(monkeypatch) + monkeypatch.setattr(manifest, limit, 0) + with manifest.verification_scope(): + manifest.verify(bundle, expected) + first_reads = list(reads) + manifest.verify(bundle, expected) + assert reads == first_reads * 2 + + +@pytest.mark.parametrize("length", [0, 17, 3 * 1024**2 + 17]) +@pytest.mark.parametrize("mode", ["actual", "unsupported", "copied_prefix_then_unsupported"]) +def test_fixed_regular_transfer_preserves_exact_bytes_and_offsets( + tmp_path, monkeypatch, length, mode +): + source, target = tmp_path / "source.txt", tmp_path / "target.txt" + content = (b"ordinary trusted byte transfer\n" * ((length // 30) + 2))[:length] + source.write_bytes(content) + original = os.sendfile + calls = 0 + + def transfer(out_fd, in_fd, offset, maximum): + nonlocal calls + calls += 1 + if mode == "unsupported" or (mode == "copied_prefix_then_unsupported" and calls > 1): + # Control-flow model, not evidence of a real kernel failure. + raise OSError(errno.EINVAL, "ordinary unsupported-path model") + return original(out_fd, in_fd, offset, maximum) + + monkeypatch.setattr(file_copy.os, "sendfile", transfer) + with source.open("rb") as incoming, target.open("wb") as outgoing: + file_copy.transfer(incoming, outgoing, length, deadline=time.monotonic() + 30) + assert target.read_bytes() == content + assert policy.digest(target) == policy.digest(source) diff --git a/scripts/release/README.md b/scripts/release/README.md index 7f53d37..8b78840 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -398,6 +398,16 @@ digest. Its inode, ownership, mode, link count, size and nanosecond timestamps must remain unchanged until the final receipt. These observations are local to one stopped-node recovery and are never reused after an interrupted attempt. Directory entries are flushed after their children before completion is recorded. +The cold operator, activation and immutable service/dependency/journal profile +installation can reuse a public bundle's complete byte verification within one +synchronous operation. Every file and directory identity, owner, mode, link count, +size and nanosecond mtime/ctime is checked before reuse and before success returns. +Changes are refused. These memory-only proofs are bound to the process, capped at +four bundles, 100006 entries and 16 MiB of metadata, and discarded on return or +failure. Further bundles receive full verification; separate commands start fresh. +Fixed-size regular-file copies can use Linux kernel transfer with bounded chunks. +Unsupported transfer falls back at the same offsets. Source size/EOF, file fsync +and independent destination byte verification remain required. Old configuration and maintenance receipts go into inactive `recovery/ID/history/`. The new boot's Sandbox preparation owns cgroup recreation. Unknown/nonempty targets and differing existing files are refused; partial restores diff --git a/scripts/release/dependency_install.py b/scripts/release/dependency_install.py index e7a403a..3e33fb2 100644 --- a/scripts/release/dependency_install.py +++ b/scripts/release/dependency_install.py @@ -15,7 +15,7 @@ import subprocess from pathlib import Path from urllib.parse import urlsplit -from . import configuration, dependencies, install, preflight, service_install +from . import configuration, dependencies, install, manifest, preflight, service_install from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 ROOT = dependencies.CONFIGURATION_ROOT @@ -387,6 +387,7 @@ def install_cookie(source: Path, expected: str) -> None: install.sync_directory(path.parent) +@manifest.verified_operation def install_snapshot(source: Path, expected: str, cookie: Path, cookie_sha: str) -> dict: if os.getuid() != 0 or os.geteuid() != 0: raise DeliveryError("dependency installation requires root operator") @@ -405,7 +406,13 @@ def install_snapshot(source: Path, expected: str, cookie: Path, cookie_sha: str) ): raise DeliveryError("dependency snapshot is not the selected binding") payload = install.RELEASES / selected["release_id"] / "payload" - for name in ("dependencies.py", "dependency_policy.py", "dependency_install.py", "layout.py"): + for name in ( + "dependencies.py", + "dependency_policy.py", + "dependency_install.py", + "layout.py", + "manifest.py", + ): shipped = payload / "delivery/scripts/release" / name preflight.protected(shipped) if digest(shipped) != digest(Path(__file__).parent / name): diff --git a/scripts/release/file_copy.py b/scripts/release/file_copy.py new file mode 100644 index 0000000..9d3dbf6 --- /dev/null +++ b/scripts/release/file_copy.py @@ -0,0 +1,81 @@ +"""Fixed-size regular-file transfer; callers retain durability and byte readback.""" + +import errno +import os +import stat +import time +from typing import BinaryIO + +from .policy import MAX_FILE_BYTES, DeliveryError + +CHUNK = 1024 * 1024 +UNSUPPORTED = {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP, errno.EXDEV} + + +def _identity(info: os.stat_result) -> tuple[int, ...]: + return ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_nlink, + info.st_uid, + info.st_gid, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + + +def transfer( + incoming: BinaryIO, outgoing: BinaryIO, expected_bytes: int, *, deadline: float | None = None +) -> None: + """Use fresh streams; unsupported kernel transfer resumes at the same offsets.""" + source, target = incoming.fileno(), outgoing.fileno() + before = os.fstat(source) + destination = os.fstat(target) + if ( + type(expected_bytes) is not int + or not 0 <= expected_bytes <= MAX_FILE_BYTES + or not stat.S_ISREG(before.st_mode) + or before.st_nlink != 1 + or before.st_size != expected_bytes + or not stat.S_ISREG(destination.st_mode) + or destination.st_nlink != 1 + or destination.st_size != 0 + or incoming.tell() != 0 + or outgoing.tell() != 0 + or os.lseek(source, 0, os.SEEK_CUR) != 0 + or os.lseek(target, 0, os.SEEK_CUR) != 0 + ): + raise DeliveryError("fixed-size regular copy input or fresh destination differs") + + def remaining() -> None: + if deadline is not None and time.monotonic() >= deadline: + raise DeliveryError("copy deadline exceeded; private partial target retained") + + total = 0 + kernel = hasattr(os, "sendfile") + while total < expected_bytes: + remaining() + maximum = min(CHUNK, expected_bytes - total) + if kernel: + try: + # Linux None advances the source offset, including a copied prefix + # before falling back. Each call stays within the verified size. + count = os.sendfile(target, source, None, maximum) + except OSError as error: + if error.errno not in UNSUPPORTED: + raise + kernel = False + continue + else: + data = incoming.read(maximum) + count = len(data) + if count and outgoing.write(data) != count: + raise DeliveryError("short copy write; private partial target retained") + if not 0 < count <= maximum: + raise DeliveryError("copy input bytes differ; private partial target retained") + total += count + remaining() + if incoming.read(1) or _identity(os.fstat(source)) != _identity(before): + raise DeliveryError("copy input changed; private partial target retained") diff --git a/scripts/release/install.py b/scripts/release/install.py index 1eb5323..b73398c 100644 --- a/scripts/release/install.py +++ b/scripts/release/install.py @@ -8,7 +8,7 @@ import stat import subprocess from pathlib import Path -from . import manifest, preflight +from . import file_copy, manifest, preflight from .layout import RELEASES as RELEASES from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 @@ -93,7 +93,15 @@ def copy_verified(source: Path, target: Path, expected: str, mode: int) -> None: _copy_verified(source, target, expected, mode, readback=True) -def _copy_verified(source: Path, target: Path, expected: str, mode: int, *, readback: bool) -> None: +def _copy_verified( + source: Path, + target: Path, + expected: str, + mode: int, + *, + readback: bool, + expected_bytes: int | None = None, +) -> None: preflight.protected(source) sha256(expected) if target.exists() or target.is_symlink(): @@ -116,10 +124,13 @@ def _copy_verified(source: Path, target: Path, expected: str, mode: int, *, read # below verifies every copied byte against the trusted records; # hashing the same stream as well adds no independent evidence. copied = hashlib.sha256() if readback else None - while chunk := src.read(1024 * 1024): - if copied is not None: + if copied is None: + size = os.fstat(incoming).st_size if expected_bytes is None else expected_bytes + file_copy.transfer(src, dst, size) + else: + while chunk := src.read(1024 * 1024): copied.update(chunk) - dst.write(chunk) + dst.write(chunk) dst.flush() if copied is not None and copied.hexdigest() != expected: raise DeliveryError( @@ -233,6 +244,7 @@ def frozen_copy( record["sha256"], int(record["mode"], 8), readback=False, + expected_bytes=record["bytes"], ) for name in sorted(directories, key=lambda name: name.count("/"), reverse=True): (target / name).chmod(0o555) @@ -454,6 +466,7 @@ def require_stopped() -> None: raise DeliveryError("delegated task groups remain; cleanup proof required") +@manifest.verified_operation def activate(release_id: str, expected: str, batch: str) -> dict: """Only files + separately installed exact configuration may be selected. diff --git a/scripts/release/journal.py b/scripts/release/journal.py index 99f41c2..6971d08 100644 --- a/scripts/release/journal.py +++ b/scripts/release/journal.py @@ -9,7 +9,7 @@ import stat import subprocess from pathlib import Path -from . import configuration, install, preflight +from . import configuration, install, manifest, preflight from .policy import DeliveryError, canonical, digest, fields, read_json NAMESPACE = "kf-onlinejudge" @@ -286,15 +286,17 @@ def check_ready() -> dict: return value +@manifest.verified_operation def install_profile() -> dict: if os.getuid() != 0 or os.geteuid() != 0 or os.getgid() != 0: raise DeliveryError("root journal operator required") selected = install.selection() payload = install.RELEASES / selected["release_id"] / "payload" - shipped = payload / "delivery/scripts/release/journal.py" - preflight.protected(shipped) - if digest(shipped) != digest(Path(__file__)): - raise DeliveryError("journal operator differs from selected delivery") + for name in ("journal.py", "manifest.py"): + shipped = payload / "delivery/scripts/release" / name + preflight.protected(shipped) + if digest(shipped) != digest(Path(__file__).parent / name): + raise DeliveryError("journal operator differs from selected delivery") install.require_stopped() observed = storage() if RECEIPT.exists() or RECEIPT.is_symlink(): diff --git a/scripts/release/manifest.py b/scripts/release/manifest.py index 436bf41..7705fc6 100644 --- a/scripts/release/manifest.py +++ b/scripts/release/manifest.py @@ -1,8 +1,13 @@ """Explicit directory bundle inventories, verified before installation.""" +import json import os import re import stat +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from functools import wraps from pathlib import Path from .policy import ( @@ -24,6 +29,104 @@ from .policy import ( ) +@dataclass(slots=True) +class _ReadScope: + pid: int + entries: dict = field(default_factory=dict) + nodes: int = 0 + metadata_bytes: int = 0 + + +_READ_SCOPE: ContextVar[_ReadScope | None] = ContextVar("delivery_bundle_reads", default=None) +_MAX_SCOPED_BUNDLES = 4 +_MAX_SCOPED_NODES = 2 * (MAX_FILES + 3) +_MAX_SCOPED_METADATA = 2 * MAX_METADATA_BYTES + + +def _bundle_signature(bundle: Path) -> dict[str, tuple[int, ...]]: + """Observe topology and all metadata that can invalidate a prior byte read.""" + if not bundle.is_dir() or bundle.is_symlink(): + raise DeliveryError("bundle directory required") + members = set() + for path in bundle.iterdir(): + members.add(path.name) + if len(members) > 2: + raise DeliveryError("unexpected bundle member") + if members != {"manifest.json", "payload"}: + raise DeliveryError("unexpected bundle member") + result = {} + + def add(path: Path, *, directory: bool) -> None: + info = path.lstat() + if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)): + raise DeliveryError("bundle proof requires ordinary directories and files") + if not directory and info.st_nlink != 1: + raise DeliveryError("bundle proof requires single-link files") + result[path.relative_to(bundle).as_posix()] = ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_nlink, + info.st_uid, + info.st_gid, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + if len(result) > MAX_FILES + 3: + raise DeliveryError("bundle proof entry count limit") + + def failed(error: OSError) -> None: + raise error + + add(bundle, directory=True) + add(bundle / "manifest.json", directory=False) + payload = bundle / "payload" + add(payload, directory=True) + for directory, names, files in os.walk(payload, followlinks=False, onerror=failed): + for name in names: + path = Path(directory) / name + relative_name(path.relative_to(payload).as_posix() + "/.__directory__") + add(path, directory=True) + for name in files: + path = Path(directory) / name + relative_name(path.relative_to(payload).as_posix()) + add(path, directory=False) + return result + + +def _unchanged_bundle(bundle: Path, signature: dict[str, tuple[int, ...]]) -> None: + if _bundle_signature(bundle) != signature: + raise DeliveryError("verified bundle changed during the operator operation") + + +@contextmanager +def verification_scope(): + """Reuse actual byte reads only inside one synchronous same-PID operation.""" + previous = _READ_SCOPE.get() + if previous is not None and previous.pid == os.getpid(): + yield + return + current = _ReadScope(os.getpid()) + token = _READ_SCOPE.set(current) + try: + yield + # This happens before the decorated operation returns its success value. + for (bundle, _), (_, signature) in current.entries.items(): + _unchanged_bundle(bundle, signature) + finally: + _READ_SCOPE.reset(token) + + +def verified_operation(function): + @wraps(function) + def wrapped(*args, **kwargs): + with verification_scope(): + return function(*args, **kwargs) + + return wrapped + + def inventory(root: Path, *, path_prefix: str = "") -> list[dict]: """No links/devices or inferred inclusion; bounds apply before hashing.""" if not root.is_dir() or root.is_symlink(): @@ -203,6 +306,34 @@ def validate(data: dict) -> dict: def verify(bundle: Path, expected_sha256: str) -> dict: + """A bounded operation may reuse a complete byte proof only while unchanged.""" + expected_sha256 = sha256(expected_sha256) + scope = _READ_SCOPE.get() + if scope is None or scope.pid != os.getpid(): + return _verify_contents(bundle, expected_sha256) + key = (bundle, expected_sha256) + if key in scope.entries: + data, signature = scope.entries[key] + _unchanged_bundle(bundle, signature) + # Never expose the remembered metadata object to a mutating caller. + return json.loads(data) + if len(scope.entries) >= _MAX_SCOPED_BUNDLES: + return _verify_contents(bundle, expected_sha256) + before = _bundle_signature(bundle) + data = _verify_contents(bundle, expected_sha256) + _unchanged_bundle(bundle, before) + encoded = canonical(data) + if ( + scope.nodes + len(before) <= _MAX_SCOPED_NODES + and scope.metadata_bytes + len(encoded) <= _MAX_SCOPED_METADATA + ): + scope.entries[key] = encoded, before + scope.nodes += len(before) + scope.metadata_bytes += len(encoded) + return data + + +def _verify_contents(bundle: Path, expected_sha256: str) -> dict: """The manifest digest comes from a separate trusted operator receipt.""" expected_sha256 = sha256(expected_sha256) if not bundle.is_dir() or bundle.is_symlink(): diff --git a/scripts/release/restore.py b/scripts/release/restore.py index 8d652c8..0fea5d7 100644 --- a/scripts/release/restore.py +++ b/scripts/release/restore.py @@ -17,6 +17,7 @@ from . import ( backup_manifest, dependencies, dependency_install, + file_copy, install, lifecycle, manifest, @@ -186,16 +187,7 @@ def write_file( ), "wb", ) as outgoing: - total = 0 - while block := incoming.read(1024 * 1024): - total += len(block) - if total > item["bytes"] or time.monotonic() >= deadline: - raise DeliveryError( - "recovery input/deadline changed; private partial target retained" - ) - outgoing.write(block) - if total != item["bytes"]: - raise DeliveryError("recovery input bytes differ; private partial target retained") + file_copy.transfer(incoming, outgoing, item["bytes"], deadline=deadline) outgoing.flush() os.fchown(outgoing.fileno(), uid, gid) os.fchmod(outgoing.fileno(), mode) @@ -206,12 +198,20 @@ def write_file( return readback(path, item, uid, gid, mode, deadline) +@manifest.verified_operation def restore(recovery_id: str, backup_id: str, expected: str) -> dict: guarded() stopped() selected = install.selection() payload = install.RELEASES / selected["release_id"] / "payload" - for name in ("restore.py", "restore_policy.py", "backup.py", "backup_manifest.py"): + for name in ( + "restore.py", + "restore_policy.py", + "backup.py", + "backup_manifest.py", + "manifest.py", + "file_copy.py", + ): if digest(payload / "delivery/scripts/release" / name) != digest( Path(__file__).parent / name ): @@ -300,8 +300,11 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict: value["directories"], key=lambda row: row["path"].count("/"), reverse=True ): path, uid, gid, mode = target(item, recovery) - os.chown(path, uid, gid, follow_symlinks=False) - path.chmod(mode) + current = path.lstat() + if (current.st_uid, current.st_gid) != (uid, gid): + os.chown(path, uid, gid, follow_symlinks=False) + if stat.S_IMODE(current.st_mode) != mode: + path.chmod(mode) install.sync_directory(path) member(path, uid, gid, mode, directory=True) # Flush directory entries once after their children, including the two diff --git a/scripts/release/service_install.py b/scripts/release/service_install.py index d1199f9..1dfcc64 100644 --- a/scripts/release/service_install.py +++ b/scripts/release/service_install.py @@ -10,7 +10,7 @@ import stat import subprocess from pathlib import Path -from . import configuration, install, preflight, services +from . import configuration, install, manifest, preflight, services from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 UNITS = Path("/etc/kf-onlinejudge/units") @@ -155,6 +155,7 @@ def systemctl(*arguments: str) -> str: return result.stdout +@manifest.verified_operation def install_units(source: Path, expected: str) -> dict: if os.getuid() != 0 or os.geteuid() != 0: raise DeliveryError("ordinary unit installation requires root operator") @@ -184,6 +185,7 @@ def install_units(source: Path, expected: str) -> dict: "service_bootstrap.py", "service_install.py", "layout.py", + "manifest.py", ): shipped = payload / "delivery/scripts/release" / name preflight.protected(shipped)