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
145 changes: 83 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,110 +1,131 @@
# fetch-runner
Run scripts when `git fetch` finds new commits to specified git branches
Run scripts when `git fetch` finds new commits to specified git branches.

## Overview

Two users matter:

- **`[general].user`** — the user fetch-runner itself runs as (also
`User=` in the systemd unit). fetch-runner refuses to start if the
running uid doesn't match.
- **`[[jobs]].run_as`** — per-job; owns this job's repo and runs its git
ops and deploy script. Defaults to `[general].user`. When set
differently, fetch-runner dispatches everything via `sudo -n -u <run_as>`
and a sudoers rule must allow it (generate one with
`fetch-runner --print-sudoers <jobs.toml>`).

Convention assumed throughout the docs is that each repo lives at
`/srv/<run_as>/<repo_name>/`, owned `<run_as>:<run_as>` mode `0755`.
That gives every path under `/srv/<run_as>/` a single owner and lets
`[general].user` traverse with just search permission.

## Setup

### 1. Install fetch-runner

As the deploy user (we use `my-app-user` in this document), install fetch-runner with `uv`:
As `[general].user` (we use `fetch-runner` below):

```bash
uv tool install git+https://github.com/BYU-ODH/fetch-runner
```

Note the path of the installed executable (typically something like
`/home/my-app-user/.local/bin/fetch-runner`). You will need it in step 4.
Note the installed executable path (typically
`/home/fetch-runner/.local/bin/fetch-runner`).

### 2. Add a deploy script to your app
### 2. Add a deploy script to each app

Copy `examples/deploy.sh` from this repository into your app's directory:
For a job with `run_as = "app1"` deploying the `api` repo:

```bash
cp /path/to/fetch-runner/examples/deploy.sh /srv/myapp/deploy.sh
chmod +x /srv/myapp/deploy.sh
sudo -u app1 cp /path/to/fetch-runner/examples/deploy.sh /srv/app1/api/deploy.sh
sudo -u app1 chmod +x /srv/app1/api/deploy.sh
```

Open the script and replace every occurrence of `deploy-user` with the
username that runs your app's deployments (e.g. `my-app-user`). The guard block
at the top of the script prevents accidental execution as the wrong user or
as root.
Replace every `deploy-user` in the script with the job's `run_as` user
(`app1` here). The guard block at the top refuses to run as any other
user. Regenerate it for a different user with:

Consider committing this script to your app's repository so the deploy
procedure is version-controlled alongside the code it deploys.
```bash
fetch-runner --print-guard app1
```

### 3. Create the jobs config
Commit the script to the app's repo so deploys are version-controlled.

Copy the example config to the deploy user's home directory and edit it:
### 3. Create the jobs config

```bash
cp /path/to/fetch-runner/examples/jobs.toml /home/my-app-user/jobs.toml
cp /path/to/fetch-runner/examples/jobs.toml /home/fetch-runner/jobs.toml
```

Edit `/home/my-app-user/jobs.toml`:
Per `[[jobs]]`:
- `name` — label shown in logs
- `path` — absolute repo path, owned and writable by `run_as`
- `branch` — branch to watch
- `script` — absolute script path
- `run_as` — optional; defaults to `[general].user`
- `timeout_seconds` — optional script timeout

- Set `user` under `[general]` to the deploy user (e.g. `"my-app-user"`).
fetch-runner exits at startup if the running user does not match this value.
- Set `poll_interval_seconds` to how often fetch-runner should check for new
commits (default: `60`).
- For each `[[jobs]]` entry, set:
- `name` — a human-readable label shown in logs
- `path` — absolute path to the local git repository to poll
- `branch` — the branch to watch (e.g. `"main"` or `"production"`)
- `script` — absolute path to the script to run when new commits are found
(e.g. `/srv/myapp/deploy.sh`)
- `timeout_seconds` — how long to let the script run before killing it
(optional; omit to use the default)
Validate without starting:

### 4. Install and start the systemd service
```bash
fetch-runner --check /home/fetch-runner/jobs.toml
```

Copy the example unit file to systemd's unit directory:
### 4. Install the systemd service

```bash
sudo cp /path/to/fetch-runner/examples/fetch-runner.service \
/etc/systemd/system/fetch-runner.service
```

Edit `/etc/systemd/system/fetch-runner.service` and update the lines in the
`CUSTOMIZE` block:

- **`User` / `Group`** — set both to your deploy user (must match `user` in
`jobs.toml`).
- **`ExecStart`** — replace `/usr/local/bin/fetch-runner` with the full path
to the executable you noted in step 1, then replace
`/etc/fetch-runner/jobs.toml` with the path to the config file from step 3.
For example:
```
ExecStart=/home/my-app-user/.local/bin/fetch-runner /home/my-app-user/jobs.toml
```
- **`ReadWritePaths`** — list every directory your deploy scripts need to
write to (at minimum, the parent directories of your git repositories).
Space-separate multiple paths, e.g.:
```
ReadWritePaths=/srv/myapp /srv/anotherapp
```

