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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Tests

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.14

- 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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
30 changes: 30 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions examples/deploy.sh
Original file line number Diff line number Diff line change
@@ -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
90 changes: 90 additions & 0 deletions examples/fetch-runner.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
[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 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

# Send both fetch-runner logs and deploy-script output to journald so there is
# one place to inspect failures.
StandardOutput=journal
StandardError=journal

# Block privilege escalation; the deploy user never needs more than it has.
NoNewPrivileges=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
AmbientCapabilities=

# 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

# Deploy scripts should not need the service user's home directory.
ProtectHome=read-only

PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true

# 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

# 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

# Prevent deploy code from creating new namespaces inside the sandbox.
RestrictNamespaces=true

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

# Syscall filter.
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target
Comment thread
benrencher marked this conversation as resolved.
20 changes: 20 additions & 0 deletions examples/jobs.toml
Original file line number Diff line number Diff line change
@@ -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 = "my example api"
path = "/srv/api"
branch = "main"
script = "/srv/api/deploy.sh"
timeout_seconds = 600
Comment thread
benrencher marked this conversation as resolved.

[[jobs]]
name = "my example web"
path = "/srv/web"
branch = "production"
script = "/srv/web/deploy.sh"
45 changes: 45 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[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",
"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-single-line = true
force-sort-within-sections = false
7 changes: 7 additions & 0 deletions src/fetch_runner/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _version

try:
__version__ = _version("fetch-runner")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
4 changes: 4 additions & 0 deletions src/fetch_runner/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from fetch_runner.cli import main

if __name__ == "__main__":
raise SystemExit(main())
92 changes: 92 additions & 0 deletions src/fetch_runner/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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
from fetch_runner.config import load_config
from fetch_runner.guard import GuardError
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:
argument_parser = argparse.ArgumentParser(
prog="fetch-runner",
description="Poll git branches and run scripts when new commits arrive.",
)
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",
)
Comment thread
benrencher marked this conversation as resolved.
argument_parser.add_argument(
"--print-guard",
metavar="USER",
help="print the canonical guard block for USER and exit (for pasting into a new script)",
)
Comment thread
benrencher marked this conversation as resolved.
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(cli_args.verbose)

if cli_args.print_guard:
try:
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 cli_args.config is None:
argument_parser.error("config path is required (or use --print-guard)")

try:
runner_config = load_config(cli_args.config)
except ConfigError as e:
print(f"config error: {e}", file=sys.stderr)
return 2

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 = GitPollingRunner(runner_config)

def _handle_stop_signal(signum, _frame):
log.info("received signal %s; shutting down", signum)
runner.request_stop()

signal.signal(signal.SIGTERM, _handle_stop_signal)
signal.signal(signal.SIGINT, _handle_stop_signal)
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,
)
Loading
Loading