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>
200 lines
6.8 KiB
Markdown
200 lines
6.8 KiB
Markdown
# 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](https://github.com/astral-sh/uv) for dependency management
|
|
|
|
## Setup
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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 `EXPOSE`s port 8000 and declares a `HEALTHCHECK` against
|
|
`/healthz`.
|
|
|
|
Build and smoke-run locally against a tempfile database:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
./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](https://forgejo.labbity.unbiasedgeek.com/homelab/homelab-IaC/wiki/PlatformContractQuartermaster)
|
|
for the canonical list.
|
|
|
|
Roll out a new build:
|
|
|
|
```sh
|
|
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).
|