quartermaster/README.md
Jeff Smith df4fcfc659 feat(ci): Forgejo Actions deploy workflow for home-ctr-onyx (#30)
On push to main, the homelab runner (container mode, docker socket
mounted) builds the image, pushes it to the Forgejo registry tagged
with the commit SHA and latest, then runs docker compose pull + up -d
directly against the host Docker daemon — no SSH hop, since the
runner already lives on the deploy host. Finishes with one
curl -u admin:... against https://quartermaster.unbiasedgeek.com/healthz
to catch TLS, Traefik routing, and basic-auth regressions in a
single probe. Two repo-scoped secrets required: REGISTRY_TOKEN for
docker login and QUARTERMASTER_SMOKE_PASSWORD for the public
healthz probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:01:58 -06:00

6.8 KiB

quartermaster

Household budget tracker. FastAPI + HTMX frontend, SQLite backend.

Pages

  • / budget configuration. One section per category (Incomes, Fixed Amount Bills, Debt Minimums, Primary Debt Target, Food and Essentials, Subscriptions, Other). Every section accepts name + amount entries and shows a running total. The Primary Debt Target is a pointer to a Debt Minimums row.
  • /month/YYYY-MM monthly view. Snapshots the budget at creation time and tracks an applied amount per entry alongside the planned amount. Each row is annotated when its name or planned value has been edited away from the snapshot, or when the row was added after creation. Per-month debt target is independent of the budget's target after snapshot.

Navigate between months with the prev / next buttons or the dropdown picker. A "This month" link on / jumps to the current YYYY-MM; if it has not been created yet, you land on the create flow.

Requirements

  • Python 3.12+
  • uv for dependency management

Setup

uv sync
uv run alembic upgrade head

The SQLite file lives at ./quartermaster.db by default. Override with the QUARTERMASTER_DB_URL environment variable (any SQLAlchemy URL).

Run

uv run uvicorn quartermaster.main:app \
    --log-config src/quartermaster/logconfig.json \
    --reload

Open http://127.0.0.1:8000.

Logs

Logs are JSON on stdout. Each line has level and event as queryable Loki labels (indexed by Promtail on the deploy host), plus arbitrary extra fields in the JSON body.

Example LogQL queries in Grafana Explore (Loki data source):

{container="quartermaster"} | json
{container="quartermaster", event="http_request", status=~"5.."}
{container="quartermaster", event="month_closed"} | json | line_format "{{.year_month}} {{.message}}"

HTTP access logs appear as event="http_request" with method, path, status, and client_ip extras. Application events (month_created, month_closed, template_entry_updated, posting_added, posting_deleted) fire at the matching mutation sites.

Docker

A Dockerfile at the repo root produces a self-contained image that runs alembic upgrade head (with the pre-upgrade backup hook) then uvicorn quartermaster.main:app as a non-root user (uid:gid 1000:1000). The image EXPOSEs port 8000 and declares a HEALTHCHECK against /healthz.

Build and smoke-run locally against a tempfile database:

docker build -t quartermaster:dev .

mkdir -p /tmp/qm-data
docker run --rm -p 8000:8000 \
    -e QUARTERMASTER_DB_URL=sqlite:////data/qm.db \
    -v /tmp/qm-data:/data \
    quartermaster:dev

Then curl http://127.0.0.1:8000/healthz should return {"status":"ok"} with JSON access logs on the container's stdout.

In production on home-ctr-onyx the bind mount is /mnt/quartermaster/ and QUARTERMASTER_DB_URL points at the DB inside it; see the compose file for the full wiring.

Tests

uv run pytest

Tests run against an in-memory SQLite database; no migration step needed.

Backups

The SQLite data file is precious and gitignored. Before any schema change or destructive operation, back it up:

./scripts/backup-db.sh <reason>

Backups land next to the database in ./backups/ by default (QUARTERMASTER_BACKUP_DIR=/some/path to override) as quartermaster-YYYYMMDD-HHMMSS-{slug}.db using SQLite's online backup API (safe even while the app is writing). Alembic invokes the script automatically before every migration via alembic/env.py; retention is forever for now. To restore, stop the app, copy the chosen backup over quartermaster.db, and restart.

Project Layout

src/quartermaster/
  main.py            FastAPI app factory
  routes.py          Budget configuration HTTP handlers
  routes_month.py    Monthly view HTTP handlers
  service.py         Budget queries, totals, target logic
  month_service.py   Snapshot, deviation, per-month CRUD
  models.py          SQLAlchemy models and Section enum
  db.py              Engine, session, PRAGMA foreign_keys=ON
  config.py          DB URL resolution
  templates/         Jinja2 templates (base, index, month, partials)
  static/            CSS
alembic/             Migrations
tests/               pytest suite

Deferred

A transaction log that rolls up into applied on a per-entry per-month basis is deferred. Once implemented it may replace the hand-edited applied field.

Deploy (home-ctr-onyx)

compose.yml at the repo root describes the quartermaster-web service. The platform team has already provisioned /mnt/quartermaster/ (owned 1000:1000), the proxy-net Docker network, and the quartermaster-basicauth@file + quartermaster-ratelimit@file Traefik middlewares on home-ctr-onyx. See the platform contract wiki page for the canonical list.

Roll out a new build:

docker compose pull
docker compose up -d

compose.yml references the image as …/quartermaster:${QUARTERMASTER_TAG:-latest}. The deploy workflow writes QUARTERMASTER_TAG=<git-sha> to an .env file next to compose.yml on the host, so each deploy pins a specific SHA without editing the checked-in compose file.

Four-slash gotcha. QUARTERMASTER_DB_URL is sqlite:////data/quartermaster.db — four slashes for an absolute path. Three would resolve relative to the working directory, and the SQLite file would NOT land on the bind mount (on next restart the database would be empty).

CI/CD

Push to main triggers .forgejo/workflows/deploy.yml on the homelab runner. That runner lives on home-ctr-onyx itself in container mode with the host's Docker socket mounted — so the workflow talks to the same Docker daemon that hosts the production container and no SSH round-trip is needed.

The workflow: checks out the repo, builds the image, pushes it to the Forgejo registry tagged with the commit SHA and latest, writes QUARTERMASTER_TAG=<git-sha> to a .env file next to the checked-out compose.yml, runs docker compose pull && docker compose up -d, and finishes with one curl -fsS -u admin:… https://quartermaster.unbiasedgeek.com/healthz against the public URL — catching TLS, Traefik routing, and the basic-auth middleware in a single probe.

The workflow reads two Forgejo Actions secrets (repo-scoped under archeious/quartermaster):

  • REGISTRY_TOKEN — archeious Forgejo personal token with write:package scope; used as the docker-login password.
  • QUARTERMASTER_SMOKE_PASSWORD — plaintext basic-auth password for the admin user, delivered to the tenant out-of-band by the platform team.

Rollback is manual for v1: git checkout the previous SHA, set QUARTERMASTER_TAG in .env to that SHA, and docker compose up -d from a clone of the repo (or let the previous commit be the main tip and the deploy workflow will roll it out).