Decision ids are per-vault sequences, so they collide by construction once there is more than one vault -- and every repo now has one. A bare D-15 here will mean this repo's D-15 the moment this repo records one. Cross-vault references are therefore qualified: workspace D-15. Not hypothetical: pql holds D-1 through D-31 while the workspace holds D-1 through D-21, so every workspace id currently collides with an unrelated pql one. A bare id is not wrong the day it is written -- it decays into wrong as the other vault grows, and nothing flags it. Co-Authored-By: Claude <noreply@anthropic.com>
178 lines
8.6 KiB
Markdown
178 lines
8.6 KiB
Markdown
# CLAUDE.md — core-api
|
|
|
|
FastAPI service providing infrastructure management, home automation, and utility
|
|
endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant,
|
|
Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on
|
|
tower-of-joy at **:8083**.
|
|
|
|
## Ports — these differ, deliberately
|
|
|
|
| | Port | How |
|
|
|---|---|---|
|
|
| Local dev | **8788** | `./wakeup.sh`, uvicorn `--reload`, logs to `logs/server.log` |
|
|
| Production | **8083** | container; health at `http://192.168.86.149:8083/health` |
|
|
|
|
Testing `localhost:8083` on the dev box hits the *container*, not your reload server.
|
|
|
|
## Live contract
|
|
|
|
The live contract is always `http://localhost:8083/openapi.json` (62 paths, verified
|
|
2026-08-09) and human docs at `http://localhost:8083/docs` / `/redoc` — generated from
|
|
running code, so query it rather than inferring routes from source or from the README's
|
|
endpoint list, which can drift.
|
|
|
|
## Architecture
|
|
|
|
Domain-first layout under `src/domains/<name>/{controller,models,schemas,service}.py`
|
|
(auth, dashboard, health, housekeeping, infrastructure, static, tools). `src/main.py`
|
|
wires only `src.domains.*` — verify by reading its imports.
|
|
|
|
**Legacy top-level packages — "not in `main.py`" does not mean dead.** Routes are wired
|
|
only from `src.domains.*`, so grepping `main.py`'s imports looks like it settles which
|
|
packages are live. It does not. `main.py:55` calls `initialize_oidc()`, and that function
|
|
(`src/shared/security.py:21`) deliberately imports and configures **both** `src.auth.oidc`
|
|
and `src.domains.auth.oidc` — a function-body import, invisible to a grep of `main.py`.
|
|
That one call drags in `src/auth/`, `src/controllers/`, `src/db/`, `src/logging_config.py`
|
|
and `src/base_schema.py` at startup.
|
|
|
|
Three tiers, established by importing the app inside the container and reading
|
|
`sys.modules` (verified 2026-08-09):
|
|
|
|
| Tier | Packages |
|
|
|---|---|
|
|
| Serving routes | `src/domains/`, `src/shared/`, `src/service_groups/` |
|
|
| **Loaded and configured**, but serving no routes | `src/auth/`, `src/db/`, `src/controllers/`, `src/logging_config.py`, `src/base_schema.py` |
|
|
| Genuinely unreferenced | `src/agent/`, `src/api/`, `src/clients/`, `src/dns/`, `src/memory/`, `src/models/` |
|
|
|
|
`src/auth/` is the trap. Its `oidc_config` singleton is configured at every startup with
|
|
the real Authentik issuers — the log line `src.auth.oidc:configure` proves it — so a test
|
|
importing `src.auth.oidc` is exercising live, configured code, not a fossil. No `src/domains/*`
|
|
module depends on it, so it is configured defensively rather than used; that makes it a
|
|
deletion candidate, but a considered one, not obvious cleanup.
|
|
|
|
**Before deleting anything from `src/`, import the app and read `sys.modules`** rather than
|
|
grepping `main.py`. Function-body imports exist here specifically to dodge circular imports,
|
|
and they are exactly what a grep misses.
|
|
|
|
**That check has its own blind spot, so do not read the third tier as a delete list.** The
|
|
table above is a snapshot taken after a cold `import src.main` — it shows what *startup*
|
|
loads. A module imported inside a request handler would be absent from it while being
|
|
entirely live, and absence would then be a timing artifact rather than evidence of death.
|
|
This bit on webber, where a tool package imported from inside an agent method looked
|
|
unloaded and was serving every request. Nothing in core-api is currently known to work that
|
|
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
|
|
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
|
|
would plausibly load it and take the snapshot again.
|
|
|
|
Some tests (`test_auth_controller.py`, `test_oidc.py`, `test_npm_client.py`,
|
|
`test_portainer_client.py`, `test_static_controller.py`, `test_tools_controller.py`) import
|
|
from the top-level paths rather than `src.domains.*`. Which of those cover live code follows
|
|
the table above — `test_oidc.py` does; the client tests target the unreferenced tier. Not
|
|
cleaned up in this pass; flagged, not fixed.
|
|
|
|
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
|
|
`src/shared/`.
|
|
|
|
Group new work by **domain, not by file type** — a single large `routers/` folder is
|
|
the thing to avoid. Reference: [FastAPI best practices](https://github.com/zhanymkanov/fastapi-best-practices).
|
|
|
|
## Database
|
|
|
|
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (`alembic/versions/`). Models
|
|
live under `src/domains/<name>/models.py` and must be imported in `alembic/env.py` to
|
|
register with `Base.metadata` — check that file when adding a new model or `alembic
|
|
revision --autogenerate` will silently miss it.
|
|
|
|
## Working here
|
|
|
|
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
|
writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and
|
|
does the new behaviour have a test?
|
|
|
|
**Test locally first — the build-deploy loop is slow.** `./wakeup.sh` auto-reloads on
|
|
code changes (not on `requirements.txt` changes; restart the container/script after
|
|
adding a dependency). Deploy only when a feature is complete and tested.
|
|
|
|
Run tests through the venv explicitly, to avoid environment mismatch:
|
|
|
|
```bash
|
|
.venv/bin/python -m pytest tests/
|
|
# or, with coverage:
|
|
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
|
|
```
|
|
|
|
Copy `.env.example` to `.env` and configure Portainer, NPM, Home Assistant, SearXNG,
|
|
Postgres, and Qdrant hosts/credentials.
|
|
|
|
No linter is configured in this repo (no ruff/flake8 config, none in `requirements.txt`
|
|
or `dev-requirements.txt`) — unlike some sibling repos, don't assume `ruff check` exists
|
|
here.
|
|
|
|
## CI
|
|
|
|
`.gitea/workflows/build.yml` is the only workflow: triggered on `v*` tag push, it
|
|
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
|
|
**no CI test/lint gate** — pytest only runs locally or on request. Verify tests pass
|
|
before tagging a release.
|
|
|
|
## Work tracking
|
|
|
|
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets
|
|
and its internal decisions live here in `.pql/` and `governance/`, and travel with a clone,
|
|
because `.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are
|
|
gitignored and rebuildable with `pql plan rebuild`.
|
|
|
|
`pql` is **not** on the non-interactive `PATH` — invoke it as
|
|
`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors
|
|
at the nearest `.git/` ancestor, which is this repo.
|
|
|
|
```bash
|
|
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
|
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
|
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
|
```
|
|
|
|
Stack-level decisions that constrain this service live in the **workspace** vault and need the
|
|
flag:
|
|
|
|
```bash
|
|
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
|
|
```
|
|
|
|
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
|
to a workspace decision. Cite the id in the ticket body instead.
|
|
|
|
Do not add a TODO section to a markdown file.
|
|
|
|
## Git
|
|
|
|
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch
|
|
that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a
|
|
feature branch for every change; that rule was retired workspace-wide on 2026-08-08
|
|
and does not apply here anymore.)
|
|
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
|
- **Atomic commits** — one logical change each.
|
|
- **Stage explicitly. Never `git add -A`** — it is denied by policy, and it sweeps in
|
|
whatever else is dirty, including secrets.
|
|
- Update `CHANGELOG.md` with every user-facing change, under `[Unreleased]` in `Added` /
|
|
`Changed` / `Fixed`.
|
|
|
|
## Releasing
|
|
|
|
Ask whether a deploy is wanted first — it is not automatic.
|
|
|
|
1. Bump the version in `pyproject.toml` (patch for fixes, minor for features).
|
|
2. Move `[Unreleased]` entries into a dated version section in `CHANGELOG.md`.
|
|
3. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
|
4. Gitea CI (`build.yml`) builds and pushes the image on the tag; Watchtower deploys it.
|
|
5. Verify: `curl http://192.168.86.149:8083/health`.
|
|
|
|
## Security
|
|
|
|
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
|
|
- Admin endpoints require authentication when `OIDC_ENABLED=true`.
|
|
- README claims the container "runs as non-root user (uid 1000)" — **checked and
|
|
false**: the Dockerfile has no `USER` directive, so the container runs as root.
|
|
Not fixed here (out of scope for a docs normalization pass); flagging so it isn't
|
|
restated as fact.
|