Make remote harvest zipapp stdlib-only

Lazy-load manifest, explain, and validation dependencies so the remote
harvest zipapp does not require jsonschema, PyYAML, Paramiko, or other
site-packages on the target host.

Run the remote zipapp with Python isolated mode and site-packages
disabled, while preserving existing CLI monkeypatch hooks.
This commit is contained in:
Miguel Jacq 2026-08-03 15:58:14 +10:00
parent 70c90c62ae
commit 5932d22801
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
3 changed files with 94 additions and 3 deletions

View file

@ -18,20 +18,38 @@ from .diff import (
post_webhook,
send_email,
)
from .explain import explain_state
from .harvest import harvest
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
from .manifest import manifest
from .remote import (
remote_harvest,
RemoteSudoPasswordRequired,
RemoteSSHKeyPassphraseRequired,
)
from .sopsutil import SopsError, encrypt_file_binary
from .validate import validate_harvest
from .version import get_enroll_version
def explain_state(*args, **kwargs):
"""Load the explain implementation only when that command is used."""
from .explain import explain_state as _explain_state
return _explain_state(*args, **kwargs)
def manifest(*args, **kwargs):
"""Load manifest dependencies only when rendering a manifest."""
from .manifest import manifest as _manifest
return _manifest(*args, **kwargs)
def validate_harvest(*args, **kwargs):
"""Load jsonschema only when validation is requested."""
from .validate import validate_harvest as _validate_harvest
return _validate_harvest(*args, **kwargs)
def _discover_config_path(argv: list[str]) -> Optional[Path]:
"""Return the config path to use, if any.

View file

@ -1126,6 +1126,8 @@ def _remote_harvest(
# Run remote harvest.
argv: list[str] = [
remote_python,
"-I",
"-S",
rapp,
"harvest",
"--out",

View file

@ -313,6 +313,9 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
assert promote_argv.count("enroll.pyz") >= 2
assert "/tmp/enroll-root-123/enroll.pyz" in harvest_cmd
assert "/tmp/enroll-remote-123/enroll.pyz" not in harvest_cmd
harvest_argv = shlex.split(harvest_cmd)
pyz_i = harvest_argv.index("/tmp/enroll-root-123/enroll.pyz")
assert harvest_argv[pyz_i - 2 : pyz_i] == ["-I", "-S"]
assert not any(_is_pyz_verify_cmd(c) for c, _pty in calls)
# The trusted digest must be obtained while the archive is still private,
@ -1251,6 +1254,74 @@ def test_remote_verify_pyz_sha256_rejects_nonzero_rc():
)
def test_build_enroll_pyz_harvest_starts_without_site_packages(tmp_path: Path):
"""The remote payload must not depend on packages installed on the target.
``-S`` disables site-packages, reproducing a target that has Python but no
jsonschema/PyYAML. ``-I`` also ignores PYTHON* environment variables and
the current directory. The old eager CLI imports failed here before
argparse could dispatch to ``harvest``.
"""
import subprocess
import sys
import enroll.remote as r
pyz, _sha = r._build_enroll_pyz(tmp_path)
proc = subprocess.run(
[sys.executable, "-I", "-S", str(pyz), "harvest", "--help"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15,
)
assert proc.returncode == 0, proc.stderr
assert "usage: enroll harvest" in proc.stdout
assert "ModuleNotFoundError" not in proc.stderr
assert "jsonschema" not in proc.stderr
assert "yaml" not in proc.stderr.lower()
def test_build_enroll_pyz_runs_harvest_without_site_packages(tmp_path: Path):
"""Exercise command dispatch and the actual harvest implementation under -S."""
import subprocess
import sys
import enroll.remote as r
build_dir = tmp_path / "build"
build_dir.mkdir()
pyz, _sha = r._build_enroll_pyz(build_dir)
out_dir = tmp_path / "bundle"
proc = subprocess.run(
[
sys.executable,
"-I",
"-S",
str(pyz),
"harvest",
"--out",
str(out_dir),
"--exclude-path",
"/**",
"--assume-safe-path",
],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
assert (out_dir / "state.json").is_file()
assert "ModuleNotFoundError" not in proc.stderr
assert "jsonschema" not in proc.stderr
assert "yaml" not in proc.stderr.lower()
def test_build_enroll_pyz_excludes_tests_and_caches_and_returns_sha(tmp_path: Path):
import zipfile