Reload systemd and enable the service:
In the `CUSTOMIZE` block, set:
- `User` / `Group` to `[general].user`
- `ExecStart` to the binary path from step 1 plus your config path
- `ReadWritePaths` to every directory any child process writes to —
including the repos themselves (sudo'd git is still inside the unit's
filesystem sandbox)

The example unit omits `NoNewPrivileges=` and `RestrictSUIDSGID=`
because they block sudo's setuid. The sudoers fragment (step 5) is what
bounds the privilege. If every job uses `run_as = [general].user`, you
can re-enable both.

### 5. Install the sudoers fragment (only if any job sets a different `run_as`)

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now fetch-runner
fetch-runner --print-sudoers /home/fetch-runner/jobs.toml \
| sudo tee /etc/sudoers.d/fetch-runner > /dev/null
sudo chmod 0440 /etc/sudoers.d/fetch-runner
sudo visudo -cf /etc/sudoers.d/fetch-runner # syntax check
```

## Debugging
Re-run after any `jobs.toml` change. The git rule is intentionally not
arg-restricted: running git as `run_as` is no broader than what the
deploy-script rule already grants.

Check whether the service is running and see its recent log output:
### 6. Enable and start

```bash
systemctl status fetch-runner
sudo systemctl daemon-reload
sudo systemctl enable --now fetch-runner
```

Stream the full journal for the service (most useful when a deployment fails):
## Migrating from single-user mode

```bash
journalctl -u fetch-runner -f
```
Existing configs without `run_as` keep working unchanged — sudo is
skipped entirely. To split, add `run_as` per job, update each script's
guard for the new user, regenerate the sudoers fragment, reload.

To review all logs since the service last started:
## Debugging

```bash
systemctl status fetch-runner
journalctl -u fetch-runner -f
journalctl -u fetch-runner -b
```

- `sudo: a password is required` → sudoers fragment is missing or stale;
re-run step 5.
- `fetch-runner-guard: refusing to run as <user>` → the script's guard
names a user that doesn't match the job's `run_as`; regenerate with
`fetch-runner --print-guard <run_as>`.
21 changes: 11 additions & 10 deletions examples/fetch-runner.service
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ 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
# Must match [general].user in jobs.toml — fetch-runner refuses to start
# otherwise. This user does not need to own the repos; each repo is
# owned by its job's run_as user, and git runs as that user via sudo.
User=fetch-runner
Group=fetch-runner

# 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.
# Every directory any child process writes to — git repos and any app
# state the deploy scripts touch. Sudo'd children inherit this sandbox.
# The rest of the filesystem is read-only thanks to ProtectSystem.
ReadWritePaths=/srv

# ===========================================================================
Expand All @@ -41,9 +42,9 @@ TimeoutStopSec=30s
StandardOutput=journal
StandardError=journal

# Block privilege escalation; the deploy user never needs more than it has.
NoNewPrivileges=true
RestrictSUIDSGID=true
# NoNewPrivileges / RestrictSUIDSGID block sudo's setuid transition, so
# they are off here. The sudoers fragment is what bounds the privilege.
# If every job uses run_as = [general].user, you can re-enable both.
CapabilityBoundingSet=
AmbientCapabilities=

Expand Down
27 changes: 27 additions & 0 deletions examples/fetch-runner.sudoers
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Example sudoers fragment for fetch-runner. Generate the live copy from
# your jobs.toml — do not edit this by hand on the host:
#
# fetch-runner --print-sudoers /etc/fetch-runner/jobs.toml \
# | sudo tee /etc/sudoers.d/fetch-runner > /dev/null
# sudo chmod 0440 /etc/sudoers.d/fetch-runner
# sudo visudo -cf /etc/sudoers.d/fetch-runner # syntax check
#
# The shape produced for the example jobs.toml is shown below.

# Preserve FETCH_RUNNER_* env vars only when running these scripts.
Defaults!/srv/app1/api/deploy.sh env_keep += "FETCH_RUNNER_JOB FETCH_RUNNER_BRANCH FETCH_RUNNER_COMMIT FETCH_RUNNER_REPO"
Defaults!/srv/app2/web/deploy.sh env_keep += "FETCH_RUNNER_JOB FETCH_RUNNER_BRANCH FETCH_RUNNER_COMMIT FETCH_RUNNER_REPO"

# Run git as each run_as user (repos are owned by that user).
fetch-runner ALL=(app1) NOPASSWD: /usr/bin/git
fetch-runner ALL=(app2) NOPASSWD: /usr/bin/git

# Run each deploy script as its run_as user.
fetch-runner ALL=(app1) NOPASSWD: /srv/app1/api/deploy.sh
fetch-runner ALL=(app2) NOPASSWD: /srv/app2/web/deploy.sh

# Notes:
# * Pin script paths absolutely; pin run_as per line — never (ALL).
# * NOPASSWD is required (no tty); the narrow pinning is what keeps it safe.
# * The git rule is not arg-restricted — running git as run_as is no
# broader than what the deploy-script rule already allows.
24 changes: 16 additions & 8 deletions examples/jobs.toml
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
# 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 — the user fetch-runner itself runs as. Must match the
# running uid or fetch-runner refuses to start.
# [[jobs]].run_as (optional) — the user that owns this job's repo and
# runs its git ops and script. Defaults to [general].user. When set,
# sudo is used and a sudoers rule is required
# (see `fetch-runner --print-sudoers <this-file>`).
#
# Convention: each repo lives at /srv/<run_as>/<repo_name>/.

[general]
user = "deploy"
user = "fetch-runner"
poll_interval_seconds = 60

[[jobs]]
name = "my example api"
path = "/srv/api"
path = "/srv/app1/api"
branch = "main"
script = "/srv/api/deploy.sh"
script = "/srv/app1/api/deploy.sh"
run_as = "app1"
timeout_seconds = 600

[[jobs]]
name = "my example web"
path = "/srv/web"
path = "/srv/app2/web"
branch = "production"
script = "/srv/web/deploy.sh"
script = "/srv/app2/web/deploy.sh"
run_as = "app2"
65 changes: 65 additions & 0 deletions src/fetch_runner/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

from . import __version__
from .config import ConfigError
from .config import RunnerConfig
from .config import load_config
from .git_ops import GitError
from .git_ops import _git_absolute_path
from .guard import PRESERVED_ENVIRONMENT_VARIABLE_NAMES
from .guard import GuardError
from .guard import render_canonical_script_guard
from .runner import GitPollingRunner
Expand All @@ -32,6 +36,11 @@ def main(argv: list[str] | None = None) -> int:
metavar="USER",
help="print the canonical guard block for USER and exit (for pasting into a new script)",
)
argument_parser.add_argument(
"--print-sudoers",
action="store_true",
help="print the sudoers fragment required by the config and exit",
)
argument_parser.add_argument(
"-v",
"--verbose",
Expand Down Expand Up @@ -64,6 +73,10 @@ def main(argv: list[str] | None = None) -> int:
print(f"config error: {e}", file=sys.stderr)
return 2

if cli_args.print_sudoers:
sys.stdout.write(render_sudoers_fragment(runner_config))
return 0

if cli_args.check:
print(
f"ok: user={runner_config.runtime_user} "
Expand All @@ -83,6 +96,58 @@ def _handle_stop_signal(signum, _frame):
return runner.run_forever()


def render_sudoers_fragment(runner_config: RunnerConfig) -> str:
"""Return a sudoers fragment authorizing every job whose ``run_as``
differs from ``[general].user``. Output is sorted/deduplicated so
regenerating after a config change produces a reviewable diff.
"""
cross_user_jobs = [
configured_job
for configured_job in runner_config.jobs
if configured_job.run_as_user != runner_config.runtime_user
]
rendered_lines: list[str] = []
rendered_lines.append(
"# Generated by `fetch-runner --print-sudoers`. "
"Install with `visudo -f /etc/sudoers.d/fetch-runner`, root:root mode 0440.\n"
)
if not cross_user_jobs:
rendered_lines.append(
f"# (no jobs use a run_as different from {runner_config.runtime_user!r}; "
"no sudoers rules needed.)\n"
)
return "".join(rendered_lines)

try:
git_path = _git_absolute_path()
except GitError as e:
raise SystemExit(f"fetch-runner --print-sudoers: cannot resolve git path: {e}") from e

unique_runas_users = sorted({j.run_as_user for j in cross_user_jobs})
unique_script_paths = sorted({str(j.script_path) for j in cross_user_jobs})
unique_runas_and_script_pairs = sorted(
{(j.run_as_user, str(j.script_path)) for j in cross_user_jobs}
)
env_keep_value = " ".join(PRESERVED_ENVIRONMENT_VARIABLE_NAMES)

rendered_lines.append("\n# Preserve FETCH_RUNNER_* env vars when running deploy scripts.\n")
for script_path in unique_script_paths:
rendered_lines.append(f'Defaults!{script_path} env_keep += "{env_keep_value}"\n')

rendered_lines.append("\n# Run git as each run_as user (repos are owned by that user).\n")
for run_as_user_name in unique_runas_users:
rendered_lines.append(
f"{runner_config.runtime_user} ALL=({run_as_user_name}) NOPASSWD: {git_path}\n"
)

rendered_lines.append("\n# Run each deploy script as its run_as user.\n")
for run_as_user_name, script_path in unique_runas_and_script_pairs:
rendered_lines.append(
f"{runner_config.runtime_user} ALL=({run_as_user_name}) NOPASSWD: {script_path}\n"
)
return "".join(rendered_lines)


def _configure_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
Expand Down
Loading