diff --git a/stacklets/backup/_cron.py b/stacklets/backup/_cron.py index b2a4b15c..05dae0f5 100644 --- a/stacklets/backup/_cron.py +++ b/stacklets/backup/_cron.py @@ -49,16 +49,19 @@ def sync_command(stack_bin: Path, log_path: Path) -> str: """The shell command a cron entry runs for the nightly sync. Both paths are double-quoted so an install or data directory - containing spaces doesn't break cron's word-splitting. PATH is - prepended with the Homebrew bin so cron's minimal environment finds - the Homebrew Python (the system Python on macOS is too old). Output + containing spaces doesn't break cron's word-splitting. cron starts + with PATH=/usr/bin:/bin, so the Homebrew bin goes in front for the + Homebrew Python (the system Python on macOS is too old), and the + directories of the other tools the sync calls by name go after it: + ``/usr/local/bin`` for docker (OrbStack and Docker Desktop link it + there), ``/usr/sbin`` for diskutil, ``/sbin`` for mount. Output appends to ``log_path`` so a misbehaving scheduled run leaves a trail. Shared by on_install and on_start so the entry stays byte-identical across reinstalls. """ homebrew_bin = _homebrew_prefix() / "bin" return ( - f'PATH="{homebrew_bin}:$PATH" ' + f'PATH="{homebrew_bin}:$PATH:/usr/local/bin:/usr/sbin:/sbin" ' f'"{stack_bin}" backup sync >> "{log_path}" 2>&1' ) diff --git a/tests/unit/stacklets/test_backup_install.py b/tests/unit/stacklets/test_backup_install.py index b3df308a..90ae2bb1 100644 --- a/tests/unit/stacklets/test_backup_install.py +++ b/tests/unit/stacklets/test_backup_install.py @@ -8,9 +8,12 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT / "stacklets" / "backup" / "hooks")) @@ -81,6 +84,21 @@ def test_prepends_homebrew_path(self): assert cmd.startswith('PATH="') assert "/bin:$PATH" in cmd + @pytest.mark.skipif(not Path("/usr/sbin/diskutil").exists(), reason="macOS only") + def test_finds_the_sync_tools_under_crons_path(self, tmp_path): + # cron on macOS starts with PATH=/usr/bin:/bin, and the sync calls + # diskutil, mount and docker by name. Run the line the way cron + # does, with a stand-in for the stack that looks each one up. + tools = ["diskutil", "mount"] + (["docker"] if Path("/usr/local/bin/docker").exists() else []) + stack = tmp_path / "stack" + stack.write_text("#!/bin/sh\n" + "".join(f"command -v {t}\n" for t in tools)) + stack.chmod(0o755) + log = tmp_path / "cron.log" + cmd = cron.sync_command(stack, log) + subprocess.run(["/usr/bin/env", "-i", "PATH=/usr/bin:/bin", "/bin/sh", "-c", cmd], check=False) + found = log.read_text().split() + assert [Path(f).name for f in found] == tools + def test_uses_absolute_paths(self): # cron's PATH is minimal; relative paths break. The binary, the # log destination, and the prepended PATH dirs must all be absolute.