Files
core-api/CLAUDE.md
T
jpmschweitzerandClaude 29bfa3259d docs: replace AGENTS.md with a repo-specific CLAUDE.md
One agent doc per repo, and it is CLAUDE.md. Two docs describing one repo
drift, and the one nobody read is always the one holding the rule that
mattered. Written fresh rather than reformatted, so the structure follows
what someone working here actually needs.

Two rules from the old file are gone deliberately. The mandate to branch
for every change was retired in favour of one linear-history policy, and
the release snippet used `git add -A`, which sweeps in whatever else is
dirty.

The architecture section is the part worth reading. An earlier draft
called src/auth, src/controllers, src/clients, src/dns and src/models
dead code, derived from grepping main.py's imports. That was wrong:
main.py:55 calls initialize_oidc(), which imports and configures
src.auth.oidc from inside the function body, so src/auth is configured
with live Authentik issuers on every boot. It also missed four genuinely
unreferenced packages. The section now states the method used -- import
the app in the container and read sys.modules -- and its blind spot, that
a cold snapshot cannot see a module imported on a request path.

Also records that the README's "runs as non-root user (uid 1000)" is
false: the Dockerfile has no USER directive. Flagged rather than fixed,
since changing the runtime user is not a docs change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 03:12:02 +02:00

8.6 KiB

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.

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:

.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 (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.

/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:

/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.