Unify pinned tool installation and cross-target staging
Use one acquisition, assembly, verification, replacement, rollback and cleanup path. Keep host facts and cross-target markers as concrete recording differences, including interrupted-entry recovery and verified Python copies. Exercise both routes with real archive servers, pause/resume, multi-archive progress, post-publication failure and killed publishers. Keep native Windows directory-hold coverage gated to its host.
This commit is contained in:
165
pm/ensure.py
165
pm/ensure.py
@@ -20,17 +20,8 @@ from pm.store import Store, current_target, merge_tree, tree_digest
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# ``progress(stage, done, total, label)`` — stage is "download" | "unpack",
|
||||
# label is the archive counter ("1/2") when a package has several. Slow
|
||||
# lines sit in one stage for minutes, so the byte counters are what prove
|
||||
# liveness to a UI.
|
||||
|
||||
|
||||
def _artifact_progress(progress, index: int, count: int):
|
||||
if progress is None:
|
||||
return None
|
||||
label = f"{index + 1}/{count}" if count > 1 else ""
|
||||
return lambda done, total: progress("download", done, total, label)
|
||||
# ``progress(stage, done, total, label)`` reports download/unpack/verify;
|
||||
# multi-archive labels follow lockfile order.
|
||||
|
||||
|
||||
def _lockfile() -> Lockfile:
|
||||
@@ -224,7 +215,7 @@ def _restore_previous_entry(store: Store, entry, previous) -> None:
|
||||
def _install(
|
||||
package: Package,
|
||||
lockfile: Lockfile,
|
||||
facts: Facts,
|
||||
facts: Facts | None,
|
||||
store: Store,
|
||||
target: str,
|
||||
progress=None,
|
||||
@@ -232,7 +223,8 @@ def _install(
|
||||
download_progress: ProgressFn | None = None,
|
||||
*,
|
||||
copy_from: tuple[Facts, Store] | None = None,
|
||||
) -> None:
|
||||
) -> Path:
|
||||
"""Realize one pin. Host installs commit facts; cross-target stages carry a marker."""
|
||||
version = lockfile.version(package.name)
|
||||
if version is None:
|
||||
raise InstallError(
|
||||
@@ -243,28 +235,41 @@ def _install(
|
||||
if reason is not None:
|
||||
raise InstallError(package.name, f"unavailable on {target}: {reason}", "none")
|
||||
|
||||
artifacts = lockfile.artifacts(package.name, target)
|
||||
entry_name = package.store_entry(version, target)
|
||||
entry = store.entry(entry_name)
|
||||
if getattr(package, "pin_only", False):
|
||||
return entry
|
||||
artifacts = lockfile.artifacts(package.name, target)
|
||||
pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]})
|
||||
|
||||
with store.install_lock():
|
||||
if pause_event is not None and pause_event.is_set():
|
||||
raise DownloadPaused("install paused")
|
||||
facts.reload()
|
||||
entry = store.entry(entry_name)
|
||||
previous_entry = store.entry(f".previous-{entry_name}")
|
||||
if facts is not None:
|
||||
facts.reload()
|
||||
previous = facts.get(package.name) if facts is not None else None
|
||||
previous_entry = store.entry(f".previous-{'stage-' if facts is None else ''}{entry_name}")
|
||||
if previous_entry.exists():
|
||||
# An interrupted replacement keeps its old bytes outside scratch.
|
||||
# Facts commit last; only a verified committed replacement wins.
|
||||
fact = facts.get(package.name)
|
||||
if fact and fact.get("entry") == entry_name and _entry_verified(package, fact, store, target):
|
||||
# Facts commit last. Stages have no host-side commit record, so
|
||||
# an interrupted stage always restores its prior usable bytes.
|
||||
if (previous and previous.get("entry") == entry_name
|
||||
and _entry_verified(package, previous, store, target)):
|
||||
_remove_entry(store, previous_entry.name)
|
||||
else:
|
||||
_restore_previous_entry(store, entry, previous_entry)
|
||||
if facts.installed(
|
||||
package.name, version, store.root, _identity(lockfile, package.name, target)
|
||||
) and _entry_verified(package, facts.get(package.name), store, target):
|
||||
if facts is not None:
|
||||
current = previous is not None and facts.installed(
|
||||
package.name, version, store.root, _identity(lockfile, package.name, target)
|
||||
) and _entry_verified(package, previous, store, target)
|
||||
else:
|
||||
try:
|
||||
recorded = (entry / ".pm-stage-pin.json").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
recorded = None
|
||||
current = recorded == pin and not package.verify(entry, target)
|
||||
if current:
|
||||
_remove_downloads(store, artifacts)
|
||||
return
|
||||
return entry
|
||||
if not artifacts:
|
||||
raise InstallError(
|
||||
package.name,
|
||||
@@ -273,7 +278,6 @@ def _install(
|
||||
)
|
||||
with store.scratch() as scratch:
|
||||
staged = scratch / "tree"
|
||||
previous = facts.get(package.name)
|
||||
try:
|
||||
def tick(done, total, ranges):
|
||||
if progress is not None:
|
||||
@@ -318,18 +322,21 @@ def _install(
|
||||
reason = package.verify(staged, target)
|
||||
if reason:
|
||||
raise InstallError(package.name, f"staged entry failed verification: {reason}")
|
||||
if entry.exists():
|
||||
if facts is None:
|
||||
(staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8")
|
||||
if entry.exists() or entry.is_symlink():
|
||||
entry.rename(previous_entry)
|
||||
try:
|
||||
store.publish(staged, entry_name)
|
||||
reason = package.verify(entry, target)
|
||||
if reason:
|
||||
raise InstallError(package.name, f"published entry failed verification: {reason}")
|
||||
facts.record(
|
||||
package.name, version, entry_name, package.env(entry, target), store.root,
|
||||
target=target, artifacts=[a["sha256"] for a in artifacts],
|
||||
digest=tree_digest(entry),
|
||||
)
|
||||
if facts is not None:
|
||||
facts.record(
|
||||
package.name, version, entry_name, package.env(entry, target), store.root,
|
||||
target=target, artifacts=[a["sha256"] for a in artifacts],
|
||||
digest=tree_digest(entry),
|
||||
)
|
||||
except BaseException:
|
||||
if previous_entry.exists():
|
||||
_restore_previous_entry(store, entry, previous_entry)
|
||||
@@ -353,91 +360,17 @@ def _install(
|
||||
else version
|
||||
)
|
||||
LOG.info("repair: %s re-realized %s -> %s", package.name, old, new)
|
||||
return entry
|
||||
|
||||
|
||||
def stage_only(name: str, target: str, progress=None) -> "Path":
|
||||
"""Cross-target staging: publish the pinned (package, version, target)
|
||||
entry into the store and return its path. No facts are written and no
|
||||
Runner is composed -- the staged binaries belong to ANOTHER machine
|
||||
(e.g. linux-arm64-bionic .debs staged on a glibc CI host); this host's
|
||||
installed-state must not learn about them. Idempotent: an already
|
||||
published + verifying entry is returned as-is.
|
||||
"""
|
||||
lockfile = _lockfile()
|
||||
store = _store()
|
||||
package = get_package(name)
|
||||
version = lockfile.version(package.name)
|
||||
if version is None:
|
||||
raise InstallError(package.name, "not in the lockfile")
|
||||
reason = package.missing_reason(target)
|
||||
if reason is not None:
|
||||
raise InstallError(package.name, f"unavailable on {target}: {reason}")
|
||||
if getattr(package, "pin_only", False):
|
||||
# A pure pin (e.g. the termux-docker digest): no bytes, no store
|
||||
# entry, nothing to verify locally -- the pin IS the artifact.
|
||||
return store.root / package.store_entry(version, target)
|
||||
artifacts = lockfile.artifacts(package.name, target)
|
||||
entry_name = package.store_entry(version, target)
|
||||
# The stage pin marker (same identity shape as a fact's recorded
|
||||
# artifacts: target + artifact digests) lets stage_only honor a
|
||||
# same-version hash repin without any host-side facts: the entry
|
||||
# belongs to ANOTHER machine, so the marker travels inside the entry.
|
||||
pin = json.dumps({"target": target, "sha256": [a["sha256"] for a in artifacts]})
|
||||
with store.install_lock():
|
||||
entry = store.entry(entry_name)
|
||||
previous_entry = store.entry(f".previous-stage-{entry_name}")
|
||||
if previous_entry.exists():
|
||||
# A killed publisher may have installed only part of the new tree.
|
||||
_restore_previous_entry(store, entry, previous_entry)
|
||||
if store.published(entry_name):
|
||||
marker = entry / ".pm-stage-pin.json"
|
||||
try:
|
||||
recorded = marker.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
recorded = None
|
||||
if not package.verify(entry, target) and recorded == pin:
|
||||
_remove_downloads(store, artifacts)
|
||||
return entry
|
||||
if not artifacts:
|
||||
raise InstallError(
|
||||
package.name,
|
||||
f"no artifact for {target} in the lockfile",
|
||||
"run `hermes pm lock --bump` for this package",
|
||||
)
|
||||
with store.scratch() as scratch:
|
||||
staged = scratch / "tree"
|
||||
for index, artifact in enumerate(artifacts):
|
||||
archive = store.fetch(
|
||||
artifact["url"], artifact["sha256"], scratch,
|
||||
progress=_artifact_progress(progress, index, len(artifacts)),
|
||||
)
|
||||
if index == 0:
|
||||
package.unpack(archive, staged, target)
|
||||
else:
|
||||
extra = scratch / f"extra-{index}"
|
||||
package.unpack(archive, extra, target)
|
||||
merge_tree(extra, staged)
|
||||
package.stage(store, staged, version, target)
|
||||
reason = package.verify(staged, target)
|
||||
if reason:
|
||||
raise InstallError(package.name, f"staged entry failed verification: {reason}")
|
||||
(staged / ".pm-stage-pin.json").write_text(pin, encoding="utf-8")
|
||||
# Keep the old pin usable until the replacement has been verified.
|
||||
if entry.exists() or entry.is_symlink():
|
||||
entry.rename(previous_entry)
|
||||
try:
|
||||
store.publish(staged, entry_name)
|
||||
reason = package.verify(entry, target)
|
||||
if reason:
|
||||
raise InstallError(package.name, f"published entry failed verification: {reason}")
|
||||
except BaseException:
|
||||
if previous_entry.exists():
|
||||
_restore_previous_entry(store, entry, previous_entry)
|
||||
raise
|
||||
if previous_entry.exists():
|
||||
_remove_entry(store, previous_entry.name)
|
||||
_remove_downloads(store, artifacts)
|
||||
return store.entry(entry_name)
|
||||
def stage_only(
|
||||
name: str, target: str, progress=None, *,
|
||||
pause_event: threading.Event | None = None,
|
||||
download_progress: ProgressFn | None = None,
|
||||
) -> Path:
|
||||
"""Realize a cross-target pin without host facts or an executable Runner."""
|
||||
return _install(get_package(name), _lockfile(), None, _store(), target,
|
||||
progress=progress, pause_event=pause_event, download_progress=download_progress)
|
||||
|
||||
|
||||
def ensure(
|
||||
@@ -454,7 +387,7 @@ def ensure(
|
||||
policy names, so the policy does not apply to them.
|
||||
|
||||
``progress(stage, done, total, label)`` reports the slow parts of an
|
||||
install to a UI; see _artifact_progress.
|
||||
install to a UI, including ordered multi-archive labels.
|
||||
"""
|
||||
if isinstance(get_package(name), StatePackage):
|
||||
sync_venv(explicit=explicit)
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from functools import partial
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -13,7 +15,7 @@ import pytest
|
||||
import pm
|
||||
from pm import paths, registry
|
||||
from pm.downloader import DownloadPaused
|
||||
from pm.ensure import ensure
|
||||
from pm.ensure import ensure, stage_only
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.package import Package
|
||||
from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
@@ -37,7 +39,14 @@ def isolate_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
|
||||
|
||||
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server):
|
||||
@pytest.fixture(params=["install", "stage"])
|
||||
def realize(request):
|
||||
if request.param == "install":
|
||||
return partial(ensure, explicit=True)
|
||||
return partial(stage_only, target="linux-arm64-bionic")
|
||||
|
||||
|
||||
def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, monkeypatch, dl_server, realize):
|
||||
root = tmp_path / "store"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
@@ -52,7 +61,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
|
||||
RangeHandler.payloads[path] = payload
|
||||
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
|
||||
lock.set_pin(ComponentPackage.name, "1", {"any": pins})
|
||||
lock.save()
|
||||
pause = threading.Event()
|
||||
|
||||
@@ -61,7 +70,7 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
|
||||
pause.set()
|
||||
|
||||
with pytest.raises(DownloadPaused):
|
||||
ensure(ComponentPackage.name, explicit=True, progress=progress, pause_event=pause)
|
||||
realize(ComponentPackage.name, progress=progress, pause_event=pause)
|
||||
assert Facts(paths.facts_path()).get(ComponentPackage.name) is None
|
||||
assert list(paths.partials_root().glob("*.ranges"))
|
||||
first_requests = [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"]
|
||||
@@ -69,17 +78,22 @@ def test_install_pause_preserves_archives_and_resumes_the_same_pin(tmp_path, mon
|
||||
assert (root / f"fetch-{pins[0]['sha256']}").is_dir()
|
||||
|
||||
pause.clear()
|
||||
ensure(ComponentPackage.name, explicit=True, pause_event=pause)
|
||||
result = realize(ComponentPackage.name, pause_event=pause)
|
||||
fact = Facts(paths.facts_path()).get(ComponentPackage.name)
|
||||
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
|
||||
if fact is None:
|
||||
entry = result
|
||||
assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [pin["sha256"] for pin in pins]
|
||||
else:
|
||||
entry = root / fact["entry"]
|
||||
assert fact["artifacts"] == [pin["sha256"] for pin in pins]
|
||||
for name, body in contents.items():
|
||||
assert (root / fact["entry"] / name).read_bytes() == body
|
||||
assert (entry / name).read_bytes() == body
|
||||
assert [request for request in RangeHandler.ranges_seen if request[0] == "/component-0.zip"] == first_requests
|
||||
assert not list(paths.partials_root().glob("*.part"))
|
||||
assert not list(root.glob("fetch-*"))
|
||||
|
||||
|
||||
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server):
|
||||
def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypatch, dl_server, realize):
|
||||
root = tmp_path / "store"
|
||||
lock_path = tmp_path / "lock.json"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
@@ -93,14 +107,14 @@ def test_install_progress_covers_all_archives_including_cache(tmp_path, monkeypa
|
||||
RangeHandler.payloads[path] = payload
|
||||
pins.append({"url": url(dl_server, path), "sha256": hashlib.sha256(payload).hexdigest()})
|
||||
lock = Lockfile(lock_path)
|
||||
lock.set_pin(ComponentPackage.name, "1", {pm.current_target(): pins})
|
||||
lock.set_pin(ComponentPackage.name, "1", {"any": pins})
|
||||
lock.save()
|
||||
store = pm.Store(root)
|
||||
with store.scratch() as scratch:
|
||||
store.fetch(pins[0]["url"], pins[0]["sha256"], scratch)
|
||||
ticks = []
|
||||
stages = []
|
||||
ensure(ComponentPackage.name, explicit=True,
|
||||
realize(ComponentPackage.name,
|
||||
progress=lambda *args: stages.append(args),
|
||||
download_progress=lambda done, total, ranges: ticks.append((done, total, ranges)))
|
||||
expected = sum(map(len, payloads))
|
||||
|
||||
@@ -116,6 +116,42 @@ def _pin(env, name: str, version: str, digest: str) -> None:
|
||||
# ── item 1: digest-bound facts ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["install", "stage"])
|
||||
def test_realization_reports_ordered_multi_archive_progress(pm_env, monkeypatch, route):
|
||||
from pm.ensure import ensure, stage_only
|
||||
|
||||
env = pm_env
|
||||
monkeypatch.setattr(registry._packages["faketool"], "flatten", False)
|
||||
artifacts = []
|
||||
for name, files in [("tool.tar.gz", {"bin/faketool": "#!tool"}),
|
||||
("data.tar.gz", {"share/data": "auxiliary"})]:
|
||||
_, digest = make_tar(env["docroot"], name, files)
|
||||
artifacts.append({"url": f"{env['base_url']}/{name}", "sha256": digest})
|
||||
lock = Lockfile(env["lockfile_path"])
|
||||
lock.set_pin("faketool", "1.0", {"any": artifacts})
|
||||
lock.save()
|
||||
events = []
|
||||
progress = lambda stage, done, total, label: events.append((stage, done, total, label))
|
||||
if route == "install":
|
||||
ensure("faketool", explicit=True, base_env={}, progress=progress)
|
||||
fact = Facts(paths.facts_path()).get("faketool")
|
||||
entry = paths.store_root() / fact["entry"]
|
||||
assert fact["artifacts"] == [artifact["sha256"] for artifact in artifacts]
|
||||
assert fact["digest"] == tree_digest(entry)
|
||||
assert not (entry / ".pm-stage-pin.json").exists()
|
||||
else:
|
||||
entry = stage_only("faketool", "linux-arm64-bionic", progress=progress)
|
||||
assert json.loads((entry / ".pm-stage-pin.json").read_text()) == {
|
||||
"target": "linux-arm64-bionic", "sha256": [artifact["sha256"] for artifact in artifacts],
|
||||
}
|
||||
assert not paths.facts_path().exists()
|
||||
assert (entry / "bin/faketool").read_text() == "#!tool"
|
||||
assert (entry / "share/data").read_text() == "auxiliary"
|
||||
assert [label for stage, _, _, label in events if stage == "unpack"] == ["1/2", "2/2"]
|
||||
assert any(stage == "verify" for stage, _, _, _ in events)
|
||||
assert not list(paths.store_root().glob("fetch-*"))
|
||||
|
||||
|
||||
def test_same_version_different_sha_is_not_installed_and_repaired(pm_env):
|
||||
"""The witness: same version, different artifact sha. Version/path
|
||||
matching cannot see this; identity matching must — check() reports
|
||||
@@ -181,17 +217,27 @@ def test_install_repairs_corrupt_entry_from_verified_archive(pm_env, matching_fa
|
||||
assert Facts(facts_path).get("faketool")["digest"] == tree_digest(binary.parent.parent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replacement", ["invalid", "publish-failure", "interrupted", "facts-failure"])
|
||||
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, replacement):
|
||||
from pm.ensure import ensure
|
||||
@pytest.mark.parametrize(("route", "replacement"), [
|
||||
(route, failure) for route in ("install", "stage")
|
||||
for failure in ("invalid", "publish-failure", "post-publish-invalid", "interrupted", "facts-failure")
|
||||
if route == "install" or failure != "facts-failure"
|
||||
])
|
||||
def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, route, replacement):
|
||||
from functools import partial
|
||||
from pm.ensure import ensure, stage_only
|
||||
from pm.package import InstallError
|
||||
|
||||
env = pm_env
|
||||
ensure("faketool", base_env={})
|
||||
target = current_target() if route == "install" else "linux-arm64-bionic"
|
||||
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
|
||||
stage_only, "faketool", target)
|
||||
realize()
|
||||
facts_path = paths.facts_path()
|
||||
old_facts = facts_path.read_bytes()
|
||||
old = Facts(facts_path).get("faketool")
|
||||
binary = paths.store_root() / old["entry"] / "bin" / "faketool"
|
||||
old_facts = facts_path.read_bytes() if facts_path.exists() else None
|
||||
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
|
||||
marker = entry / ".pm-stage-pin.json"
|
||||
old_marker = marker.read_bytes() if marker.exists() else None
|
||||
binary = entry / "bin/faketool"
|
||||
files = {"bin/unrelated": "bad layout"} if replacement == "invalid" else {"bin/faketool": "#!new"}
|
||||
_, digest = make_tar(env["docroot"], "replacement.tar.gz", files)
|
||||
lockfile = Lockfile(env["lockfile_path"])
|
||||
@@ -199,15 +245,19 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla
|
||||
"url": f"{env['base_url']}/replacement.tar.gz", "sha256": digest,
|
||||
}})
|
||||
lockfile.save()
|
||||
if replacement in ("publish-failure", "interrupted"):
|
||||
if replacement in ("publish-failure", "post-publish-invalid", "interrupted"):
|
||||
original = Store.publish
|
||||
|
||||
def fail_package_publish(self, staged, name):
|
||||
if name == old["entry"]:
|
||||
if replacement == "interrupted":
|
||||
raise KeyboardInterrupt()
|
||||
raise OSError("replacement publication failed")
|
||||
return original(self, staged, name)
|
||||
if name != entry.name:
|
||||
return original(self, staged, name)
|
||||
if replacement == "interrupted":
|
||||
raise KeyboardInterrupt()
|
||||
if replacement == "post-publish-invalid":
|
||||
published = original(self, staged, name)
|
||||
(published / "bin/faketool").unlink()
|
||||
return published
|
||||
raise OSError("replacement publication failed")
|
||||
|
||||
monkeypatch.setattr(Store, "publish", fail_package_publish)
|
||||
elif replacement == "facts-failure":
|
||||
@@ -217,24 +267,33 @@ def test_failed_replacement_preserves_entry_and_facts(pm_env, monkeypatch, repla
|
||||
|
||||
expected_error = KeyboardInterrupt if replacement == "interrupted" else InstallError
|
||||
with pytest.raises(expected_error):
|
||||
ensure("faketool", explicit=True, base_env={})
|
||||
realize()
|
||||
|
||||
assert binary.read_bytes() == b"#!x"
|
||||
assert facts_path.read_bytes() == old_facts
|
||||
assert (facts_path.read_bytes() if facts_path.exists() else None) == old_facts
|
||||
assert (marker.read_bytes() if marker.exists() else None) == old_marker
|
||||
|
||||
|
||||
def test_killed_replacement_recovers_on_next_install(pm_env):
|
||||
"""A process exit between moving old bytes and publishing new bytes is recoverable."""
|
||||
@pytest.mark.parametrize("route", ["install", "stage"])
|
||||
@pytest.mark.parametrize("interruption", ["before-publish", "after-publish"])
|
||||
def test_killed_replacement_recovers_on_next_install(pm_env, route, interruption):
|
||||
"""A killed publisher retains old bytes outside scratch until recovery."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from functools import partial
|
||||
|
||||
from pm.ensure import ensure
|
||||
from pm.ensure import ensure, stage_only
|
||||
|
||||
env = pm_env
|
||||
ensure("faketool", base_env={})
|
||||
target = current_target() if route == "install" else "linux-arm64-bionic"
|
||||
realize = partial(ensure, "faketool", explicit=True, base_env={}) if route == "install" else partial(
|
||||
stage_only, "faketool", target)
|
||||
realize()
|
||||
old_fact = Facts(paths.facts_path()).get("faketool")
|
||||
entry = paths.store_root() / FakeTool().store_entry("1.0", target)
|
||||
old_digest = tree_digest(entry)
|
||||
_, digest = make_tar(env["docroot"], "replacement.tar.gz", {"bin/faketool": "#!new"})
|
||||
lockfile = Lockfile(env["lockfile_path"])
|
||||
lockfile.set_pin("faketool", "1.0", {"any": {
|
||||
@@ -248,28 +307,39 @@ def test_killed_replacement_recovers_on_next_install(pm_env):
|
||||
import pm.registry as registry
|
||||
from pm.store import Store
|
||||
from tests.pm.test_pm_authority import FakeTool
|
||||
from pm.ensure import ensure
|
||||
from pm.ensure import ensure, stage_only
|
||||
paths.lockfile_path = lambda: Path(sys.argv[1])
|
||||
registry._packages[FakeTool.name] = FakeTool()
|
||||
publish = Store.publish
|
||||
def crash(self, staged, name):
|
||||
if name.startswith('faketool-'):
|
||||
os._exit(17)
|
||||
return publish(self, staged, name)
|
||||
if not name.startswith('faketool-'):
|
||||
return publish(self, staged, name)
|
||||
if sys.argv[3] == 'after-publish':
|
||||
publish(self, staged, name)
|
||||
os._exit(17)
|
||||
Store.publish = crash
|
||||
ensure('faketool', explicit=True, base_env={})
|
||||
if sys.argv[2] == 'install':
|
||||
ensure('faketool', explicit=True, base_env={})
|
||||
else:
|
||||
stage_only('faketool', 'linux-arm64-bionic')
|
||||
""")
|
||||
child = subprocess.run(
|
||||
[sys.executable, "-c", code, str(env["lockfile_path"])],
|
||||
[sys.executable, "-c", code, str(env["lockfile_path"]), route, interruption],
|
||||
env=dict(os.environ), capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert child.returncode == 17, child.stderr
|
||||
assert Facts(paths.facts_path()).get("faketool") == old_fact
|
||||
ensure("faketool", explicit=True, base_env={})
|
||||
fact = Facts(paths.facts_path()).get("faketool")
|
||||
entry = paths.store_root() / fact["entry"]
|
||||
assert (entry / "bin" / "faketool").read_bytes() == b"#!new"
|
||||
assert fact["digest"] == tree_digest(entry)
|
||||
previous = entry.with_name(f".previous-{'stage-' if route == 'stage' else ''}{entry.name}")
|
||||
assert tree_digest(previous) == old_digest
|
||||
realize()
|
||||
assert (entry / "bin/faketool").read_bytes() == b"#!new"
|
||||
assert not previous.exists()
|
||||
if route == "install":
|
||||
fact = Facts(paths.facts_path()).get("faketool")
|
||||
assert fact["digest"] == tree_digest(entry)
|
||||
else:
|
||||
assert not paths.facts_path().exists()
|
||||
assert json.loads((entry / ".pm-stage-pin.json").read_text())["sha256"] == [digest]
|
||||
|
||||
|
||||
def test_failed_restore_preserves_both_interrupted_versions(pm_env, monkeypatch):
|
||||
|
||||
@@ -172,6 +172,38 @@ def test_stage_only_repin_same_version_rebuilds(tmp_path, sandbox, monkeypatch):
|
||||
assert (stable / "bin" / "tool").read_bytes() == payload_b
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_stage_repin_refuses_a_native_directory_hold_then_recovers(sandbox, monkeypatch):
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
original, replacement = b"original", b"replacement"
|
||||
_arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(original)}])
|
||||
_seed_fetch_cache(sandbox, original)
|
||||
entry = ensure_mod.stage_only("stage-test", TARGET)
|
||||
marker = (entry / ".pm-stage-pin.json").read_bytes()
|
||||
_arm_lock(monkeypatch, [{"url": "https://example.test/tool", "sha256": _sha(replacement)}])
|
||||
_seed_fetch_cache(sandbox, replacement)
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
||||
kernel.CreateFileW.restype = wintypes.HANDLE
|
||||
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel.CloseHandle.restype = wintypes.BOOL
|
||||
# FILE_FLAG_BACKUP_SEMANTICS opens a directory; omit FILE_SHARE_DELETE.
|
||||
handle = kernel.CreateFileW(str(entry), 0, 3, None, 3, 0x02000000, None)
|
||||
assert handle != wintypes.HANDLE(-1).value, ctypes.get_last_error()
|
||||
try:
|
||||
with pytest.raises(InstallError):
|
||||
ensure_mod.stage_only("stage-test", TARGET)
|
||||
assert (entry / "bin/tool").read_bytes() == original
|
||||
assert (entry / ".pm-stage-pin.json").read_bytes() == marker
|
||||
finally:
|
||||
kernel.CloseHandle(handle)
|
||||
ensure_mod.stage_only("stage-test", TARGET)
|
||||
assert (entry / "bin/tool").read_bytes() == replacement
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_name", ["HashError", "DownloadPaused"])
|
||||
def test_permanent_or_paused_download_is_not_retried(error_name):
|
||||
from pm import downloader
|
||||
@@ -200,7 +232,7 @@ def test_repin_failure_preserves_previous_staged_entry(sandbox, monkeypatch, fai
|
||||
raise InstallError("stage-test", "injected staging failure")
|
||||
|
||||
if failure == "fetch":
|
||||
monkeypatch.setattr(sandbox, "fetch", fail)
|
||||
monkeypatch.setattr(sandbox, "fetch_many", fail)
|
||||
else:
|
||||
monkeypatch.setattr(sandbox, "publish", fail)
|
||||
with pytest.raises(InstallError, match="injected staging failure"):
|
||||
|
||||
@@ -199,8 +199,7 @@ def test_bundled_uv_uses_a_verified_writable_python_without_changing_runtime(ins
|
||||
assert tree_digest(entry) == shipped_digest
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
@pytest.mark.parametrize("damage", ["source", "copy", "publication"])
|
||||
@pytest.mark.parametrize("damage", [None, "source", "copy", "publication"])
|
||||
def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damage):
|
||||
import pm.registry as registry
|
||||
from pm.store import Store, tree_digest
|
||||
@@ -216,12 +215,15 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag
|
||||
entry_name = python.store_entry("test", target)
|
||||
source = shipped / entry_name
|
||||
source.mkdir()
|
||||
(source / "python.exe").write_bytes(b"new interpreter")
|
||||
binary_rel = python.binary(source, target).relative_to(source)
|
||||
(source / binary_rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(source / binary_rel).write_bytes(b"new interpreter")
|
||||
facts.record("python", "test", entry_name, {}, shipped,
|
||||
target=target, artifacts=[digest], digest=tree_digest(source))
|
||||
previous = writable / entry_name
|
||||
previous.mkdir()
|
||||
(previous / "python.exe").write_bytes(b"previous interpreter")
|
||||
(previous / binary_rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(previous / binary_rel).write_bytes(b"previous interpreter")
|
||||
previous_facts = Facts(writable / "facts.json")
|
||||
previous_facts.record("python", "previous", entry_name, {}, writable,
|
||||
target=target, artifacts=[digest], digest=tree_digest(previous))
|
||||
@@ -229,19 +231,28 @@ def test_copy_failure_preserves_previous_python(installed_uv, monkeypatch, damag
|
||||
copytree = shutil.copytree
|
||||
|
||||
if damage == "source":
|
||||
(source / "python.exe").write_bytes(b"damaged source")
|
||||
(source / binary_rel).write_bytes(b"damaged source")
|
||||
elif damage == "copy":
|
||||
def damaged_copy(src, dest, **kwargs):
|
||||
copytree(src, dest, **kwargs)
|
||||
(dest / "python.exe").write_bytes(b"damaged copy")
|
||||
def damaged_copy(src, dest, *args, **kwargs):
|
||||
result = copytree(src, dest, *args, **kwargs)
|
||||
if Path(src) == source:
|
||||
(dest / binary_rel).write_bytes(b"damaged copy")
|
||||
return result
|
||||
monkeypatch.setattr(shutil, "copytree", damaged_copy)
|
||||
else:
|
||||
elif damage == "publication":
|
||||
def failed_publication(*args, **kwargs):
|
||||
raise OSError("publication refused")
|
||||
monkeypatch.setattr(Store, "publish", failed_publication)
|
||||
|
||||
with pytest.raises(InstallError, match="verification|copied bytes|publication refused"):
|
||||
if damage is None:
|
||||
monkeypatch.setattr(Store, "fetch_many", lambda *args, **kwargs: pytest.fail("copy downloaded bytes"))
|
||||
ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target,
|
||||
copy_from=(facts, Store(shipped)))
|
||||
assert (previous / "python.exe").read_bytes() == b"previous interpreter"
|
||||
assert previous_facts.path.read_bytes() == before
|
||||
assert tree_digest(previous) == tree_digest(source)
|
||||
assert previous_facts.get("python")["digest"] == facts.get("python")["digest"]
|
||||
else:
|
||||
with pytest.raises(InstallError, match="verification|copied bytes|publication refused"):
|
||||
ensure._install(python, ensure._lockfile(), previous_facts, Store(writable), target,
|
||||
copy_from=(facts, Store(shipped)))
|
||||
assert (previous / binary_rel).read_bytes() == b"previous interpreter"
|
||||
assert previous_facts.path.read_bytes() == before
|
||||
|
||||
Reference in New Issue
Block a user