From 5932d228012ca5ba7a02bd9e16a2fe8d3dcf7230 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 3 Aug 2026 15:58:14 +1000 Subject: [PATCH] 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. --- enroll/cli.py | 24 +++++++++++++-- enroll/remote.py | 2 ++ tests/test_remote.py | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/enroll/cli.py b/enroll/cli.py index a123fd1..b0147d3 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -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. diff --git a/enroll/remote.py b/enroll/remote.py index 83006d9..60eb7a3 100644 --- a/enroll/remote.py +++ b/enroll/remote.py @@ -1126,6 +1126,8 @@ def _remote_harvest( # Run remote harvest. argv: list[str] = [ remote_python, + "-I", + "-S", rapp, "harvest", "--out", diff --git a/tests/test_remote.py b/tests/test_remote.py index 1e5ffcc..7e44316 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -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