You are given a project (below, as a dump of its files; you cannot read anything else or run commands). Task: organize everything this project's user would want to reach into screens, the map a command line's help screens will be built from. Do the planning only: build nothing, and don't wait for answers. Where a decision is the user's to make, put it in "questions" with your recommendation. End your answer with the map as ONE fenced ```json block, in exactly this shape: {"screens": [{"path": ["noun", "..."], "sentence": "...", "rows": [{"name": "...", "form": "...", "what": "..."}]}], "questions": ["..."]} - "path": the nouns that lead to the screen, [] for the top screen. - "sentence": what the screen shows, in plain words. - "rows": what can be reached from that screen: "name" in plain words, "form" its technical form (a path, URL, port, command, or the screen it opens), "what" what it is for. # Project dump: burrow Repository snapshot of `burrow`, a small tool for looking after the machines in a home lab. Below is the file tree, then the full text of the files that matter. ## File tree ``` burrow/ ├── README.md ├── LICENSE ├── Makefile ├── pyproject.toml ├── docker-compose.yml ├── .env.example ├── config/ │ ├── burrow.toml │ └── rigs.toml ├── deploy/ │ ├── burrow-porch.service │ └── burrow.crontab ├── bin/ │ └── rig-wake ├── burrow/ │ ├── __init__.py │ ├── cli.py │ ├── config.py │ ├── stash.py │ ├── chores.py │ ├── rigs.py │ └── porch/ │ ├── __init__.py │ ├── app.py │ └── templates/ │ ├── index.html │ ├── chores.html │ └── stashes.html ├── pulse/ │ ├── Dockerfile │ └── pulse.py ├── scripts/ │ ├── battery-report.sh │ ├── cert-renew.sh │ ├── check-disks.sh │ ├── dns-flush.sh │ ├── du-stashes.sh │ ├── export-chores.sh │ ├── fan-curve.sh │ ├── gc-docker.sh │ ├── import-rigs.sh │ ├── list-ports.sh │ ├── mount-nas.sh │ ├── net-speed.sh │ ├── pihole-update.sh │ ├── reboot-rig.sh │ ├── rotate-logs.sh │ ├── seed-dev.sh │ ├── smart-report.sh │ ├── ssh-keys-push.sh │ ├── sync-clock.sh │ ├── temp-check.sh │ ├── unmount-nas.sh │ ├── update-rigs.sh │ ├── ups-status.sh │ ├── verify-stash.sh │ └── zfs-scrub.sh ├── docs/ │ ├── setup.md │ ├── adding-a-rig.md │ ├── chores.md │ └── internals/ │ └── stash/ │ └── format/ │ └── restore-by-hand.md └── tests/ ├── test_stash.py ├── test_chores.py └── test_rigs.py ``` --- ## README.md ```markdown # burrow burrow keeps an eye on the handful of machines in my home lab. I call each machine a **rig**. burrow knows which rigs exist, takes **stashes** (snapshots of a rig's important folders, pulled over rsync), runs **chores** (recurring jobs such as nightly stashes and disk checks) and shows everything on the **porch**, a small web dashboard. It is a personal project. It works on my three rigs (`attic`, `cellar`, `garden`) and should work on yours with a bit of config. ## Install git clone https://github.com/example/burrow /opt/burrow cd /opt/burrow make install `make install` creates a virtualenv in `.venv/` and installs the `burrow` command into it. Copy `.env.example` to `.env` and fill in the values. ## Configure All settings live in `config/burrow.toml`. Your rigs are listed in `config/rigs.toml`; see [docs/adding-a-rig.md](docs/adding-a-rig.md) to add one. Stashes are written under `/srv/burrow/stashes`, one folder per rig, one dated folder per stash. ## Use See which rigs burrow knows and whether they answer: burrow rigs Take a stash of one rig right now: burrow stash attic or of every rig at once with `make stash-all`. Put a stash back: burrow restore attic --from 2026-10-20 The porch runs as a systemd service (`burrow-porch.service`) and listens on port 8470. Open http://127.0.0.1:8470 from any machine on the LAN (use the rig's IP instead of 127.0.0.1). ## Chores The nightly stash of every rig is a chore. Install the crontab from `deploy/burrow.crontab`; its output goes to `/var/log/burrow/chores.log`. More in [docs/chores.md](docs/chores.md). ## Metrics `make up` starts the containers in `docker-compose.yml`, which include Grafana on http://127.0.0.1:3000 for graphs of rig temperatures and disk use. ## Helpers `scripts/` holds small shell helpers I use now and then. Read the top of each one before running it. ## Development make test ## License MIT ``` --- ## Makefile ```makefile PREFIX ?= /opt/burrow VENV := $(PREFIX)/.venv PY := $(VENV)/bin/python BURROW := $(VENV)/bin/burrow .PHONY: install up down test lint stash-all prune-stashes install-prune-cron install-unit clean install: python3 -m venv $(VENV) $(VENV)/bin/pip install -e . mkdir -p /srv/burrow/stashes /srv/burrow/state /var/log/burrow install-unit: sudo cp deploy/burrow-porch.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now burrow-porch.service up: docker compose up -d down: docker compose down test: $(PY) -m pytest -q tests lint: $(VENV)/bin/ruff check burrow tests stash-all: $(BURROW) stash --all prune-stashes: $(BURROW) stash prune --keep 8 # Weekly prune so /srv/burrow/stashes does not fill the disk. install-prune-cron: ( crontab -l 2>/dev/null | grep -v prune-stashes ; \ echo "40 4 * * 0 cd /opt/burrow && make prune-stashes" ) | crontab - clean: rm -rf $(VENV) .pytest_cache build *.egg-info ``` --- ## pyproject.toml ```toml [project] name = "burrow" version = "0.6.2" description = "Look after the rigs in a home lab: stashes, chores and a porch." requires-python = ">=3.11" dependencies = [ "click>=8.1", "flask>=3.0", "tomli-w>=1.0", "paramiko>=3.4", ] [project.optional-dependencies] dev = ["pytest>=8", "ruff>=0.6"] [project.scripts] burrow = "burrow.cli:main" [tool.setuptools.packages.find] include = ["burrow*"] [tool.ruff] line-length = 100 ``` --- ## docker-compose.yml ```yaml services: pulse: build: ./pulse restart: unless-stopped network_mode: bridge ports: - "9188:9188" environment: PULSE_PORT: "9188" PULSE_RIGS_FILE: /config/rigs.toml PULSE_INTERVAL: "30" volumes: - ./config:/config:ro prometheus: image: prom/prometheus:v2.54.1 restart: unless-stopped command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=30d volumes: - prom-data:/prometheus depends_on: - pulse grafana: image: grafana/grafana:11.2.0 restart: unless-stopped ports: - "3000:3000" environment: GF_AUTH_ANONYMOUS_ENABLED: "true" volumes: - grafana-data:/var/lib/grafana depends_on: - prometheus volumes: prom-data: grafana-data: ``` --- ## .env.example ```dotenv # Copy to .env and fill in. BURROW_SSH_KEY=/home/me/.ssh/burrow_ed25519 BURROW_PORCH_SECRET=change-me BURROW_NTFY_TOPIC=burrow-alerts ``` --- ## config/burrow.toml ```toml [stash] root = "/srv/burrow/stashes" rsync_flags = ["-aHAX", "--delete", "--numeric-ids"] keep = 8 [chores] state_file = "/srv/burrow/state/last-run.json" notify = "ntfy" [porch] bind = "0.0.0.0:8470" title = "burrow porch" refresh_seconds = 20 [ssh] user = "burrow" key_env = "BURROW_SSH_KEY" ``` --- ## config/rigs.toml ```toml [[rig]] name = "attic" address = "192.168.1.20" mac = "a8:a1:59:3c:10:02" paths = ["/etc", "/home", "/var/lib/docker/volumes"] [[rig]] name = "cellar" address = "192.168.1.21" mac = "a8:a1:59:3c:10:7f" paths = ["/etc", "/tank/photos"] [[rig]] name = "garden" address = "192.168.1.22" mac = "d8:3a:dd:41:0e:9b" paths = ["/etc", "/opt/homeassistant"] ``` --- ## deploy/burrow-porch.service ```ini [Unit] Description=burrow porch (dashboard for the rigs) After=network-online.target Wants=network-online.target [Service] Type=simple User=burrow WorkingDirectory=/opt/burrow EnvironmentFile=/opt/burrow/.env ExecStart=/opt/burrow/.venv/bin/burrow porch --bind 0.0.0.0:8470 Restart=on-failure RestartSec=5 StandardOutput=append:/var/log/burrow/porch.log StandardError=append:/var/log/burrow/porch.log [Install] WantedBy=multi-user.target ``` --- ## deploy/burrow.crontab ``` # burrow chores. Install with: crontab deploy/burrow.crontab SHELL=/bin/bash 15 2 * * * /opt/burrow/.venv/bin/burrow chores run nightly-stash >> /var/log/burrow/chores.log 2>&1 0 */6 * * * /opt/burrow/.venv/bin/burrow chores run disk-check >> /var/log/burrow/chores.log 2>&1 ``` --- ## bin/rig-wake ```bash #!/usr/bin/env bash # Wake a sleeping rig with a magic packet, using the MAC from config/rigs.toml. # usage: bin/rig-wake set -euo pipefail here="$(cd "$(dirname "$0")/.." && pwd)" rig="${1:?usage: rig-wake }" mac="$(python3 - "$here/config/rigs.toml" "$rig" <<'PY' import sys, tomllib cfg = tomllib.load(open(sys.argv[1], "rb")) for r in cfg.get("rig", []): if r["name"] == sys.argv[2]: print(r["mac"]); break else: sys.exit(f"no rig named {sys.argv[2]}") PY )" wakeonlan "$mac" echo "sent wake packet to $rig ($mac)" ``` --- ## burrow/cli.py ```python """The burrow command.""" import click from burrow import chores, config, rigs, stash @click.group() @click.option("--config", "config_path", default="config/burrow.toml", show_default=True) @click.pass_context def main(ctx, config_path): ctx.obj = config.load(config_path) @main.command("rigs") @click.pass_obj def rigs_cmd(cfg): """List rigs and whether each one answers.""" for rig in rigs.load(cfg): state = "up" if rigs.ping(rig) else "down" click.echo(f"{rig.name:10} {rig.address:15} {state}") @main.group("stash", invoke_without_command=True) @click.argument("rig", required=False) @click.option("--all", "all_rigs", is_flag=True) @click.pass_context def stash_cmd(ctx, rig, all_rigs): """Take a stash of one rig, or of every rig with --all.""" if ctx.invoked_subcommand: return targets = rigs.load(ctx.obj) if all_rigs else [rigs.get(ctx.obj, rig)] for r in targets: path = stash.take(ctx.obj, r) click.echo(f"stashed {r.name} -> {path}") @stash_cmd.command("list") @click.argument("rig") @click.pass_obj def stash_list(cfg, rig): for s in stash.list_for(cfg, rig): click.echo(s) @stash_cmd.command("prune") @click.option("--keep", default=8, show_default=True) @click.pass_obj def stash_prune(cfg, keep): removed = stash.prune(cfg, keep) click.echo(f"removed {len(removed)} old stashes") @main.command("restore") @click.argument("rig") @click.option("--from", "when", required=True, help="Date of the stash, YYYY-MM-DD.") @click.option("--dry-run", is_flag=True) @click.pass_obj def restore_cmd(cfg, rig, when, dry_run): """Copy a stash back onto its rig.""" stash.restore(cfg, rigs.get(cfg, rig), when, dry_run=dry_run) @main.group("chores") def chores_cmd(): """Recurring jobs.""" @chores_cmd.command("list") def chores_list(): for name in chores.REGISTRY: click.echo(name) @chores_cmd.command("run") @click.argument("name") @click.pass_obj def chores_run(cfg, name): chores.run(cfg, name) @chores_cmd.command("status") @click.pass_obj def chores_status(cfg): for name, info in chores.last_runs(cfg).items(): click.echo(f"{name:15} {info['finished']} {info['result']}") @main.command("porch") @click.option("--bind", default=None) @click.pass_obj def porch_cmd(cfg, bind): """Run the porch dashboard.""" from burrow.porch.app import create_app addr, port = (bind or cfg["porch"]["bind"]).rsplit(":", 1) create_app(cfg).run(addr, int(port)) ``` --- ## burrow/chores.py ```python """Chores: named recurring jobs, run from cron.""" import json import time from pathlib import Path from burrow import rigs, stash REGISTRY = {} def chore(name): def wrap(fn): REGISTRY[name] = fn return fn return wrap @chore("nightly-stash") def nightly_stash(cfg): for r in rigs.load(cfg): stash.take(cfg, r) @chore("disk-check") def disk_check(cfg): for r in rigs.load(cfg): rigs.check_disk(r, warn_at=0.85) def run(cfg, name): started = time.time() try: REGISTRY[name](cfg) result = "ok" except Exception as exc: # noqa: BLE001 result = f"failed: {exc}" _record(cfg, name, started, result) def last_runs(cfg): path = Path(cfg["chores"]["state_file"]) return json.loads(path.read_text()) if path.exists() else {} def _record(cfg, name, started, result): path = Path(cfg["chores"]["state_file"]) data = last_runs(cfg) data[name] = {"started": started, "finished": time.time(), "result": result} path.write_text(json.dumps(data, indent=2)) ``` --- ## burrow/porch/app.py ```python """The porch: a small Flask dashboard.""" from flask import Flask, render_template from burrow import chores, rigs, stash def create_app(cfg): app = Flask(__name__) @app.get("/") def index(): return render_template("index.html", rigs=rigs.load(cfg), cfg=cfg) @app.get("/chores") def chores_page(): return render_template("chores.html", runs=chores.last_runs(cfg)) @app.get("/stashes/") def stashes_page(rig): return render_template("stashes.html", rig=rig, items=stash.list_for(cfg, rig)) @app.get("/healthz") def healthz(): return {"ok": True} return app ``` --- ## pulse/pulse.py ```python """pulse: exposes rig temperatures and disk use for Prometheus.""" import os import time import tomllib import threading from flask import Flask, Response from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest TEMP = Gauge("burrow_rig_temp_celsius", "CPU temperature", ["rig"]) DISK = Gauge("burrow_rig_disk_used_ratio", "Root disk used", ["rig"]) app = Flask(__name__) @app.get("/metrics") def metrics(): return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) def poll(rigs): while True: for r in rigs: TEMP.labels(r["name"]).set(read_temp(r)) DISK.labels(r["name"]).set(read_disk(r)) time.sleep(int(os.environ.get("PULSE_INTERVAL", "30"))) def main(): rigs = tomllib.load(open(os.environ["PULSE_RIGS_FILE"], "rb"))["rig"] threading.Thread(target=poll, args=(rigs,), daemon=True).start() app.run("0.0.0.0", int(os.environ["PULSE_PORT"])) def read_temp(r): return 0.0 # filled in over ssh in the real thing def read_disk(r): return 0.0 if __name__ == "__main__": main() ``` --- ## pulse/Dockerfile ```dockerfile FROM python:3.12-slim RUN pip install --no-cache-dir prometheus-client==0.21.0 flask==3.0.3 COPY pulse.py /app/pulse.py CMD ["python", "/app/pulse.py"] ``` --- ## scripts/check-disks.sh ```bash #!/usr/bin/env bash # Show disk use on every rig in config/rigs.toml. set -euo pipefail for addr in $(grep address config/rigs.toml | cut -d'"' -f2); do echo "== $addr"; ssh "burrow@$addr" df -h -x tmpfs -x overlay done ``` ## scripts/verify-stash.sh ```bash #!/usr/bin/env bash # Check that the newest stash of a rig is readable and not empty. # usage: scripts/verify-stash.sh set -euo pipefail rig="${1:?rig}" latest="$(ls -1d /srv/burrow/stashes/"$rig"/* | tail -n1)" find "$latest" -type f | head -n 5 du -sh "$latest" ``` ## scripts/reboot-rig.sh ```bash #!/usr/bin/env bash # Reboot one rig after a 10 second countdown. set -euo pipefail addr="${1:?address}" for i in $(seq 10 -1 1); do printf '%s ' "$i"; sleep 1; done; echo ssh "burrow@$addr" sudo systemctl reboot ``` (The other 22 scripts in `scripts/` are similar one-screen shell helpers.) --- ## docs/setup.md ```markdown # Setting up burrow 1. Make a `burrow` user on every rig and put the public half of `BURROW_SSH_KEY` in its `~/.ssh/authorized_keys`. 2. On the rig that will run burrow, `make install`, then `make install-unit` to start the porch under systemd. 3. Install the chores crontab: `crontab deploy/burrow.crontab`. 4. Optional: `make up` for the metrics containers. Check it worked: `burrow rigs` should list every rig as `up`, and http://127.0.0.1:8470/healthz should return `{"ok": true}`. ``` --- ## docs/adding-a-rig.md ```markdown # Adding a rig Add a `[[rig]]` block to `config/rigs.toml`: [[rig]] name = "shed" address = "192.168.1.23" mac = "aa:bb:cc:dd:ee:ff" paths = ["/etc"] Then run `burrow rigs` to see that it answers, and `burrow stash shed` to take its first stash. The porch picks it up on its next refresh. ``` --- ## docs/chores.md ```markdown # Chores A chore is a named job registered in `burrow/chores.py`. Two ship with burrow: - `nightly-stash`: a stash of every rig, at 02:15. - `disk-check`: warns when any rig's root disk is over 85%, every 6 hours. See them with `burrow chores list`. Run one by hand with `burrow chores run disk-check`. See when each last ran, and whether it worked, with `burrow chores status`, or on the porch at http://127.0.0.1:8470/chores. The last-run record is kept in `/srv/burrow/state/last-run.json`. ``` --- ## docs/internals/stash/format/restore-by-hand.md ```markdown # Restoring a stash by hand If burrow itself is broken, a stash is still just a folder: /srv/burrow/stashes/// Copy it back with rsync, as root on the rig: rsync -aHAX --numeric-ids /srv/burrow/stashes/attic/2026-10-20/etc/ /etc/ The `.burrow-manifest` file at the top of each stash lists the paths and the rsync exit code of the run that made it. A non-zero code means the stash is partial. ``` --- ## tests/test_chores.py ```python from burrow import chores def test_registry_has_builtin_chores(): assert {"nightly-stash", "disk-check"} <= set(chores.REGISTRY) def test_record_writes_state(tmp_path): cfg = {"chores": {"state_file": str(tmp_path / "last-run.json")}} chores._record(cfg, "disk-check", 0, "ok") assert chores.last_runs(cfg)["disk-check"]["result"] == "ok" ```