From a9ab59d118994a2672b92090a5098dce39264cb5 Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 21 Apr 2026 15:29:21 -0600 Subject: [PATCH 1/8] first --- examples/deploy.sh | 22 +++ examples/fetch-runner.service | 73 ++++++++ examples/jobs.toml | 20 +++ pyproject.toml | 27 +++ src/fetch_runner/__init__.py | 6 + src/fetch_runner/__main__.py | 4 + src/fetch_runner/cli.py | 82 +++++++++ src/fetch_runner/config.py | 178 ++++++++++++++++++ src/fetch_runner/git_ops.py | 52 ++++++ src/fetch_runner/guard.py | 157 ++++++++++++++++ src/fetch_runner/runner.py | 135 ++++++++++++++ tests/__init__.py | 0 tests/test_config.py | 328 ++++++++++++++++++++++++++++++++++ tests/test_guard.py | 136 ++++++++++++++ uv.lock | 79 ++++++++ 15 files changed, 1299 insertions(+) create mode 100755 examples/deploy.sh create mode 100644 examples/fetch-runner.service create mode 100644 examples/jobs.toml create mode 100644 pyproject.toml create mode 100644 src/fetch_runner/__init__.py create mode 100644 src/fetch_runner/__main__.py create mode 100644 src/fetch_runner/cli.py create mode 100644 src/fetch_runner/config.py create mode 100644 src/fetch_runner/git_ops.py create mode 100644 src/fetch_runner/guard.py create mode 100644 src/fetch_runner/runner.py create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py create mode 100644 tests/test_guard.py create mode 100644 uv.lock diff --git a/examples/deploy.sh b/examples/deploy.sh new file mode 100755 index 0000000..e935172 --- /dev/null +++ b/examples/deploy.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# >>> fetch-runner-guard:BEGIN user=deploy +if [ "$(whoami)" != "deploy" ] || [ "$(id -u)" -eq 0 ]; then + printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: deploy, non-root\n' "$(whoami)" "$(id -u)" >&2 + exit 1 +fi +# <<< fetch-runner-guard:END + +set -euo pipefail + +# This script is designed to run identically whether fetch-runner invoked it +# on a new commit or a human invoked it from a terminal. The guard above is +# the only invariant: the caller must be user=deploy and not root. + +cd "$(dirname -- "$0")" + +echo "deploying $(basename -- "$PWD")" + +# Your deploy steps here, e.g. a docker compose rollout: +# docker compose pull +# docker compose up --detach --remove-orphans +# docker image prune --force diff --git a/examples/fetch-runner.service b/examples/fetch-runner.service new file mode 100644 index 0000000..c0872c0 --- /dev/null +++ b/examples/fetch-runner.service @@ -0,0 +1,73 @@ +[Unit] +Description=fetch-runner: poll git branches and run scripts on new commits +Documentation=https://github.com/BYU-ODH/fetch-runner +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# =========================================================================== +# CUSTOMIZE: edit the lines in this block for each deployment. +# =========================================================================== + +# MUST match the `user` field in the jobs.toml this unit starts. +# fetch-runner exits at startup if they do not match. +User=deploy +Group=deploy + +# Path to the fetch-runner binary and to the config file it should load. +# If you installed fetch-runner from a venv, point ExecStart at that venv's +# bin/fetch-runner. The config path is positional. +ExecStart=/usr/local/bin/fetch-runner /etc/fetch-runner/jobs.toml + +# Every directory your deploy scripts need to write to must appear here +# (the git repos you poll, plus any app state they touch). Separate with +# spaces. The rest of the filesystem is read-only thanks to ProtectSystem. +ReadWritePaths=/srv + +# =========================================================================== +# DO NOT MODIFY below this line unless you know exactly why. +# These settings are the security hardening that makes this service safe +# to leave running. Removing any of them weakens the sandbox. +# =========================================================================== + +Restart=on-failure +RestartSec=10s +TimeoutStopSec=30s + +StandardOutput=journal +StandardError=journal + +# Block privilege escalation; the deploy user never needs more than it has. +NoNewPrivileges=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +AmbientCapabilities= + +# Filesystem isolation. +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible + +# Kernel / namespace hardening. +LockPersonality=true +RestrictRealtime=true +RestrictNamespaces=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +# Syscall filter. +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target diff --git a/examples/jobs.toml b/examples/jobs.toml new file mode 100644 index 0000000..cb4c61c --- /dev/null +++ b/examples/jobs.toml @@ -0,0 +1,20 @@ +# Sample fetch-runner config. +# fetch-runner will refuse to load this unless the process is running as the +# user named below (and not as root). + +[general] +user = "deploy" +poll_interval_seconds = 60 + +[[jobs]] +name = "api" +path = "/srv/api" +branch = "main" +script = "/srv/api/deploy.sh" +timeout_seconds = 600 + +[[jobs]] +name = "web" +path = "/srv/web" +branch = "production" +script = "/srv/web/deploy.sh" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..95a4ce2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["uv_build>=0.4.8,<100"] +build-backend = "uv_build" + +[project] +name = "fetch-runner" +version = "0.1.0" +description = "Poll git branches and run scripts when new commits arrive" +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "BYU ODH" }] +dependencies = [] + +[project.scripts] +fetch-runner = "fetch_runner.cli:main" + +[tool.uv.build-backend] +module-name = "fetch_runner" +module-root = "src" + +[dependency-groups] +dev = ["pytest>=8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/src/fetch_runner/__init__.py b/src/fetch_runner/__init__.py new file mode 100644 index 0000000..9da87ca --- /dev/null +++ b/src/fetch_runner/__init__.py @@ -0,0 +1,6 @@ +from importlib.metadata import PackageNotFoundError, version as _version + +try: + __version__ = _version("fetch-runner") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/fetch_runner/__main__.py b/src/fetch_runner/__main__.py new file mode 100644 index 0000000..fb771ef --- /dev/null +++ b/src/fetch_runner/__main__.py @@ -0,0 +1,4 @@ +from fetch_runner.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py new file mode 100644 index 0000000..fa858ae --- /dev/null +++ b/src/fetch_runner/cli.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import argparse +import logging +import signal +import sys +from pathlib import Path + +from fetch_runner import __version__ +from fetch_runner.config import ConfigError, load_config +from fetch_runner.guard import GuardError, render_guard +from fetch_runner.runner import Runner + +log = logging.getLogger("fetch_runner") + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + prog="fetch-runner", + description="Poll git branches and run scripts when new commits arrive.", + ) + p.add_argument("config", type=Path, nargs="?", help="path to jobs.toml") + p.add_argument( + "--check", + action="store_true", + help="validate the config (including every script's guard) and exit", + ) + p.add_argument( + "--print-guard", + metavar="USER", + help="print the canonical guard block for USER and exit", + ) + p.add_argument( + "-v", "--verbose", action="store_true", help="enable debug logging" + ) + p.add_argument("--version", action="version", version=f"fetch-runner {__version__}") + args = p.parse_args(argv) + + _configure_logging(args.verbose) + + if args.print_guard: + try: + sys.stdout.write(render_guard(args.print_guard)) + except GuardError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + return 0 + + if args.config is None: + p.error("config path is required (or use --print-guard)") + + try: + cfg = load_config(args.config) + except ConfigError as e: + print(f"config error: {e}", file=sys.stderr) + return 2 + + if args.check: + print( + f"ok: user={cfg.user} jobs={len(cfg.jobs)} " + f"poll={cfg.poll_interval_seconds}s" + ) + return 0 + + runner = Runner(cfg) + + def _stop(signum, _frame): + log.info("received signal %s; shutting down", signum) + runner.request_stop() + + signal.signal(signal.SIGTERM, _stop) + signal.signal(signal.SIGINT, _stop) + return runner.run_forever() + + +def _configure_logging(verbose: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + stream=sys.stderr, + ) diff --git a/src/fetch_runner/config.py b/src/fetch_runner/config.py new file mode 100644 index 0000000..a5c5e56 --- /dev/null +++ b/src/fetch_runner/config.py @@ -0,0 +1,178 @@ +"""jobs.toml loader with strict validation. + +Loading a config is a security decision: if any check fails, we raise +``ConfigError`` and refuse to start rather than skip the job. The expected +operational pattern is ``fetch-runner --check jobs.toml`` as part of any +deploy, so that config errors surface before a restart. +""" +from __future__ import annotations + +import os +import stat +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from fetch_runner.guard import ( + GuardError, + require_runtime_user, + validate_script_guard, +) + + +class ConfigError(Exception): + pass + + +@dataclass(frozen=True) +class Job: + name: str + path: Path + branch: str + script: Path + timeout_seconds: int | None + + +@dataclass(frozen=True) +class Config: + user: str + poll_interval_seconds: int + jobs: tuple[Job, ...] + + +_ALLOWED_TOP = {"general", "jobs"} +_ALLOWED_GENERAL = {"user", "poll_interval_seconds"} +_ALLOWED_JOB = {"name", "path", "branch", "script", "timeout_seconds"} + +_UNSAFE_BRANCH_CHARS = frozenset(" \t\n\r\x00'\";|&`$<>()[]{}\\*?") + + +def load_config(path: Path) -> Config: + try: + raw_text = path.read_text() + except OSError as e: + raise ConfigError(f"cannot read {path}: {e}") from e + try: + raw = tomllib.loads(raw_text) + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"invalid TOML in {path}: {e}") from e + + _reject_unknown(raw, _ALLOWED_TOP, f"{path}: top-level") + + general = raw.get("general") + if not isinstance(general, dict): + raise ConfigError(f"{path}: missing [general] section") + _reject_unknown(general, _ALLOWED_GENERAL, f"{path}: [general]") + + user = _require_str(general, "user", "[general]", path) + poll_interval = _require_int( + general, "poll_interval_seconds", "[general]", path, minimum=1 + ) + + # Enforce user match before doing anything else: an operator who dropped + # a jobs.toml for the wrong service account should see an immediate + # error, not have individual jobs quietly skipped. + try: + require_runtime_user(user) + except GuardError as e: + raise ConfigError(f"{path}: {e}") from e + + raw_jobs = raw.get("jobs") + if not isinstance(raw_jobs, list) or not raw_jobs: + raise ConfigError(f"{path}: at least one [[jobs]] entry is required") + + seen_names: set[str] = set() + seen_paths: set[Path] = set() + jobs: list[Job] = [] + for i, entry in enumerate(raw_jobs): + section = f"[[jobs]] #{i}" + if not isinstance(entry, dict): + raise ConfigError(f"{path}: {section} is not a table") + _reject_unknown(entry, _ALLOWED_JOB, f"{path}: {section}") + + name = _require_str(entry, "name", section, path) + if name in seen_names: + raise ConfigError(f"{path}: duplicate job name {name!r}") + seen_names.add(name) + + repo_path = Path(_require_str(entry, "path", section, path)).resolve() + if repo_path in seen_paths: + raise ConfigError(f"{path}: duplicate job path {repo_path}") + seen_paths.add(repo_path) + if not (repo_path / ".git").exists(): + raise ConfigError( + f"{path}: {section}.path {repo_path} is not a git repository" + ) + + branch = _require_str(entry, "branch", section, path) + if branch.startswith("-") or any(c in _UNSAFE_BRANCH_CHARS for c in branch): + raise ConfigError( + f"{path}: {section}.branch contains unsafe characters: {branch!r}" + ) + if len(branch) > 128: + raise ConfigError(f"{path}: {section}.branch too long") + + script_path = Path(_require_str(entry, "script", section, path)).resolve() + _validate_script_file(script_path, user, section, path) + + timeout = entry.get("timeout_seconds") + if timeout is not None: + if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0: + raise ConfigError( + f"{path}: {section}.timeout_seconds must be a positive integer" + ) + + jobs.append( + Job( + name=name, + path=repo_path, + branch=branch, + script=script_path, + timeout_seconds=timeout, + ) + ) + + return Config(user=user, poll_interval_seconds=poll_interval, jobs=tuple(jobs)) + + +def _validate_script_file(script: Path, user: str, section: str, cfg_path: Path) -> None: + if not script.is_file(): + raise ConfigError(f"{cfg_path}: {section}.script {script} does not exist") + if not os.access(script, os.X_OK): + raise ConfigError(f"{cfg_path}: {section}.script {script} is not executable") + st = script.stat() + if st.st_mode & stat.S_IWOTH: + raise ConfigError( + f"{cfg_path}: {section}.script {script} is world-writable; refusing" + ) + check = validate_script_guard(script, user) + if not check.ok: + raise ConfigError( + f"{cfg_path}: {section}.script failed guard validation: {check.reason}" + ) + + +def _reject_unknown(d: dict, allowed: set[str], where: str) -> None: + extra = set(d) - allowed + if extra: + raise ConfigError(f"{where}: unknown keys {sorted(extra)!r}") + + +def _require_str(d: dict, key: str, section: str, cfg_path: Path) -> str: + v = d.get(key) + if not isinstance(v, str) or not v: + raise ConfigError( + f"{cfg_path}: {section}.{key} must be a non-empty string" + ) + return v + + +def _require_int( + d: dict, key: str, section: str, cfg_path: Path, *, minimum: int +) -> int: + v = d.get(key) + if not isinstance(v, int) or isinstance(v, bool): + raise ConfigError(f"{cfg_path}: {section}.{key} must be an integer") + if v < minimum: + raise ConfigError(f"{cfg_path}: {section}.{key} must be >= {minimum}") + return v diff --git a/src/fetch_runner/git_ops.py b/src/fetch_runner/git_ops.py new file mode 100644 index 0000000..29dd944 --- /dev/null +++ b/src/fetch_runner/git_ops.py @@ -0,0 +1,52 @@ +"""Minimal ``git`` wrappers. + +Subprocess calls use an argv list (never ``shell=True``); repo paths are +passed with ``-C`` and branch names have already been validated against an +unsafe-character set at config load. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class GitError(Exception): + pass + + +def _run(repo: Path, *args: str, timeout: float = 120) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as e: + raise GitError("git executable not found in PATH") from e + except subprocess.TimeoutExpired as e: + raise GitError(f"git {' '.join(args)} in {repo} timed out after {timeout}s") from e + if result.returncode != 0: + msg = (result.stderr or result.stdout).strip() + raise GitError(f"git {' '.join(args)} in {repo} failed: {msg}") + return result.stdout.strip() + + +def current_commit(repo: Path, branch: str) -> str: + """Return the commit the local ``branch`` ref points at, or '' if it does not exist.""" + try: + return _run(repo, "rev-parse", "--verify", f"refs/heads/{branch}") + except GitError: + return "" + + +def fetch(repo: Path, branch: str, timeout: float = 120) -> str: + """Fetch ``origin/`` and return the fetched commit SHA.""" + _run(repo, "fetch", "--quiet", "--no-tags", "origin", branch, timeout=timeout) + return _run(repo, "rev-parse", "FETCH_HEAD") + + +def checkout(repo: Path, branch: str, commit: str) -> None: + """Force the local ``branch`` to ``commit`` and reset the working tree.""" + _run(repo, "checkout", "--quiet", "--force", "-B", branch, commit) diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py new file mode 100644 index 0000000..9dcc1c2 --- /dev/null +++ b/src/fetch_runner/guard.py @@ -0,0 +1,157 @@ +"""User-check enforcement. + +Two jobs: + +1. At runtime, verify fetch-runner itself is executing as the configured user + and not as root. The check is based on the real UID (``os.getuid``) and the + passwd database, so ``$USER`` / ``$LOGNAME`` / utmp cannot influence it. + +2. Before a job script runs, verify the script contains a canonical guard + block near the top. The block is matched by exact bytes, so a weakened + or removed check is detected. + +The guard is written in portable POSIX shell, so it works under both +``#!/bin/sh`` and ``#!/bin/bash`` (and anything else that speaks POSIX +``test``/``printf``). +""" +from __future__ import annotations + +import os +import pwd +from dataclasses import dataclass +from pathlib import Path + +GUARD_BEGIN_PREFIX = "# >>> fetch-runner-guard:BEGIN" +GUARD_END = "# <<< fetch-runner-guard:END" + +_GUARD_TEMPLATE = """\ +# >>> fetch-runner-guard:BEGIN user={user} +if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then + printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: {user}, non-root\\n' "$(whoami)" "$(id -u)" >&2 + exit 1 +fi +# <<< fetch-runner-guard:END +""" + + +class GuardError(Exception): + pass + + +@dataclass(frozen=True) +class GuardCheck: + ok: bool + reason: str = "" + + +def render_guard(user: str) -> str: + """Return the canonical guard block for ``user`` (trailing newline included).""" + _assert_safe_user(user) + return _GUARD_TEMPLATE.format(user=user) + + +def current_user() -> str: + """Return the login name of the real UID. + + Uses ``pwd.getpwuid(os.getuid())`` rather than ``$USER`` or ``whoami`` so + the answer cannot be spoofed via environment variables or utmp. + """ + uid = os.getuid() + try: + return pwd.getpwuid(uid).pw_name + except KeyError as e: + raise GuardError(f"cannot resolve current uid {uid} to a user name") from e + + +def require_runtime_user(expected: str) -> None: + """Raise ``GuardError`` unless the process is running as ``expected`` and non-root.""" + _assert_safe_user(expected) + actual = current_user() + if actual != expected: + raise GuardError( + f"runtime user {actual!r} does not match configured user {expected!r}" + ) + if os.getuid() == 0: + raise GuardError("refusing to run as root (uid 0)") + + +def validate_script_guard(script: Path, user: str) -> GuardCheck: + """Check that ``script`` begins with the canonical guard for ``user``. + + Allowed before the guard: an optional shebang on line 1, blank lines, and + ``#``-comment lines. Anything else (including ``set -e``) is rejected: + the guard must be the first code that runs. + """ + _assert_safe_user(user) + try: + text = script.read_text() + except OSError as e: + return GuardCheck(False, f"cannot read script {script}: {e}") + + lines = text.splitlines() + i = 0 + if lines and lines[0].startswith("#!"): + i = 1 + + expected_begin = f"# >>> fetch-runner-guard:BEGIN user={user}" + while i < len(lines): + stripped = lines[i].strip() + if stripped == expected_begin: + break + if stripped.startswith(GUARD_BEGIN_PREFIX): + return GuardCheck( + False, + f"{script}:{i + 1}: guard BEGIN marker targets a different user " + f"(expected {user!r}); found {stripped!r}", + ) + if stripped == "" or stripped.startswith("#"): + i += 1 + continue + return GuardCheck( + False, + f"{script}:{i + 1}: guard must come before any executable code; " + f"found {lines[i]!r}", + ) + else: + return GuardCheck( + False, f"{script}: canonical guard block for user {user!r} not found" + ) + + expected = render_guard(user).splitlines() + for k, want in enumerate(expected): + line_no = i + k + 1 + if i + k >= len(lines): + return GuardCheck( + False, + f"{script}:{line_no}: guard block truncated; expected {want!r}", + ) + got = lines[i + k] + if got != want: + return GuardCheck( + False, + f"{script}:{line_no}: guard block mismatch; " + f"expected {want!r}, got {got!r}", + ) + return GuardCheck(True) + + +# Conservative allowlist; keeps us safe if a user name is ever interpolated +# into shell text. POSIX permits a broader set but this covers real-world +# Linux account names. +_ALLOWED_USER_CHARS = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" +) + + +def _assert_safe_user(user: str) -> None: + if not isinstance(user, str) or not user: + raise GuardError("user name must be a non-empty string") + if len(user) > 32: + raise GuardError(f"user name too long: {user!r}") + if user.startswith("-"): + raise GuardError(f"user name may not start with '-': {user!r}") + bad = [c for c in user if c not in _ALLOWED_USER_CHARS] + if bad: + raise GuardError( + f"user name contains disallowed characters {sorted(set(bad))!r}: {user!r}" + ) diff --git a/src/fetch_runner/runner.py b/src/fetch_runner/runner.py new file mode 100644 index 0000000..487bd15 --- /dev/null +++ b/src/fetch_runner/runner.py @@ -0,0 +1,135 @@ +"""Polling loop. + +Per job, on each tick: + 1. ``git fetch origin `` and read ``FETCH_HEAD``. + 2. If the SHA differs from the last one we acted on, re-validate the + script's guard (in case the new commit tampered with it), hard-reset + the working tree to that SHA, and run the script. + 3. Update the in-memory cursor whether the script succeeded or failed — + a failing commit should not be re-run in a tight loop. + +The initial cursor is the current local branch SHA, so restarting the +service does not replay the last deploy. +""" +from __future__ import annotations + +import logging +import os +import subprocess +import threading +from pathlib import Path + +from fetch_runner.config import Config, Job +from fetch_runner.git_ops import GitError, checkout, current_commit, fetch +from fetch_runner.guard import validate_script_guard + +log = logging.getLogger("fetch_runner") + + +class Runner: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self._last_commit: dict[str, str] = {} + self._stop = threading.Event() + + def request_stop(self) -> None: + self._stop.set() + + def run_forever(self) -> int: + self._initialize_cursors() + log.info( + "fetch-runner started: user=%s jobs=%d poll=%ss", + self.cfg.user, + len(self.cfg.jobs), + self.cfg.poll_interval_seconds, + ) + while not self._stop.is_set(): + for job in self.cfg.jobs: + if self._stop.is_set(): + break + try: + self._poll(job) + except Exception: + log.exception("job %s: unexpected error", job.name) + self._stop.wait(self.cfg.poll_interval_seconds) + log.info("fetch-runner stopped") + return 0 + + def _initialize_cursors(self) -> None: + for job in self.cfg.jobs: + try: + sha = current_commit(job.path, job.branch) + except GitError as e: + log.warning( + "job %s: cannot read initial commit for %s: %s", + job.name, job.branch, e, + ) + sha = "" + self._last_commit[job.name] = sha + log.info("job %s: initial commit %s", job.name, _short(sha)) + + def _poll(self, job: Job) -> None: + try: + remote = fetch(job.path, job.branch) + except GitError as e: + log.warning("job %s: fetch failed: %s", job.name, e) + return + last = self._last_commit.get(job.name, "") + if remote == last: + log.debug("job %s: no change (%s)", job.name, _short(remote)) + return + log.info( + "job %s: new commit %s -> %s", + job.name, _short(last) or "", _short(remote), + ) + try: + checkout(job.path, job.branch, remote) + except GitError as e: + log.error("job %s: checkout failed: %s", job.name, e) + return + # Re-validate the guard after checkout: the incoming commit could + # have removed or weakened it. + check = validate_script_guard(job.script, self.cfg.user) + if not check.ok: + log.error( + "job %s: script at %s failed guard check after checkout: %s", + job.name, remote, check.reason, + ) + self._last_commit[job.name] = remote + return + self._run_script(job, remote) + self._last_commit[job.name] = remote + + def _run_script(self, job: Job, commit: str) -> None: + env = { + **os.environ, + "FETCH_RUNNER_JOB": job.name, + "FETCH_RUNNER_BRANCH": job.branch, + "FETCH_RUNNER_COMMIT": commit, + "FETCH_RUNNER_REPO": str(job.path), + } + log.info("job %s: running %s", job.name, job.script) + try: + proc = subprocess.run( + [str(job.script)], + cwd=job.path, + env=env, + timeout=job.timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired: + log.error( + "job %s: script timed out after %ss", job.name, job.timeout_seconds + ) + return + except OSError as e: + log.error("job %s: cannot execute script: %s", job.name, e) + return + if proc.returncode == 0: + log.info("job %s: script succeeded", job.name) + else: + log.error("job %s: script exited %d", job.name, proc.returncode) + + +def _short(sha: str) -> str: + return sha[:12] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..877916b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest + +from fetch_runner.config import ConfigError, load_config +from fetch_runner.guard import current_user, render_guard + + +@pytest.fixture(autouse=True) +def _not_running_as_root(): + if os.getuid() == 0: + pytest.skip("config tests require a non-root test user") + + +def _make_repo(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / ".git").mkdir() + return path + + +def _make_script(path: Path, user: str) -> Path: + path.write_text("#!/bin/bash\n" + render_guard(user) + "echo hi\n") + path.chmod(0o755) + return path + + +def _write_cfg(path: Path, body: str) -> Path: + path.write_text(body) + return path + + +def _base_cfg(tmp_path: Path, *, user: str, extra_job_lines: str = "") -> Path: + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + return _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 30 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +{extra_job_lines} +""", + ) + + +def test_load_happy_path(tmp_path: Path): + user = current_user() + cfg_path = _base_cfg(tmp_path, user=user) + cfg = load_config(cfg_path) + assert cfg.user == user + assert cfg.poll_interval_seconds == 30 + assert len(cfg.jobs) == 1 + assert cfg.jobs[0].name == "j1" + assert cfg.jobs[0].branch == "main" + assert cfg.jobs[0].timeout_seconds is None + + +def test_load_rejects_wrong_user(tmp_path: Path): + # A jobs.toml whose user does not match the running user must be refused. + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", "someone-else-xyz") + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "someone-else-xyz" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="runtime user"): + load_config(cfg_path) + + +def test_load_rejects_unknown_top_level_key(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[extras] +something = 1 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="unknown keys"): + load_config(cfg_path) + + +def test_load_rejects_unknown_job_key(tmp_path: Path): + cfg_path = _base_cfg( + tmp_path, user=current_user(), extra_job_lines='command = "rm -rf /"' + ) + with pytest.raises(ConfigError, match="unknown keys"): + load_config(cfg_path) + + +def test_load_rejects_missing_script(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{tmp_path}/nope.sh" +""", + ) + with pytest.raises(ConfigError, match="does not exist"): + load_config(cfg_path) + + +def test_load_rejects_non_git_path(tmp_path: Path): + user = current_user() + script = _make_script(tmp_path / "deploy.sh", user) + not_a_repo = tmp_path / "not_a_repo" + not_a_repo.mkdir() + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{not_a_repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="not a git repository"): + load_config(cfg_path) + + +def test_load_rejects_world_writable_script(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + script.chmod(script.stat().st_mode | stat.S_IWOTH) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="world-writable"): + load_config(cfg_path) + + +def test_load_rejects_non_executable_script(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + script.chmod(0o644) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="not executable"): + load_config(cfg_path) + + +def test_load_rejects_script_without_guard(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = tmp_path / "deploy.sh" + script.write_text("#!/bin/bash\necho hi\n") + script.chmod(0o755) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="guard"): + load_config(cfg_path) + + +def test_load_rejects_duplicate_path(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "a" +path = "{repo}" +branch = "main" +script = "{script}" + +[[jobs]] +name = "b" +path = "{repo}" +branch = "other" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="duplicate job path"): + load_config(cfg_path) + + +def test_load_rejects_unsafe_branch(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main; rm -rf /" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="unsafe characters"): + load_config(cfg_path) + + +def test_load_rejects_leading_dash_branch(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "--upload-pack=evil" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="unsafe characters"): + load_config(cfg_path) + + +def test_load_rejects_zero_poll_interval(tmp_path: Path): + user = current_user() + repo = _make_repo(tmp_path / "repo") + script = _make_script(tmp_path / "deploy.sh", user) + cfg_path = _write_cfg( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user}" +poll_interval_seconds = 0 + +[[jobs]] +name = "j1" +path = "{repo}" +branch = "main" +script = "{script}" +""", + ) + with pytest.raises(ConfigError, match="poll_interval_seconds"): + load_config(cfg_path) diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..775a8ac --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import os +import pwd +from pathlib import Path + +import pytest + +from fetch_runner.guard import ( + GuardError, + current_user, + render_guard, + require_runtime_user, + validate_script_guard, +) + + +def test_render_guard_embeds_user_everywhere(): + g = render_guard("deploy") + assert "user=deploy" in g + assert '"$(whoami)" != "deploy"' in g + assert '"$(id -u)" -eq 0' in g + assert g.endswith("# <<< fetch-runner-guard:END\n") + + +@pytest.mark.parametrize( + "bad", + ["", "a" * 64, "-root", "foo;bar", "he re", "ro ot", "user$name", "`id`"], +) +def test_render_guard_rejects_unsafe_user(bad): + with pytest.raises(GuardError): + render_guard(bad) + + +def _write(path: Path, body: str) -> Path: + path.write_text(body) + return path + + +def test_validate_script_guard_happy(tmp_path: Path): + script = _write( + tmp_path / "deploy.sh", + "#!/bin/bash\n# a comment\n\n" + + render_guard("deploy") + + "echo hi\n", + ) + assert validate_script_guard(script, "deploy").ok + + +def test_validate_script_guard_works_without_shebang(tmp_path: Path): + script = _write(tmp_path / "s", render_guard("deploy") + "echo hi\n") + assert validate_script_guard(script, "deploy").ok + + +def test_validate_script_guard_only_comments_has_no_guard(tmp_path: Path): + script = _write(tmp_path / "s.sh", "#!/bin/bash\n# nothing here\n\n") + result = validate_script_guard(script, "deploy") + assert not result.ok + assert "canonical guard block" in result.reason + + +def test_validate_script_guard_code_without_guard(tmp_path: Path): + script = _write(tmp_path / "s.sh", "#!/bin/bash\necho hi\n") + result = validate_script_guard(script, "deploy") + assert not result.ok + assert "before any executable code" in result.reason + + +def test_validate_script_guard_wrong_user(tmp_path: Path): + script = _write( + tmp_path / "s.sh", "#!/bin/bash\n" + render_guard("otheruser") + "echo hi\n" + ) + result = validate_script_guard(script, "deploy") + assert not result.ok + assert "different user" in result.reason + + +def test_validate_script_guard_rejects_code_before_guard(tmp_path: Path): + script = _write( + tmp_path / "s.sh", + "#!/bin/bash\nset -e\n" + render_guard("deploy") + "echo hi\n", + ) + result = validate_script_guard(script, "deploy") + assert not result.ok + assert "before any executable code" in result.reason + + +def test_validate_script_guard_detects_flipped_comparator(tmp_path: Path): + # The most dangerous form of tampering: a check that always passes. + good = render_guard("deploy") + tampered = good.replace('!= "deploy"', '== "deploy"') + assert tampered != good + script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + tampered + "echo hi\n") + result = validate_script_guard(script, "deploy") + assert not result.ok + + +def test_validate_script_guard_detects_removed_uid_check(tmp_path: Path): + # Removing the root check: whoami would still match, but a root invocation + # should be blocked. Tampering must be caught. + good = render_guard("deploy") + tampered = good.replace(' || [ "$(id -u)" -eq 0 ]', "") + assert tampered != good + script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + tampered + "echo hi\n") + assert not validate_script_guard(script, "deploy").ok + + +def test_validate_script_guard_truncated(tmp_path: Path): + good = render_guard("deploy").splitlines() + truncated = "\n".join(good[:-2]) + "\n" + script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + truncated) + result = validate_script_guard(script, "deploy") + assert not result.ok + + +def test_validate_script_guard_missing_file(tmp_path: Path): + result = validate_script_guard(tmp_path / "nope.sh", "deploy") + assert not result.ok + assert "cannot read" in result.reason + + +def test_current_user_matches_pwd_entry(): + assert current_user() == pwd.getpwuid(os.getuid()).pw_name + + +def test_require_runtime_user_rejects_mismatch(): + with pytest.raises(GuardError): + require_runtime_user("definitely-not-this-user-xyz") + + +def test_require_runtime_user_accepts_match(): + # Running the test suite as the current user must succeed (uid != 0 + # in a sane test environment). Skip if someone is running tests as root. + if os.getuid() == 0: + pytest.skip("test suite is running as root") + require_runtime_user(current_user()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..02ce648 --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fetch-runner" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] From 25b2e4bc77e351ce9916c42a3396cc209e37ea57 Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 21 Apr 2026 16:39:45 -0600 Subject: [PATCH 2/8] pre-commit --- .github/workflows/tests.yml | 32 +++++++ .gitignore | 4 +- .pre-commit-config.yaml | 30 ++++++ pyproject.toml | 20 +++- src/fetch_runner/cli.py | 9 +- src/fetch_runner/config.py | 33 ++----- src/fetch_runner/git_ops.py | 1 + src/fetch_runner/guard.py | 40 ++++---- src/fetch_runner/runner.py | 18 ++-- tests/test_config.py | 4 +- tests/test_guard.py | 8 +- uv.lock | 186 +++++++++++++++++++++++++++++++++++- 12 files changed, 310 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7621359 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run ruff + run: uv run ruff check . + + - name: Run ruff format check + run: uv run ruff format --check . + + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index b7faf40..09be0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5585110 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: check-merge-conflict + - id: check-case-conflict + - id: debug-statements + - id: mixed-line-ending + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: pytest + name: pytest + entry: uv run pytest + language: system + types: [python] + pass_filenames: false + always_run: true diff --git a/pyproject.toml b/pyproject.toml index 95a4ce2..04f4197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,26 @@ module-name = "fetch_runner" module-root = "src" [dependency-groups] -dev = ["pytest>=8"] +dev = [ + "pytest>=8", + "pre-commit>=3", + "ruff>=0.8", +] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["fetch_runner"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +force-sort-within-sections = false +combine-as-imports = true diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py index fa858ae..37fe58d 100644 --- a/src/fetch_runner/cli.py +++ b/src/fetch_runner/cli.py @@ -30,9 +30,7 @@ def main(argv: list[str] | None = None) -> int: metavar="USER", help="print the canonical guard block for USER and exit", ) - p.add_argument( - "-v", "--verbose", action="store_true", help="enable debug logging" - ) + p.add_argument("-v", "--verbose", action="store_true", help="enable debug logging") p.add_argument("--version", action="version", version=f"fetch-runner {__version__}") args = p.parse_args(argv) @@ -56,10 +54,7 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.check: - print( - f"ok: user={cfg.user} jobs={len(cfg.jobs)} " - f"poll={cfg.poll_interval_seconds}s" - ) + print(f"ok: user={cfg.user} jobs={len(cfg.jobs)} " f"poll={cfg.poll_interval_seconds}s") return 0 runner = Runner(cfg) diff --git a/src/fetch_runner/config.py b/src/fetch_runner/config.py index a5c5e56..36dbe6a 100644 --- a/src/fetch_runner/config.py +++ b/src/fetch_runner/config.py @@ -5,6 +5,7 @@ operational pattern is ``fetch-runner --check jobs.toml`` as part of any deploy, so that config errors surface before a restart. """ + from __future__ import annotations import os @@ -65,9 +66,7 @@ def load_config(path: Path) -> Config: _reject_unknown(general, _ALLOWED_GENERAL, f"{path}: [general]") user = _require_str(general, "user", "[general]", path) - poll_interval = _require_int( - general, "poll_interval_seconds", "[general]", path, minimum=1 - ) + poll_interval = _require_int(general, "poll_interval_seconds", "[general]", path, minimum=1) # Enforce user match before doing anything else: an operator who dropped # a jobs.toml for the wrong service account should see an immediate @@ -100,15 +99,11 @@ def load_config(path: Path) -> Config: raise ConfigError(f"{path}: duplicate job path {repo_path}") seen_paths.add(repo_path) if not (repo_path / ".git").exists(): - raise ConfigError( - f"{path}: {section}.path {repo_path} is not a git repository" - ) + raise ConfigError(f"{path}: {section}.path {repo_path} is not a git repository") branch = _require_str(entry, "branch", section, path) if branch.startswith("-") or any(c in _UNSAFE_BRANCH_CHARS for c in branch): - raise ConfigError( - f"{path}: {section}.branch contains unsafe characters: {branch!r}" - ) + raise ConfigError(f"{path}: {section}.branch contains unsafe characters: {branch!r}") if len(branch) > 128: raise ConfigError(f"{path}: {section}.branch too long") @@ -118,9 +113,7 @@ def load_config(path: Path) -> Config: timeout = entry.get("timeout_seconds") if timeout is not None: if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0: - raise ConfigError( - f"{path}: {section}.timeout_seconds must be a positive integer" - ) + raise ConfigError(f"{path}: {section}.timeout_seconds must be a positive integer") jobs.append( Job( @@ -142,14 +135,10 @@ def _validate_script_file(script: Path, user: str, section: str, cfg_path: Path) raise ConfigError(f"{cfg_path}: {section}.script {script} is not executable") st = script.stat() if st.st_mode & stat.S_IWOTH: - raise ConfigError( - f"{cfg_path}: {section}.script {script} is world-writable; refusing" - ) + raise ConfigError(f"{cfg_path}: {section}.script {script} is world-writable; refusing") check = validate_script_guard(script, user) if not check.ok: - raise ConfigError( - f"{cfg_path}: {section}.script failed guard validation: {check.reason}" - ) + raise ConfigError(f"{cfg_path}: {section}.script failed guard validation: {check.reason}") def _reject_unknown(d: dict, allowed: set[str], where: str) -> None: @@ -161,15 +150,11 @@ def _reject_unknown(d: dict, allowed: set[str], where: str) -> None: def _require_str(d: dict, key: str, section: str, cfg_path: Path) -> str: v = d.get(key) if not isinstance(v, str) or not v: - raise ConfigError( - f"{cfg_path}: {section}.{key} must be a non-empty string" - ) + raise ConfigError(f"{cfg_path}: {section}.{key} must be a non-empty string") return v -def _require_int( - d: dict, key: str, section: str, cfg_path: Path, *, minimum: int -) -> int: +def _require_int(d: dict, key: str, section: str, cfg_path: Path, *, minimum: int) -> int: v = d.get(key) if not isinstance(v, int) or isinstance(v, bool): raise ConfigError(f"{cfg_path}: {section}.{key} must be an integer") diff --git a/src/fetch_runner/git_ops.py b/src/fetch_runner/git_ops.py index 29dd944..98ff3cd 100644 --- a/src/fetch_runner/git_ops.py +++ b/src/fetch_runner/git_ops.py @@ -4,6 +4,7 @@ passed with ``-C`` and branch names have already been validated against an unsafe-character set at config load. """ + from __future__ import annotations import subprocess diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py index 9dcc1c2..c717332 100644 --- a/src/fetch_runner/guard.py +++ b/src/fetch_runner/guard.py @@ -14,6 +14,7 @@ ``#!/bin/sh`` and ``#!/bin/bash`` (and anything else that speaks POSIX ``test``/``printf``). """ + from __future__ import annotations import os @@ -24,14 +25,15 @@ GUARD_BEGIN_PREFIX = "# >>> fetch-runner-guard:BEGIN" GUARD_END = "# <<< fetch-runner-guard:END" -_GUARD_TEMPLATE = """\ -# >>> fetch-runner-guard:BEGIN user={user} -if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then - printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: {user}, non-root\\n' "$(whoami)" "$(id -u)" >&2 - exit 1 -fi -# <<< fetch-runner-guard:END -""" +_GUARD_TEMPLATE = ( + "# >>> fetch-runner-guard:BEGIN user={user}\n" + 'if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then\n' + " printf 'fetch-runner-guard: refusing to run as %s (uid %s);" + ' required: {user}, non-root\\n\' "$(whoami)" "$(id -u)" >&2\n' + " exit 1\n" + "fi\n" + "# <<< fetch-runner-guard:END\n" +) class GuardError(Exception): @@ -68,9 +70,7 @@ def require_runtime_user(expected: str) -> None: _assert_safe_user(expected) actual = current_user() if actual != expected: - raise GuardError( - f"runtime user {actual!r} does not match configured user {expected!r}" - ) + raise GuardError(f"runtime user {actual!r} does not match configured user {expected!r}") if os.getuid() == 0: raise GuardError("refusing to run as root (uid 0)") @@ -109,13 +109,10 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: continue return GuardCheck( False, - f"{script}:{i + 1}: guard must come before any executable code; " - f"found {lines[i]!r}", + f"{script}:{i + 1}: guard must come before any executable code; " f"found {lines[i]!r}", ) else: - return GuardCheck( - False, f"{script}: canonical guard block for user {user!r} not found" - ) + return GuardCheck(False, f"{script}: canonical guard block for user {user!r} not found") expected = render_guard(user).splitlines() for k, want in enumerate(expected): @@ -129,8 +126,7 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: if got != want: return GuardCheck( False, - f"{script}:{line_no}: guard block mismatch; " - f"expected {want!r}, got {got!r}", + f"{script}:{line_no}: guard block mismatch; " f"expected {want!r}, got {got!r}", ) return GuardCheck(True) @@ -138,9 +134,7 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: # Conservative allowlist; keeps us safe if a user name is ever interpolated # into shell text. POSIX permits a broader set but this covers real-world # Linux account names. -_ALLOWED_USER_CHARS = frozenset( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" -) +_ALLOWED_USER_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") def _assert_safe_user(user: str) -> None: @@ -152,6 +146,4 @@ def _assert_safe_user(user: str) -> None: raise GuardError(f"user name may not start with '-': {user!r}") bad = [c for c in user if c not in _ALLOWED_USER_CHARS] if bad: - raise GuardError( - f"user name contains disallowed characters {sorted(set(bad))!r}: {user!r}" - ) + raise GuardError(f"user name contains disallowed characters {sorted(set(bad))!r}: {user!r}") diff --git a/src/fetch_runner/runner.py b/src/fetch_runner/runner.py index 487bd15..23b2d58 100644 --- a/src/fetch_runner/runner.py +++ b/src/fetch_runner/runner.py @@ -11,13 +11,13 @@ The initial cursor is the current local branch SHA, so restarting the service does not replay the last deploy. """ + from __future__ import annotations import logging import os import subprocess import threading -from pathlib import Path from fetch_runner.config import Config, Job from fetch_runner.git_ops import GitError, checkout, current_commit, fetch @@ -62,7 +62,9 @@ def _initialize_cursors(self) -> None: except GitError as e: log.warning( "job %s: cannot read initial commit for %s: %s", - job.name, job.branch, e, + job.name, + job.branch, + e, ) sha = "" self._last_commit[job.name] = sha @@ -80,7 +82,9 @@ def _poll(self, job: Job) -> None: return log.info( "job %s: new commit %s -> %s", - job.name, _short(last) or "", _short(remote), + job.name, + _short(last) or "", + _short(remote), ) try: checkout(job.path, job.branch, remote) @@ -93,7 +97,9 @@ def _poll(self, job: Job) -> None: if not check.ok: log.error( "job %s: script at %s failed guard check after checkout: %s", - job.name, remote, check.reason, + job.name, + remote, + check.reason, ) self._last_commit[job.name] = remote return @@ -118,9 +124,7 @@ def _run_script(self, job: Job, commit: str) -> None: check=False, ) except subprocess.TimeoutExpired: - log.error( - "job %s: script timed out after %ss", job.name, job.timeout_seconds - ) + log.error("job %s: script timed out after %ss", job.name, job.timeout_seconds) return except OSError as e: log.error("job %s: cannot execute script: %s", job.name, e) diff --git a/tests/test_config.py b/tests/test_config.py index 877916b..239920d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -113,9 +113,7 @@ def test_load_rejects_unknown_top_level_key(tmp_path: Path): def test_load_rejects_unknown_job_key(tmp_path: Path): - cfg_path = _base_cfg( - tmp_path, user=current_user(), extra_job_lines='command = "rm -rf /"' - ) + cfg_path = _base_cfg(tmp_path, user=current_user(), extra_job_lines='command = "rm -rf /"') with pytest.raises(ConfigError, match="unknown keys"): load_config(cfg_path) diff --git a/tests/test_guard.py b/tests/test_guard.py index 775a8ac..713b9f4 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -40,9 +40,7 @@ def _write(path: Path, body: str) -> Path: def test_validate_script_guard_happy(tmp_path: Path): script = _write( tmp_path / "deploy.sh", - "#!/bin/bash\n# a comment\n\n" - + render_guard("deploy") - + "echo hi\n", + "#!/bin/bash\n# a comment\n\n" + render_guard("deploy") + "echo hi\n", ) assert validate_script_guard(script, "deploy").ok @@ -67,9 +65,7 @@ def test_validate_script_guard_code_without_guard(tmp_path: Path): def test_validate_script_guard_wrong_user(tmp_path: Path): - script = _write( - tmp_path / "s.sh", "#!/bin/bash\n" + render_guard("otheruser") + "echo hi\n" - ) + script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + render_guard("otheruser") + "echo hi\n") result = validate_script_guard(script, "deploy") assert not result.ok assert "different user" in result.reason diff --git a/uv.lock b/uv.lock index 02ce648..345bace 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "fetch-runner" version = "0.1.0" @@ -18,13 +36,37 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] +dev = [ + { name = "pre-commit", specifier = ">=3" }, + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] [[package]] name = "iniconfig" @@ -35,6 +77,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -44,6 +95,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -53,6 +113,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -77,3 +153,111 @@ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] + +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, +] From 59729722608513fb7ad7bd40db1233240074e4c6 Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 21 Apr 2026 16:44:01 -0600 Subject: [PATCH 3/8] action versions --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7621359..567985c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,10 +9,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true From df2702f76b6c8ec044859fa4d010d81aa8684e0a Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 21 Apr 2026 16:58:07 -0600 Subject: [PATCH 4/8] isort --- pyproject.toml | 2 +- src/fetch_runner/__init__.py | 3 ++- src/fetch_runner/cli.py | 8 +++++--- src/fetch_runner/config.py | 8 +++----- src/fetch_runner/guard.py | 4 ++-- src/fetch_runner/runner.py | 8 ++++++-- tests/test_config.py | 6 ++++-- tests/test_guard.py | 12 +++++------- 8 files changed, 28 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 04f4197..65a8c23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,5 +41,5 @@ select = ["E", "F", "I", "W", "UP", "B"] [tool.ruff.lint.isort] known-first-party = ["fetch_runner"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +force-single-line = true force-sort-within-sections = false -combine-as-imports = true diff --git a/src/fetch_runner/__init__.py b/src/fetch_runner/__init__.py index 9da87ca..d5c062d 100644 --- a/src/fetch_runner/__init__.py +++ b/src/fetch_runner/__init__.py @@ -1,4 +1,5 @@ -from importlib.metadata import PackageNotFoundError, version as _version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _version try: __version__ = _version("fetch-runner") diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py index 37fe58d..bbd6687 100644 --- a/src/fetch_runner/cli.py +++ b/src/fetch_runner/cli.py @@ -7,8 +7,10 @@ from pathlib import Path from fetch_runner import __version__ -from fetch_runner.config import ConfigError, load_config -from fetch_runner.guard import GuardError, render_guard +from fetch_runner.config import ConfigError +from fetch_runner.config import load_config +from fetch_runner.guard import GuardError +from fetch_runner.guard import render_guard from fetch_runner.runner import Runner log = logging.getLogger("fetch_runner") @@ -54,7 +56,7 @@ def main(argv: list[str] | None = None) -> int: return 2 if args.check: - print(f"ok: user={cfg.user} jobs={len(cfg.jobs)} " f"poll={cfg.poll_interval_seconds}s") + print(f"ok: user={cfg.user} jobs={len(cfg.jobs)} poll={cfg.poll_interval_seconds}s") return 0 runner = Runner(cfg) diff --git a/src/fetch_runner/config.py b/src/fetch_runner/config.py index 36dbe6a..5de1690 100644 --- a/src/fetch_runner/config.py +++ b/src/fetch_runner/config.py @@ -14,11 +14,9 @@ from dataclasses import dataclass from pathlib import Path -from fetch_runner.guard import ( - GuardError, - require_runtime_user, - validate_script_guard, -) +from fetch_runner.guard import GuardError +from fetch_runner.guard import require_runtime_user +from fetch_runner.guard import validate_script_guard class ConfigError(Exception): diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py index c717332..6e65a6b 100644 --- a/src/fetch_runner/guard.py +++ b/src/fetch_runner/guard.py @@ -109,7 +109,7 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: continue return GuardCheck( False, - f"{script}:{i + 1}: guard must come before any executable code; " f"found {lines[i]!r}", + f"{script}:{i + 1}: guard must come before any executable code; found {lines[i]!r}", ) else: return GuardCheck(False, f"{script}: canonical guard block for user {user!r} not found") @@ -126,7 +126,7 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: if got != want: return GuardCheck( False, - f"{script}:{line_no}: guard block mismatch; " f"expected {want!r}, got {got!r}", + f"{script}:{line_no}: guard block mismatch; expected {want!r}, got {got!r}", ) return GuardCheck(True) diff --git a/src/fetch_runner/runner.py b/src/fetch_runner/runner.py index 23b2d58..169005a 100644 --- a/src/fetch_runner/runner.py +++ b/src/fetch_runner/runner.py @@ -19,8 +19,12 @@ import subprocess import threading -from fetch_runner.config import Config, Job -from fetch_runner.git_ops import GitError, checkout, current_commit, fetch +from fetch_runner.config import Config +from fetch_runner.config import Job +from fetch_runner.git_ops import GitError +from fetch_runner.git_ops import checkout +from fetch_runner.git_ops import current_commit +from fetch_runner.git_ops import fetch from fetch_runner.guard import validate_script_guard log = logging.getLogger("fetch_runner") diff --git a/tests/test_config.py b/tests/test_config.py index 239920d..b7abe4d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,8 +6,10 @@ import pytest -from fetch_runner.config import ConfigError, load_config -from fetch_runner.guard import current_user, render_guard +from fetch_runner.config import ConfigError +from fetch_runner.config import load_config +from fetch_runner.guard import current_user +from fetch_runner.guard import render_guard @pytest.fixture(autouse=True) diff --git a/tests/test_guard.py b/tests/test_guard.py index 713b9f4..069eeaa 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -6,13 +6,11 @@ import pytest -from fetch_runner.guard import ( - GuardError, - current_user, - render_guard, - require_runtime_user, - validate_script_guard, -) +from fetch_runner.guard import GuardError +from fetch_runner.guard import current_user +from fetch_runner.guard import render_guard +from fetch_runner.guard import require_runtime_user +from fetch_runner.guard import validate_script_guard def test_render_guard_embeds_user_everywhere(): From 03000f9c9cf020f1095e39b4304d5ad51211c80a Mon Sep 17 00:00:00 2001 From: Ben Rencher <80055175+benrencher@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:26:03 -0600 Subject: [PATCH 5/8] Adding commentary on unit file settings --- examples/fetch-runner.service | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/examples/fetch-runner.service b/examples/fetch-runner.service index c0872c0..8d4617a 100644 --- a/examples/fetch-runner.service +++ b/examples/fetch-runner.service @@ -36,6 +36,8 @@ Restart=on-failure RestartSec=10s TimeoutStopSec=30s +# jounral is the deafult for StandardOutput +# the deafult for StandardError is "inherit" which inherit's StandardOutput's value StandardOutput=journal StandardError=journal @@ -46,22 +48,41 @@ CapabilityBoundingSet= AmbientCapabilities= # Filesystem isolation. +# ProtectSystem=strict prevents the service from writing to the filepaths we want +# it to edit. We can add a ReadWritePaths setting to allow the service to write to +# specific paths if we want to keep this at strict. You can also relax the setting +# a little to "full" to get a lot of protection without having to fill out ReadWritePaths ProtectSystem=strict + +# we wont need the service user's home, so we should set ProtectHome to true to +# enhance security ProtectHome=read-only + PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true + +# Containers need access to control groups, so we can try setting this to "private" +# or disable it ProtectControlGroups=true + ProtectClock=true ProtectHostname=true + +# we can change this to "noaccess" for enhanced security since we don't +# need this service to see what other processes are running ProtectProc=invisible # Kernel / namespace hardening. LockPersonality=true RestrictRealtime=true + +# This should be a list of namespaces "true" doesn't make sense as a setting here. +# I recommend we remove this setting RestrictNamespaces=true + RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # Syscall filter. From fcfc0b96be39945dc1aa51d942781cedbb57c4f0 Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Fri, 24 Apr 2026 14:43:50 -0600 Subject: [PATCH 6/8] make names less ambiguous --- examples/fetch-runner.service | 32 ++-- src/fetch_runner/cli.py | 55 ++++--- src/fetch_runner/config.py | 287 +++++++++++++++++++++------------- src/fetch_runner/git_ops.py | 51 ++++-- src/fetch_runner/guard.py | 139 +++++++++------- src/fetch_runner/runner.py | 180 ++++++++++++--------- tests/test_config.py | 247 +++++++++++++++-------------- tests/test_guard.py | 126 ++++++++------- 8 files changed, 647 insertions(+), 470 deletions(-) diff --git a/examples/fetch-runner.service b/examples/fetch-runner.service index 8d4617a..4393399 100644 --- a/examples/fetch-runner.service +++ b/examples/fetch-runner.service @@ -27,17 +27,17 @@ ExecStart=/usr/local/bin/fetch-runner /etc/fetch-runner/jobs.toml ReadWritePaths=/srv # =========================================================================== -# DO NOT MODIFY below this line unless you know exactly why. -# These settings are the security hardening that makes this service safe -# to leave running. Removing any of them weakens the sandbox. +# DO NOT MODIFY below this line casually. +# These settings are the sandbox that keeps a long-running deploy service from +# having broad access to the host. If you must relax one, document why. # =========================================================================== Restart=on-failure RestartSec=10s TimeoutStopSec=30s -# jounral is the deafult for StandardOutput -# the deafult for StandardError is "inherit" which inherit's StandardOutput's value +# Send both fetch-runner logs and deploy-script output to journald so there is +# one place to inspect failures. StandardOutput=journal StandardError=journal @@ -47,15 +47,12 @@ RestrictSUIDSGID=true CapabilityBoundingSet= AmbientCapabilities= -# Filesystem isolation. -# ProtectSystem=strict prevents the service from writing to the filepaths we want -# it to edit. We can add a ReadWritePaths setting to allow the service to write to -# specific paths if we want to keep this at strict. You can also relax the setting -# a little to "full" to get a lot of protection without having to fill out ReadWritePaths +# Keep the filesystem read-only by default, then punch narrow write holes back +# in with `ReadWritePaths=` above for the repos and app state that deployments +# genuinely need to modify. ProtectSystem=strict -# we wont need the service user's home, so we should set ProtectHome to true to -# enhance security +# Deploy scripts should not need the service user's home directory. ProtectHome=read-only PrivateTmp=true @@ -64,23 +61,22 @@ ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true -# Containers need access to control groups, so we can try setting this to "private" -# or disable it +# Prevent deploy code from reconfiguring host control groups. If your rollout +# tool genuinely needs cgroup access, revisit this deliberately. ProtectControlGroups=true ProtectClock=true ProtectHostname=true -# we can change this to "noaccess" for enhanced security since we don't -# need this service to see what other processes are running +# Hide unrelated processes from the service; deploy hooks should not be +# inspecting the rest of the machine. ProtectProc=invisible # Kernel / namespace hardening. LockPersonality=true RestrictRealtime=true -# This should be a list of namespaces "true" doesn't make sense as a setting here. -# I recommend we remove this setting +# Prevent deploy code from creating new namespaces inside the sandbox. RestrictNamespaces=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py index bbd6687..22c8302 100644 --- a/src/fetch_runner/cli.py +++ b/src/fetch_runner/cli.py @@ -10,63 +10,76 @@ from fetch_runner.config import ConfigError from fetch_runner.config import load_config from fetch_runner.guard import GuardError -from fetch_runner.guard import render_guard -from fetch_runner.runner import Runner +from fetch_runner.guard import render_canonical_script_guard +from fetch_runner.runner import GitPollingRunner log = logging.getLogger("fetch_runner") def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser( + argument_parser = argparse.ArgumentParser( prog="fetch-runner", description="Poll git branches and run scripts when new commits arrive.", ) - p.add_argument("config", type=Path, nargs="?", help="path to jobs.toml") - p.add_argument( + argument_parser.add_argument("config", type=Path, nargs="?", help="path to jobs.toml") + argument_parser.add_argument( "--check", action="store_true", help="validate the config (including every script's guard) and exit", ) - p.add_argument( + argument_parser.add_argument( "--print-guard", metavar="USER", help="print the canonical guard block for USER and exit", ) - p.add_argument("-v", "--verbose", action="store_true", help="enable debug logging") - p.add_argument("--version", action="version", version=f"fetch-runner {__version__}") - args = p.parse_args(argv) + argument_parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="enable debug logging", + ) + argument_parser.add_argument( + "--version", + action="version", + version=f"fetch-runner {__version__}", + ) + cli_args = argument_parser.parse_args(argv) - _configure_logging(args.verbose) + _configure_logging(cli_args.verbose) - if args.print_guard: + if cli_args.print_guard: try: - sys.stdout.write(render_guard(args.print_guard)) + sys.stdout.write(render_canonical_script_guard(cli_args.print_guard)) except GuardError as e: print(f"error: {e}", file=sys.stderr) return 2 return 0 - if args.config is None: - p.error("config path is required (or use --print-guard)") + if cli_args.config is None: + argument_parser.error("config path is required (or use --print-guard)") try: - cfg = load_config(args.config) + runner_config = load_config(cli_args.config) except ConfigError as e: print(f"config error: {e}", file=sys.stderr) return 2 - if args.check: - print(f"ok: user={cfg.user} jobs={len(cfg.jobs)} poll={cfg.poll_interval_seconds}s") + if cli_args.check: + print( + f"ok: user={runner_config.runtime_user} " + f"jobs={len(runner_config.jobs)} " + f"poll={runner_config.poll_interval_seconds}s" + ) return 0 - runner = Runner(cfg) + runner = GitPollingRunner(runner_config) - def _stop(signum, _frame): + def _handle_stop_signal(signum, _frame): log.info("received signal %s; shutting down", signum) runner.request_stop() - signal.signal(signal.SIGTERM, _stop) - signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _handle_stop_signal) + signal.signal(signal.SIGINT, _handle_stop_signal) return runner.run_forever() diff --git a/src/fetch_runner/config.py b/src/fetch_runner/config.py index 5de1690..80f35ac 100644 --- a/src/fetch_runner/config.py +++ b/src/fetch_runner/config.py @@ -15,8 +15,8 @@ from pathlib import Path from fetch_runner.guard import GuardError -from fetch_runner.guard import require_runtime_user -from fetch_runner.guard import validate_script_guard +from fetch_runner.guard import require_expected_runtime_user +from fetch_runner.guard import validate_canonical_script_guard class ConfigError(Exception): @@ -24,138 +24,209 @@ class ConfigError(Exception): @dataclass(frozen=True) -class Job: +class ConfiguredJob: name: str - path: Path - branch: str - script: Path - timeout_seconds: int | None + repo_path: Path + branch_name: str + script_path: Path + script_timeout_seconds: int | None @dataclass(frozen=True) -class Config: - user: str +class RunnerConfig: + runtime_user: str poll_interval_seconds: int - jobs: tuple[Job, ...] + jobs: tuple[ConfiguredJob, ...] -_ALLOWED_TOP = {"general", "jobs"} -_ALLOWED_GENERAL = {"user", "poll_interval_seconds"} -_ALLOWED_JOB = {"name", "path", "branch", "script", "timeout_seconds"} +_ALLOWED_TOP_LEVEL_KEYS = {"general", "jobs"} +_ALLOWED_GENERAL_KEYS = {"user", "poll_interval_seconds"} +_ALLOWED_JOB_KEYS = {"name", "path", "branch", "script", "timeout_seconds"} -_UNSAFE_BRANCH_CHARS = frozenset(" \t\n\r\x00'\";|&`$<>()[]{}\\*?") +_DISALLOWED_BRANCH_CHARACTERS = frozenset(" \t\n\r\x00'\";|&`$<>()[]{}\\*?") -def load_config(path: Path) -> Config: +def load_config(config_path: Path) -> RunnerConfig: try: - raw_text = path.read_text() + config_text = config_path.read_text() except OSError as e: - raise ConfigError(f"cannot read {path}: {e}") from e + raise ConfigError(f"cannot read {config_path}: {e}") from e try: - raw = tomllib.loads(raw_text) + parsed_toml = tomllib.loads(config_text) except tomllib.TOMLDecodeError as e: - raise ConfigError(f"invalid TOML in {path}: {e}") from e - - _reject_unknown(raw, _ALLOWED_TOP, f"{path}: top-level") - - general = raw.get("general") - if not isinstance(general, dict): - raise ConfigError(f"{path}: missing [general] section") - _reject_unknown(general, _ALLOWED_GENERAL, f"{path}: [general]") - - user = _require_str(general, "user", "[general]", path) - poll_interval = _require_int(general, "poll_interval_seconds", "[general]", path, minimum=1) + raise ConfigError(f"invalid TOML in {config_path}: {e}") from e + + _reject_unknown_keys(parsed_toml, _ALLOWED_TOP_LEVEL_KEYS, f"{config_path}: top-level") + + general_section = parsed_toml.get("general") + if not isinstance(general_section, dict): + raise ConfigError(f"{config_path}: missing [general] section") + _reject_unknown_keys(general_section, _ALLOWED_GENERAL_KEYS, f"{config_path}: [general]") + + runtime_user = _require_non_empty_string( + general_section, + "user", + "[general]", + config_path, + ) + poll_interval_seconds = _require_integer_at_least( + general_section, + "poll_interval_seconds", + "[general]", + config_path, + minimum=1, + ) # Enforce user match before doing anything else: an operator who dropped # a jobs.toml for the wrong service account should see an immediate # error, not have individual jobs quietly skipped. try: - require_runtime_user(user) + require_expected_runtime_user(runtime_user) except GuardError as e: - raise ConfigError(f"{path}: {e}") from e + raise ConfigError(f"{config_path}: {e}") from e - raw_jobs = raw.get("jobs") - if not isinstance(raw_jobs, list) or not raw_jobs: - raise ConfigError(f"{path}: at least one [[jobs]] entry is required") + raw_job_sections = parsed_toml.get("jobs") + if not isinstance(raw_job_sections, list) or not raw_job_sections: + raise ConfigError(f"{config_path}: at least one [[jobs]] entry is required") seen_names: set[str] = set() - seen_paths: set[Path] = set() - jobs: list[Job] = [] - for i, entry in enumerate(raw_jobs): - section = f"[[jobs]] #{i}" - if not isinstance(entry, dict): - raise ConfigError(f"{path}: {section} is not a table") - _reject_unknown(entry, _ALLOWED_JOB, f"{path}: {section}") - - name = _require_str(entry, "name", section, path) - if name in seen_names: - raise ConfigError(f"{path}: duplicate job name {name!r}") - seen_names.add(name) - - repo_path = Path(_require_str(entry, "path", section, path)).resolve() - if repo_path in seen_paths: - raise ConfigError(f"{path}: duplicate job path {repo_path}") - seen_paths.add(repo_path) + seen_repo_paths: set[Path] = set() + configured_jobs: list[ConfiguredJob] = [] + for job_index, raw_job_section in enumerate(raw_job_sections): + section_label = f"[[jobs]] #{job_index}" + if not isinstance(raw_job_section, dict): + raise ConfigError(f"{config_path}: {section_label} is not a table") + _reject_unknown_keys(raw_job_section, _ALLOWED_JOB_KEYS, f"{config_path}: {section_label}") + + job_name = _require_non_empty_string(raw_job_section, "name", section_label, config_path) + if job_name in seen_names: + raise ConfigError(f"{config_path}: duplicate job name {job_name!r}") + seen_names.add(job_name) + + # Resolve early so duplicate-path detection is based on the real target + # path, not on whatever relative spelling happened to appear in TOML. + repo_path = Path( + _require_non_empty_string(raw_job_section, "path", section_label, config_path) + ).resolve() + # Only one job may own a worktree. Two jobs resetting the same checkout + # to different commits would create non-deterministic deploy behavior. + if repo_path in seen_repo_paths: + raise ConfigError(f"{config_path}: duplicate job path {repo_path}") + seen_repo_paths.add(repo_path) if not (repo_path / ".git").exists(): - raise ConfigError(f"{path}: {section}.path {repo_path} is not a git repository") - - branch = _require_str(entry, "branch", section, path) - if branch.startswith("-") or any(c in _UNSAFE_BRANCH_CHARS for c in branch): - raise ConfigError(f"{path}: {section}.branch contains unsafe characters: {branch!r}") - if len(branch) > 128: - raise ConfigError(f"{path}: {section}.branch too long") - - script_path = Path(_require_str(entry, "script", section, path)).resolve() - _validate_script_file(script_path, user, section, path) - - timeout = entry.get("timeout_seconds") - if timeout is not None: - if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0: - raise ConfigError(f"{path}: {section}.timeout_seconds must be a positive integer") - - jobs.append( - Job( - name=name, - path=repo_path, - branch=branch, - script=script_path, - timeout_seconds=timeout, + raise ConfigError( + f"{config_path}: {section_label}.path {repo_path} is not a git repository" ) - ) - - return Config(user=user, poll_interval_seconds=poll_interval, jobs=tuple(jobs)) - - -def _validate_script_file(script: Path, user: str, section: str, cfg_path: Path) -> None: - if not script.is_file(): - raise ConfigError(f"{cfg_path}: {section}.script {script} does not exist") - if not os.access(script, os.X_OK): - raise ConfigError(f"{cfg_path}: {section}.script {script} is not executable") - st = script.stat() - if st.st_mode & stat.S_IWOTH: - raise ConfigError(f"{cfg_path}: {section}.script {script} is world-writable; refusing") - check = validate_script_guard(script, user) - if not check.ok: - raise ConfigError(f"{cfg_path}: {section}.script failed guard validation: {check.reason}") - - -def _reject_unknown(d: dict, allowed: set[str], where: str) -> None: - extra = set(d) - allowed - if extra: - raise ConfigError(f"{where}: unknown keys {sorted(extra)!r}") + branch_name = _require_non_empty_string( + raw_job_section, + "branch", + section_label, + config_path, + ) + # Branch names are passed as argv entries, but git still interprets + # leading dashes and a wide range of refname syntax. A conservative + # character filter keeps the allowed surface area easy to reason about. + if branch_name.startswith("-") or any( + char in _DISALLOWED_BRANCH_CHARACTERS for char in branch_name + ): + raise ConfigError( + f"{config_path}: {section_label}.branch contains unsafe characters: {branch_name!r}" + ) + if len(branch_name) > 128: + raise ConfigError(f"{config_path}: {section_label}.branch too long") + + script_path = Path( + _require_non_empty_string(raw_job_section, "script", section_label, config_path) + ).resolve() + _validate_job_script_file(script_path, runtime_user, section_label, config_path) + + script_timeout_seconds = raw_job_section.get("timeout_seconds") + if script_timeout_seconds is not None: + if ( + not isinstance(script_timeout_seconds, int) + or isinstance(script_timeout_seconds, bool) + or script_timeout_seconds <= 0 + ): + raise ConfigError( + f"{config_path}: {section_label}.timeout_seconds must be a positive integer" + ) + + configured_jobs.append( + ConfiguredJob( + name=job_name, + repo_path=repo_path, + branch_name=branch_name, + script_path=script_path, + script_timeout_seconds=script_timeout_seconds, + ) + ) -def _require_str(d: dict, key: str, section: str, cfg_path: Path) -> str: - v = d.get(key) - if not isinstance(v, str) or not v: - raise ConfigError(f"{cfg_path}: {section}.{key} must be a non-empty string") - return v + return RunnerConfig( + runtime_user=runtime_user, + poll_interval_seconds=poll_interval_seconds, + jobs=tuple(configured_jobs), + ) + + +def _validate_job_script_file( + script_path: Path, + runtime_user: str, + section_label: str, + config_path: Path, +) -> None: + """Run the startup-time script checks. + + This is intentionally duplicated later in the runner after checkout. The + load-time check catches bad deployments before the service starts; the + post-checkout check catches a newly fetched commit that changed the script. + """ + if not script_path.is_file(): + raise ConfigError(f"{config_path}: {section_label}.script {script_path} does not exist") + if not os.access(script_path, os.X_OK): + raise ConfigError(f"{config_path}: {section_label}.script {script_path} is not executable") + script_stat = script_path.stat() + if script_stat.st_mode & stat.S_IWOTH: + raise ConfigError( + f"{config_path}: {section_label}.script {script_path} is world-writable; refusing" + ) + guard_validation = validate_canonical_script_guard(script_path, runtime_user) + if not guard_validation.is_valid: + raise ConfigError( + f"{config_path}: {section_label}.script failed guard validation: " + f"{guard_validation.error_reason}" + ) -def _require_int(d: dict, key: str, section: str, cfg_path: Path, *, minimum: int) -> int: - v = d.get(key) - if not isinstance(v, int) or isinstance(v, bool): - raise ConfigError(f"{cfg_path}: {section}.{key} must be an integer") - if v < minimum: - raise ConfigError(f"{cfg_path}: {section}.{key} must be >= {minimum}") - return v +def _reject_unknown_keys(raw_section: dict, allowed_keys: set[str], section_label: str) -> None: + unknown_keys = set(raw_section) - allowed_keys + if unknown_keys: + raise ConfigError(f"{section_label}: unknown keys {sorted(unknown_keys)!r}") + + +def _require_non_empty_string( + raw_section: dict, + key: str, + section_label: str, + config_path: Path, +) -> str: + value = raw_section.get(key) + if not isinstance(value, str) or not value: + raise ConfigError(f"{config_path}: {section_label}.{key} must be a non-empty string") + return value + + +def _require_integer_at_least( + raw_section: dict, + key: str, + section_label: str, + config_path: Path, + *, + minimum: int, +) -> int: + value = raw_section.get(key) + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigError(f"{config_path}: {section_label}.{key} must be an integer") + if value < minimum: + raise ConfigError(f"{config_path}: {section_label}.{key} must be >= {minimum}") + return value diff --git a/src/fetch_runner/git_ops.py b/src/fetch_runner/git_ops.py index 98ff3cd..b5c835a 100644 --- a/src/fetch_runner/git_ops.py +++ b/src/fetch_runner/git_ops.py @@ -1,8 +1,9 @@ """Minimal ``git`` wrappers. +The runner intentionally uses a very small, audited subset of ``git``. Subprocess calls use an argv list (never ``shell=True``); repo paths are -passed with ``-C`` and branch names have already been validated against an -unsafe-character set at config load. +passed with ``-C`` and branch names have already been validated against a +conservative allowlist at config load. """ from __future__ import annotations @@ -15,10 +16,10 @@ class GitError(Exception): pass -def _run(repo: Path, *args: str, timeout: float = 120) -> str: +def _run_git_command(repo_path: Path, *git_args: str, timeout: float = 120) -> str: try: result = subprocess.run( - ["git", "-C", str(repo), *args], + ["git", "-C", str(repo_path), *git_args], capture_output=True, text=True, timeout=timeout, @@ -27,27 +28,43 @@ def _run(repo: Path, *args: str, timeout: float = 120) -> str: except FileNotFoundError as e: raise GitError("git executable not found in PATH") from e except subprocess.TimeoutExpired as e: - raise GitError(f"git {' '.join(args)} in {repo} timed out after {timeout}s") from e + raise GitError(f"git {' '.join(git_args)} in {repo_path} timed out after {timeout}s") from e if result.returncode != 0: - msg = (result.stderr or result.stdout).strip() - raise GitError(f"git {' '.join(args)} in {repo} failed: {msg}") + error_output = (result.stderr or result.stdout).strip() + raise GitError(f"git {' '.join(git_args)} in {repo_path} failed: {error_output}") return result.stdout.strip() -def current_commit(repo: Path, branch: str) -> str: - """Return the commit the local ``branch`` ref points at, or '' if it does not exist.""" +def git_get_local_branch_commit_sha(repo_path: Path, branch_name: str) -> str: + """Return the commit the local ``branch_name`` ref points at, or ``""`` if missing.""" try: - return _run(repo, "rev-parse", "--verify", f"refs/heads/{branch}") + return _run_git_command(repo_path, "rev-parse", "--verify", f"refs/heads/{branch_name}") except GitError: return "" -def fetch(repo: Path, branch: str, timeout: float = 120) -> str: - """Fetch ``origin/`` and return the fetched commit SHA.""" - _run(repo, "fetch", "--quiet", "--no-tags", "origin", branch, timeout=timeout) - return _run(repo, "rev-parse", "FETCH_HEAD") +def git_fetch_branch_from_origin( + repo_path: Path, + branch_name: str, + timeout: float = 120, +) -> str: + """Fetch ``origin/`` and return the resulting ``FETCH_HEAD`` SHA.""" + _run_git_command( + repo_path, + "fetch", + "--quiet", + "--no-tags", + "origin", + branch_name, + timeout=timeout, + ) + return _run_git_command(repo_path, "rev-parse", "FETCH_HEAD") -def checkout(repo: Path, branch: str, commit: str) -> None: - """Force the local ``branch`` to ``commit`` and reset the working tree.""" - _run(repo, "checkout", "--quiet", "--force", "-B", branch, commit) +def git_force_checkout_branch_to_commit( + repo_path: Path, + branch_name: str, + commit_sha: str, +) -> None: + """Force the local ``branch_name`` ref and working tree to ``commit_sha``.""" + _run_git_command(repo_path, "checkout", "--quiet", "--force", "-B", branch_name, commit_sha) diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py index 6e65a6b..2c34185 100644 --- a/src/fetch_runner/guard.py +++ b/src/fetch_runner/guard.py @@ -1,4 +1,4 @@ -"""User-check enforcement. +"""Runtime-user and script-guard enforcement. Two jobs: @@ -22,9 +22,12 @@ from dataclasses import dataclass from pathlib import Path -GUARD_BEGIN_PREFIX = "# >>> fetch-runner-guard:BEGIN" -GUARD_END = "# <<< fetch-runner-guard:END" +GUARD_BEGIN_MARKER_PREFIX = "# >>> fetch-runner-guard:BEGIN" +GUARD_END_MARKER = "# <<< fetch-runner-guard:END" +# Keep the guard as a literal byte template. The validator compares a script's +# bytes against the rendered output exactly, so "helpful" rewrites do not +# silently weaken the check. _GUARD_TEMPLATE = ( "# >>> fetch-runner-guard:BEGIN user={user}\n" 'if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then\n' @@ -32,7 +35,7 @@ ' required: {user}, non-root\\n\' "$(whoami)" "$(id -u)" >&2\n' " exit 1\n" "fi\n" - "# <<< fetch-runner-guard:END\n" + f"{GUARD_END_MARKER}\n" ) @@ -41,94 +44,107 @@ class GuardError(Exception): @dataclass(frozen=True) -class GuardCheck: - ok: bool - reason: str = "" +class ScriptGuardValidation: + is_valid: bool + error_reason: str = "" -def render_guard(user: str) -> str: +def render_canonical_script_guard(user_name: str) -> str: """Return the canonical guard block for ``user`` (trailing newline included).""" - _assert_safe_user(user) - return _GUARD_TEMPLATE.format(user=user) + _require_safe_user_name(user_name) + return _GUARD_TEMPLATE.format(user=user_name) -def current_user() -> str: +def get_current_real_uid_user_name() -> str: """Return the login name of the real UID. Uses ``pwd.getpwuid(os.getuid())`` rather than ``$USER`` or ``whoami`` so the answer cannot be spoofed via environment variables or utmp. """ - uid = os.getuid() + real_uid = os.getuid() try: - return pwd.getpwuid(uid).pw_name + return pwd.getpwuid(real_uid).pw_name except KeyError as e: - raise GuardError(f"cannot resolve current uid {uid} to a user name") from e + raise GuardError(f"cannot resolve current uid {real_uid} to a user name") from e -def require_runtime_user(expected: str) -> None: +def require_expected_runtime_user(expected_user_name: str) -> None: """Raise ``GuardError`` unless the process is running as ``expected`` and non-root.""" - _assert_safe_user(expected) - actual = current_user() - if actual != expected: - raise GuardError(f"runtime user {actual!r} does not match configured user {expected!r}") + _require_safe_user_name(expected_user_name) + actual_user_name = get_current_real_uid_user_name() + if actual_user_name != expected_user_name: + raise GuardError( + f"runtime user {actual_user_name!r} does not match configured user " + f"{expected_user_name!r}" + ) if os.getuid() == 0: raise GuardError("refusing to run as root (uid 0)") -def validate_script_guard(script: Path, user: str) -> GuardCheck: +def validate_canonical_script_guard( + script_path: Path, + expected_user_name: str, +) -> ScriptGuardValidation: """Check that ``script`` begins with the canonical guard for ``user``. Allowed before the guard: an optional shebang on line 1, blank lines, and ``#``-comment lines. Anything else (including ``set -e``) is rejected: the guard must be the first code that runs. """ - _assert_safe_user(user) + _require_safe_user_name(expected_user_name) try: - text = script.read_text() + script_text = script_path.read_text() except OSError as e: - return GuardCheck(False, f"cannot read script {script}: {e}") + return ScriptGuardValidation(False, f"cannot read script {script_path}: {e}") - lines = text.splitlines() - i = 0 - if lines and lines[0].startswith("#!"): - i = 1 + script_lines = script_text.splitlines() + current_line_index = 0 + if script_lines and script_lines[0].startswith("#!"): + current_line_index = 1 - expected_begin = f"# >>> fetch-runner-guard:BEGIN user={user}" - while i < len(lines): - stripped = lines[i].strip() - if stripped == expected_begin: + expected_guard_begin_marker = f"# >>> fetch-runner-guard:BEGIN user={expected_user_name}" + while current_line_index < len(script_lines): + stripped_line = script_lines[current_line_index].strip() + if stripped_line == expected_guard_begin_marker: break - if stripped.startswith(GUARD_BEGIN_PREFIX): - return GuardCheck( + if stripped_line.startswith(GUARD_BEGIN_MARKER_PREFIX): + return ScriptGuardValidation( False, - f"{script}:{i + 1}: guard BEGIN marker targets a different user " - f"(expected {user!r}); found {stripped!r}", + f"{script_path}:{current_line_index + 1}: guard BEGIN marker targets a different " + f"user (expected {expected_user_name!r}); found {stripped_line!r}", ) - if stripped == "" or stripped.startswith("#"): - i += 1 + # Allow only comments before the guard. Even benign shell code like + # `set -e` can change behavior before the identity check runs. + if stripped_line == "" or stripped_line.startswith("#"): + current_line_index += 1 continue - return GuardCheck( + return ScriptGuardValidation( False, - f"{script}:{i + 1}: guard must come before any executable code; found {lines[i]!r}", + f"{script_path}:{current_line_index + 1}: guard must come before any executable " + f"code; found {script_lines[current_line_index]!r}", ) else: - return GuardCheck(False, f"{script}: canonical guard block for user {user!r} not found") + return ScriptGuardValidation( + False, + f"{script_path}: canonical guard block for user {expected_user_name!r} not found", + ) - expected = render_guard(user).splitlines() - for k, want in enumerate(expected): - line_no = i + k + 1 - if i + k >= len(lines): - return GuardCheck( + expected_guard_lines = render_canonical_script_guard(expected_user_name).splitlines() + for guard_line_offset, expected_line in enumerate(expected_guard_lines): + line_number = current_line_index + guard_line_offset + 1 + if current_line_index + guard_line_offset >= len(script_lines): + return ScriptGuardValidation( False, - f"{script}:{line_no}: guard block truncated; expected {want!r}", + f"{script_path}:{line_number}: guard block truncated; expected {expected_line!r}", ) - got = lines[i + k] - if got != want: - return GuardCheck( + actual_line = script_lines[current_line_index + guard_line_offset] + if actual_line != expected_line: + return ScriptGuardValidation( False, - f"{script}:{line_no}: guard block mismatch; expected {want!r}, got {got!r}", + f"{script_path}:{line_number}: guard block mismatch; " + f"expected {expected_line!r}, got {actual_line!r}", ) - return GuardCheck(True) + return ScriptGuardValidation(True) # Conservative allowlist; keeps us safe if a user name is ever interpolated @@ -137,13 +153,16 @@ def validate_script_guard(script: Path, user: str) -> GuardCheck: _ALLOWED_USER_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") -def _assert_safe_user(user: str) -> None: - if not isinstance(user, str) or not user: +def _require_safe_user_name(user_name: str) -> None: + if not isinstance(user_name, str) or not user_name: raise GuardError("user name must be a non-empty string") - if len(user) > 32: - raise GuardError(f"user name too long: {user!r}") - if user.startswith("-"): - raise GuardError(f"user name may not start with '-': {user!r}") - bad = [c for c in user if c not in _ALLOWED_USER_CHARS] - if bad: - raise GuardError(f"user name contains disallowed characters {sorted(set(bad))!r}: {user!r}") + if len(user_name) > 32: + raise GuardError(f"user name too long: {user_name!r}") + if user_name.startswith("-"): + raise GuardError(f"user name may not start with '-': {user_name!r}") + disallowed_characters = [char for char in user_name if char not in _ALLOWED_USER_CHARS] + if disallowed_characters: + raise GuardError( + f"user name contains disallowed characters {sorted(set(disallowed_characters))!r}: " + f"{user_name!r}" + ) diff --git a/src/fetch_runner/runner.py b/src/fetch_runner/runner.py index 169005a..3b136ae 100644 --- a/src/fetch_runner/runner.py +++ b/src/fetch_runner/runner.py @@ -19,125 +19,165 @@ import subprocess import threading -from fetch_runner.config import Config -from fetch_runner.config import Job +from fetch_runner.config import ConfiguredJob +from fetch_runner.config import RunnerConfig from fetch_runner.git_ops import GitError -from fetch_runner.git_ops import checkout -from fetch_runner.git_ops import current_commit -from fetch_runner.git_ops import fetch -from fetch_runner.guard import validate_script_guard +from fetch_runner.git_ops import git_fetch_branch_from_origin +from fetch_runner.git_ops import git_force_checkout_branch_to_commit +from fetch_runner.git_ops import git_get_local_branch_commit_sha +from fetch_runner.guard import validate_canonical_script_guard log = logging.getLogger("fetch_runner") -class Runner: - def __init__(self, cfg: Config) -> None: - self.cfg = cfg - self._last_commit: dict[str, str] = {} - self._stop = threading.Event() +class GitPollingRunner: + def __init__(self, runner_config: RunnerConfig) -> None: + self.runner_config = runner_config + self._last_processed_commit_by_job_name: dict[str, str] = {} + self._stop_requested = threading.Event() def request_stop(self) -> None: - self._stop.set() + self._stop_requested.set() def run_forever(self) -> int: - self._initialize_cursors() + self._initialize_last_processed_commits() log.info( "fetch-runner started: user=%s jobs=%d poll=%ss", - self.cfg.user, - len(self.cfg.jobs), - self.cfg.poll_interval_seconds, + self.runner_config.runtime_user, + len(self.runner_config.jobs), + self.runner_config.poll_interval_seconds, ) - while not self._stop.is_set(): - for job in self.cfg.jobs: - if self._stop.is_set(): + while not self._stop_requested.is_set(): + for configured_job in self.runner_config.jobs: + if self._stop_requested.is_set(): break try: - self._poll(job) + self._poll_job_for_new_commit(configured_job) except Exception: - log.exception("job %s: unexpected error", job.name) - self._stop.wait(self.cfg.poll_interval_seconds) + log.exception("job %s: unexpected error", configured_job.name) + self._stop_requested.wait(self.runner_config.poll_interval_seconds) log.info("fetch-runner stopped") return 0 - def _initialize_cursors(self) -> None: - for job in self.cfg.jobs: + def _initialize_last_processed_commits(self) -> None: + for configured_job in self.runner_config.jobs: try: - sha = current_commit(job.path, job.branch) + # Seed each job from the current local branch tip so a service + # restart does not replay the last successfully fetched commit. + initial_commit_sha = git_get_local_branch_commit_sha( + configured_job.repo_path, + configured_job.branch_name, + ) except GitError as e: log.warning( "job %s: cannot read initial commit for %s: %s", - job.name, - job.branch, + configured_job.name, + configured_job.branch_name, e, ) - sha = "" - self._last_commit[job.name] = sha - log.info("job %s: initial commit %s", job.name, _short(sha)) + initial_commit_sha = "" + self._last_processed_commit_by_job_name[configured_job.name] = initial_commit_sha + log.info( + "job %s: initial commit %s", + configured_job.name, + _short_commit_sha(initial_commit_sha), + ) - def _poll(self, job: Job) -> None: + def _poll_job_for_new_commit(self, configured_job: ConfiguredJob) -> None: try: - remote = fetch(job.path, job.branch) + fetched_commit_sha = git_fetch_branch_from_origin( + configured_job.repo_path, + configured_job.branch_name, + ) except GitError as e: - log.warning("job %s: fetch failed: %s", job.name, e) + log.warning("job %s: fetch failed: %s", configured_job.name, e) return - last = self._last_commit.get(job.name, "") - if remote == last: - log.debug("job %s: no change (%s)", job.name, _short(remote)) + last_processed_commit_sha = self._last_processed_commit_by_job_name.get( + configured_job.name, + "", + ) + if fetched_commit_sha == last_processed_commit_sha: + log.debug( + "job %s: no change (%s)", + configured_job.name, + _short_commit_sha(fetched_commit_sha), + ) return log.info( "job %s: new commit %s -> %s", - job.name, - _short(last) or "", - _short(remote), + configured_job.name, + _short_commit_sha(last_processed_commit_sha) or "", + _short_commit_sha(fetched_commit_sha), ) try: - checkout(job.path, job.branch, remote) + git_force_checkout_branch_to_commit( + configured_job.repo_path, + configured_job.branch_name, + fetched_commit_sha, + ) except GitError as e: - log.error("job %s: checkout failed: %s", job.name, e) + log.error("job %s: checkout failed: %s", configured_job.name, e) return - # Re-validate the guard after checkout: the incoming commit could - # have removed or weakened it. - check = validate_script_guard(job.script, self.cfg.user) - if not check.ok: + # Re-validate after checkout because the fetched commit controls the + # script bytes on disk. A config-time pass only proves the *previous* + # checkout was safe. + guard_validation = validate_canonical_script_guard( + configured_job.script_path, + self.runner_config.runtime_user, + ) + if not guard_validation.is_valid: log.error( "job %s: script at %s failed guard check after checkout: %s", - job.name, - remote, - check.reason, + configured_job.name, + fetched_commit_sha, + guard_validation.error_reason, ) - self._last_commit[job.name] = remote + # Record the bad commit so the service does not hammer the same + # broken revision forever. Recovery should be an intentional human + # action, not an automatic tight loop. + self._last_processed_commit_by_job_name[configured_job.name] = fetched_commit_sha return - self._run_script(job, remote) - self._last_commit[job.name] = remote + self._run_job_script_for_commit(configured_job, fetched_commit_sha) + self._last_processed_commit_by_job_name[configured_job.name] = fetched_commit_sha - def _run_script(self, job: Job, commit: str) -> None: - env = { + def _run_job_script_for_commit( + self, + configured_job: ConfiguredJob, + commit_sha: str, + ) -> None: + # Export execution context so scripts can log or branch on it without + # having to re-run git commands against the working tree. + script_environment = { **os.environ, - "FETCH_RUNNER_JOB": job.name, - "FETCH_RUNNER_BRANCH": job.branch, - "FETCH_RUNNER_COMMIT": commit, - "FETCH_RUNNER_REPO": str(job.path), + "FETCH_RUNNER_JOB": configured_job.name, + "FETCH_RUNNER_BRANCH": configured_job.branch_name, + "FETCH_RUNNER_COMMIT": commit_sha, + "FETCH_RUNNER_REPO": str(configured_job.repo_path), } - log.info("job %s: running %s", job.name, job.script) + log.info("job %s: running %s", configured_job.name, configured_job.script_path) try: - proc = subprocess.run( - [str(job.script)], - cwd=job.path, - env=env, - timeout=job.timeout_seconds, + completed_process = subprocess.run( + [str(configured_job.script_path)], + cwd=configured_job.repo_path, + env=script_environment, + timeout=configured_job.script_timeout_seconds, check=False, ) except subprocess.TimeoutExpired: - log.error("job %s: script timed out after %ss", job.name, job.timeout_seconds) + log.error( + "job %s: script timed out after %ss", + configured_job.name, + configured_job.script_timeout_seconds, + ) return except OSError as e: - log.error("job %s: cannot execute script: %s", job.name, e) + log.error("job %s: cannot execute script: %s", configured_job.name, e) return - if proc.returncode == 0: - log.info("job %s: script succeeded", job.name) + if completed_process.returncode == 0: + log.info("job %s: script succeeded", configured_job.name) else: - log.error("job %s: script exited %d", job.name, proc.returncode) + log.error("job %s: script exited %d", configured_job.name, completed_process.returncode) -def _short(sha: str) -> str: - return sha[:12] +def _short_commit_sha(commit_sha: str) -> str: + return commit_sha[:12] diff --git a/tests/test_config.py b/tests/test_config.py index b7abe4d..9dde878 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,8 +8,8 @@ from fetch_runner.config import ConfigError from fetch_runner.config import load_config -from fetch_runner.guard import current_user -from fetch_runner.guard import render_guard +from fetch_runner.guard import get_current_real_uid_user_name +from fetch_runner.guard import render_canonical_script_guard @pytest.fixture(autouse=True) @@ -18,60 +18,65 @@ def _not_running_as_root(): pytest.skip("config tests require a non-root test user") -def _make_repo(path: Path) -> Path: - path.mkdir(parents=True, exist_ok=True) - (path / ".git").mkdir() - return path +def _create_repo_directory(repo_path: Path) -> Path: + repo_path.mkdir(parents=True, exist_ok=True) + (repo_path / ".git").mkdir() + return repo_path -def _make_script(path: Path, user: str) -> Path: - path.write_text("#!/bin/bash\n" + render_guard(user) + "echo hi\n") - path.chmod(0o755) - return path +def _create_guarded_script(script_path: Path, user_name: str) -> Path: + script_path.write_text("#!/bin/bash\n" + render_canonical_script_guard(user_name) + "echo hi\n") + script_path.chmod(0o755) + return script_path -def _write_cfg(path: Path, body: str) -> Path: - path.write_text(body) - return path +def _write_jobs_toml(config_path: Path, config_body: str) -> Path: + config_path.write_text(config_body) + return config_path -def _base_cfg(tmp_path: Path, *, user: str, extra_job_lines: str = "") -> Path: - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - return _write_cfg( +def _write_minimal_valid_jobs_toml( + tmp_path: Path, + *, + user_name: str, + extra_job_lines: str = "", +) -> Path: + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + return _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 30 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" {extra_job_lines} """, ) def test_load_happy_path(tmp_path: Path): - user = current_user() - cfg_path = _base_cfg(tmp_path, user=user) - cfg = load_config(cfg_path) - assert cfg.user == user - assert cfg.poll_interval_seconds == 30 - assert len(cfg.jobs) == 1 - assert cfg.jobs[0].name == "j1" - assert cfg.jobs[0].branch == "main" - assert cfg.jobs[0].timeout_seconds is None + user_name = get_current_real_uid_user_name() + config_path = _write_minimal_valid_jobs_toml(tmp_path, user_name=user_name) + runner_config = load_config(config_path) + assert runner_config.runtime_user == user_name + assert runner_config.poll_interval_seconds == 30 + assert len(runner_config.jobs) == 1 + assert runner_config.jobs[0].name == "j1" + assert runner_config.jobs[0].branch_name == "main" + assert runner_config.jobs[0].script_timeout_seconds is None def test_load_rejects_wrong_user(tmp_path: Path): # A jobs.toml whose user does not match the running user must be refused. - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", "someone-else-xyz") - cfg_path = _write_cfg( + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", "someone-else-xyz") + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] @@ -80,24 +85,24 @@ def test_load_rejects_wrong_user(tmp_path: Path): [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="runtime user"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_unknown_top_level_key(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [extras] @@ -105,224 +110,228 @@ def test_load_rejects_unknown_top_level_key(tmp_path: Path): [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="unknown keys"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_unknown_job_key(tmp_path: Path): - cfg_path = _base_cfg(tmp_path, user=current_user(), extra_job_lines='command = "rm -rf /"') + config_path = _write_minimal_valid_jobs_toml( + tmp_path, + user_name=get_current_real_uid_user_name(), + extra_job_lines='command = "rm -rf /"', + ) with pytest.raises(ConfigError, match="unknown keys"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_missing_script(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" script = "{tmp_path}/nope.sh" """, ) with pytest.raises(ConfigError, match="does not exist"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_non_git_path(tmp_path: Path): - user = current_user() - script = _make_script(tmp_path / "deploy.sh", user) + user_name = get_current_real_uid_user_name() + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) not_a_repo = tmp_path / "not_a_repo" not_a_repo.mkdir() - cfg_path = _write_cfg( + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" path = "{not_a_repo}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="not a git repository"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_world_writable_script(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - script.chmod(script.stat().st_mode | stat.S_IWOTH) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + script_path.chmod(script_path.stat().st_mode | stat.S_IWOTH) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="world-writable"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_non_executable_script(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - script.chmod(0o644) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + script_path.chmod(0o644) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="not executable"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_script_without_guard(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = tmp_path / "deploy.sh" - script.write_text("#!/bin/bash\necho hi\n") - script.chmod(0o755) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = tmp_path / "deploy.sh" + script_path.write_text("#!/bin/bash\necho hi\n") + script_path.chmod(0o755) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="guard"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_duplicate_path(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "a" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" [[jobs]] name = "b" -path = "{repo}" +path = "{repo_path}" branch = "other" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="duplicate job path"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_unsafe_branch(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main; rm -rf /" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="unsafe characters"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_leading_dash_branch(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 5 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "--upload-pack=evil" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="unsafe characters"): - load_config(cfg_path) + load_config(config_path) def test_load_rejects_zero_poll_interval(tmp_path: Path): - user = current_user() - repo = _make_repo(tmp_path / "repo") - script = _make_script(tmp_path / "deploy.sh", user) - cfg_path = _write_cfg( + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( tmp_path / "jobs.toml", f""" [general] -user = "{user}" +user = "{user_name}" poll_interval_seconds = 0 [[jobs]] name = "j1" -path = "{repo}" +path = "{repo_path}" branch = "main" -script = "{script}" +script = "{script_path}" """, ) with pytest.raises(ConfigError, match="poll_interval_seconds"): - load_config(cfg_path) + load_config(config_path) diff --git a/tests/test_guard.py b/tests/test_guard.py index 069eeaa..1eb0bd6 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -7,18 +7,18 @@ import pytest from fetch_runner.guard import GuardError -from fetch_runner.guard import current_user -from fetch_runner.guard import render_guard -from fetch_runner.guard import require_runtime_user -from fetch_runner.guard import validate_script_guard +from fetch_runner.guard import get_current_real_uid_user_name +from fetch_runner.guard import render_canonical_script_guard +from fetch_runner.guard import require_expected_runtime_user +from fetch_runner.guard import validate_canonical_script_guard def test_render_guard_embeds_user_everywhere(): - g = render_guard("deploy") - assert "user=deploy" in g - assert '"$(whoami)" != "deploy"' in g - assert '"$(id -u)" -eq 0' in g - assert g.endswith("# <<< fetch-runner-guard:END\n") + rendered_guard = render_canonical_script_guard("deploy") + assert "user=deploy" in rendered_guard + assert '"$(whoami)" != "deploy"' in rendered_guard + assert '"$(id -u)" -eq 0' in rendered_guard + assert rendered_guard.endswith("# <<< fetch-runner-guard:END\n") @pytest.mark.parametrize( @@ -27,99 +27,111 @@ def test_render_guard_embeds_user_everywhere(): ) def test_render_guard_rejects_unsafe_user(bad): with pytest.raises(GuardError): - render_guard(bad) + render_canonical_script_guard(bad) -def _write(path: Path, body: str) -> Path: - path.write_text(body) - return path +def _write_script_file(script_path: Path, script_body: str) -> Path: + script_path.write_text(script_body) + return script_path def test_validate_script_guard_happy(tmp_path: Path): - script = _write( + script_path = _write_script_file( tmp_path / "deploy.sh", - "#!/bin/bash\n# a comment\n\n" + render_guard("deploy") + "echo hi\n", + "#!/bin/bash\n# a comment\n\n" + render_canonical_script_guard("deploy") + "echo hi\n", ) - assert validate_script_guard(script, "deploy").ok + assert validate_canonical_script_guard(script_path, "deploy").is_valid def test_validate_script_guard_works_without_shebang(tmp_path: Path): - script = _write(tmp_path / "s", render_guard("deploy") + "echo hi\n") - assert validate_script_guard(script, "deploy").ok + script_path = _write_script_file( + tmp_path / "s", + render_canonical_script_guard("deploy") + "echo hi\n", + ) + assert validate_canonical_script_guard(script_path, "deploy").is_valid def test_validate_script_guard_only_comments_has_no_guard(tmp_path: Path): - script = _write(tmp_path / "s.sh", "#!/bin/bash\n# nothing here\n\n") - result = validate_script_guard(script, "deploy") - assert not result.ok - assert "canonical guard block" in result.reason + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\n# nothing here\n\n") + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "canonical guard block" in guard_validation.error_reason def test_validate_script_guard_code_without_guard(tmp_path: Path): - script = _write(tmp_path / "s.sh", "#!/bin/bash\necho hi\n") - result = validate_script_guard(script, "deploy") - assert not result.ok - assert "before any executable code" in result.reason + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\necho hi\n") + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "before any executable code" in guard_validation.error_reason def test_validate_script_guard_wrong_user(tmp_path: Path): - script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + render_guard("otheruser") + "echo hi\n") - result = validate_script_guard(script, "deploy") - assert not result.ok - assert "different user" in result.reason + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + render_canonical_script_guard("otheruser") + "echo hi\n", + ) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "different user" in guard_validation.error_reason def test_validate_script_guard_rejects_code_before_guard(tmp_path: Path): - script = _write( + script_path = _write_script_file( tmp_path / "s.sh", - "#!/bin/bash\nset -e\n" + render_guard("deploy") + "echo hi\n", + "#!/bin/bash\nset -e\n" + render_canonical_script_guard("deploy") + "echo hi\n", ) - result = validate_script_guard(script, "deploy") - assert not result.ok - assert "before any executable code" in result.reason + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "before any executable code" in guard_validation.error_reason def test_validate_script_guard_detects_flipped_comparator(tmp_path: Path): # The most dangerous form of tampering: a check that always passes. - good = render_guard("deploy") - tampered = good.replace('!= "deploy"', '== "deploy"') - assert tampered != good - script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + tampered + "echo hi\n") - result = validate_script_guard(script, "deploy") - assert not result.ok + canonical_guard = render_canonical_script_guard("deploy") + tampered_guard = canonical_guard.replace('!= "deploy"', '== "deploy"') + assert tampered_guard != canonical_guard + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + tampered_guard + "echo hi\n", + ) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid def test_validate_script_guard_detects_removed_uid_check(tmp_path: Path): # Removing the root check: whoami would still match, but a root invocation # should be blocked. Tampering must be caught. - good = render_guard("deploy") - tampered = good.replace(' || [ "$(id -u)" -eq 0 ]', "") - assert tampered != good - script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + tampered + "echo hi\n") - assert not validate_script_guard(script, "deploy").ok + canonical_guard = render_canonical_script_guard("deploy") + tampered_guard = canonical_guard.replace(' || [ "$(id -u)" -eq 0 ]', "") + assert tampered_guard != canonical_guard + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + tampered_guard + "echo hi\n", + ) + assert not validate_canonical_script_guard(script_path, "deploy").is_valid def test_validate_script_guard_truncated(tmp_path: Path): - good = render_guard("deploy").splitlines() - truncated = "\n".join(good[:-2]) + "\n" - script = _write(tmp_path / "s.sh", "#!/bin/bash\n" + truncated) - result = validate_script_guard(script, "deploy") - assert not result.ok + canonical_guard_lines = render_canonical_script_guard("deploy").splitlines() + truncated_guard = "\n".join(canonical_guard_lines[:-2]) + "\n" + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\n" + truncated_guard) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid def test_validate_script_guard_missing_file(tmp_path: Path): - result = validate_script_guard(tmp_path / "nope.sh", "deploy") - assert not result.ok - assert "cannot read" in result.reason + guard_validation = validate_canonical_script_guard(tmp_path / "nope.sh", "deploy") + assert not guard_validation.is_valid + assert "cannot read" in guard_validation.error_reason def test_current_user_matches_pwd_entry(): - assert current_user() == pwd.getpwuid(os.getuid()).pw_name + assert get_current_real_uid_user_name() == pwd.getpwuid(os.getuid()).pw_name def test_require_runtime_user_rejects_mismatch(): with pytest.raises(GuardError): - require_runtime_user("definitely-not-this-user-xyz") + require_expected_runtime_user("definitely-not-this-user-xyz") def test_require_runtime_user_accepts_match(): @@ -127,4 +139,4 @@ def test_require_runtime_user_accepts_match(): # in a sane test environment). Skip if someone is running tests as root. if os.getuid() == 0: pytest.skip("test suite is running as root") - require_runtime_user(current_user()) + require_expected_runtime_user(get_current_real_uid_user_name()) From f13d866adb1c1adba284a87cb161481bbc74ac61 Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 28 Apr 2026 14:23:05 -0600 Subject: [PATCH 7/8] fixes suggested by Ben --- .github/workflows/tests.yml | 2 +- src/fetch_runner/cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 567985c..3adcdfd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ jobs: enable-cache: true - name: Set up Python - run: uv python install 3.11 + run: uv python install 3.14 - name: Install dependencies run: uv sync --all-extras --dev diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py index 22c8302..591d511 100644 --- a/src/fetch_runner/cli.py +++ b/src/fetch_runner/cli.py @@ -30,7 +30,7 @@ def main(argv: list[str] | None = None) -> int: argument_parser.add_argument( "--print-guard", metavar="USER", - help="print the canonical guard block for USER and exit", + help="print the canonical guard block for USER and exit (for pasting into a new script)", ) argument_parser.add_argument( "-v", From 99caf676c7131764a629bd39f945467826f5601b Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Tue, 28 Apr 2026 14:25:31 -0600 Subject: [PATCH 8/8] example explicit --- examples/jobs.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/jobs.toml b/examples/jobs.toml index cb4c61c..bac6528 100644 --- a/examples/jobs.toml +++ b/examples/jobs.toml @@ -7,14 +7,14 @@ user = "deploy" poll_interval_seconds = 60 [[jobs]] -name = "api" +name = "my example api" path = "/srv/api" branch = "main" script = "/srv/api/deploy.sh" timeout_seconds = 600 [[jobs]] -name = "web" +name = "my example web" path = "/srv/web" branch = "production" script = "/srv/web/deploy.sh"