Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions stacklets/backup/_cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)

Expand Down
18 changes: 18 additions & 0 deletions tests/unit/stacklets/test_backup_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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.
Expand Down
Loading