Files
awkawbandClaude Sonnet 5 2c9915699d nixos-module: run db migrations on every activation, not just once
The .initial-migration marker file prevented `migrate --force` from ever
running again after the first activation, so migrations shipped by a later
invoiceninja version upgrade would never apply to an already-deployed
instance. `migrate` is idempotent (Laravel tracks applied migrations in the
DB), so just run it unconditionally on every activation instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 13:15:00 -04:00

156 lines
12 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
A Nix flake that packages [Invoice Ninja](https://github.com/invoiceninja/invoiceninja) (a PHP/Laravel app with a
separate React frontend) and provides a NixOS module (`services.invoiceninja`) to deploy it as a full service:
PHP-FPM pool, nginx or Caddy reverse proxy, MariaDB, Redis, a queue-worker systemd unit, and a one-shot
migration/setup unit.
There is no application source code here — this is packaging/deployment code only. All behavior changes come from
editing Nix expressions.
## Commands
This project uses `just` (see `justfile`) and Nix directly.
- `just build-vm [proxy]` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test-<proxy>`)
via `nom build`. `proxy` is `nginx` (default) or `caddy`.
- `just boot-vm` — boots the built VM (`result/bin/run-nixos-vm`) in the current terminal. Serves Invoice Ninja on
`localhost:8080` (see `QEMU_NET_OPTS` in the `justfile`) with root login `toor`.
- `just clean` — removes `result`/`nixos.qcow2` and kills any running `qemu` process.
- `just test` — runs the automated test suite (`nix flake check -L`).
- `nix build .#invoiceninja` — build just the Invoice Ninja package.
- `nix build .#checks.x86_64-linux.invoiceninja -L` — run only the automated VM test, with build/test log output.
- `nix flake check` — evaluate the flake (module options, overlay, package derivation) and run the automated test.
### `tests/`
- `common.nix` — the shared `services.invoiceninja` config used by both the interactive VM and the automated test:
imports the module and points at the checked-in test fixtures (`invoice_ninja_test_password`, `test.env`). It's a
function taking `{ proxyServer ? "nginx" }` so callers can pick which proxy backend to test.
- `default.nix` — the interactive VM config (`nixosConfigurations.test-nginx`/`test-caddy` in `flake.nix`), used
for manual, click-around verification via `just build-vm [proxy]` / `just boot-vm`. It's also a function taking
`{ proxyServer ? "nginx" }`, forwarded straight through to `common.nix`, so `flake.nix` can instantiate one
`nixosConfigurations` entry per proxy backend. Adds the qemu-guest profile and a root login. It also sets a
dummy `fileSystems."/"` and disables `boot.loader.grub` purely to satisfy NixOS's built-in assertions when
`nix flake check` evaluates
`config.system.build.toplevel` directly — the qemu-vm module overrides both with a tmpfs root and direct kernel
boot when actually building `config.system.build.vm` (what `just build-vm` uses), so these values are otherwise
unused. Also raises `virtualisation.cores`/`memorySize` to 2/2560 (qemu-vm's defaults, 1 core/1024MB, are too
tight for real interactive use — see the module architecture notes below on PDF generation's memory cost).
- `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks.<system>.invoiceninja` in
`flake.nix`). Builds its own `pkgs` externally (needed regardless of config, since `nixosTest` itself is called
as `pkgs.testers.nixosTest`) and boots two nodes from `common.nix` — one with `proxyServer = "nginx"`, one with
`proxyServer = "caddy"` — so both proxy backends get exercised. The shared
assertion suite (factored into a `check_invoiceninja(machine, proxy_unit)` function in the test script) runs
against each node: waits for mysql/redis/phpfpm/queue-worker/proxy to be active, checks the
`invoiceninja-data-setup` marker files (`.db-seeded`, `.admin-created`) to confirm the
one-time setup actually completed (not just that the oneshot unit exited), curls `/` for the app shell, exercises
the `invoiceninja-manage` wrapper, logs in via `/api/v1/login` using the test admin credentials as an
end-to-end check that migrations/seeding/account creation all worked, checks the full language list is
available (see the `cache:forget languages` note above), and exercises PDF generation end-to-end (POSTs to
`/api/v1/preview` with no entity, which falls back to a self-contained mock PDF built via the same
chromium/snappdf pipeline real invoices use, and checks for the `%PDF` magic bytes — no client/invoice setup
needed). This is the only automated test in the repo — there is no separate unit-test suite.
## Architecture
### `package.nix`
Builds Invoice Ninja in two stages:
1. **`ui`** — the separate `invoiceninja/ui` React frontend repo, built with `buildNpmPackage`. Patches
`.env.example` to disable test mode and swaps in `vite.config.ts.react` before building.
2. **Main package** — built with `php82.buildComposerProject` from the `invoiceninja/invoiceninja` PHP repo. The
`ui` build's `dist/` output is copied into `public/` before the PHP build (`preConfigure`), and
`public/index.html` is copied to `resources/views/react/index.blade.php` so Laravel serves the React shell.
Both source fetches (`fetchFromGitHub`) run their lockfiles through `npm-lockfile-fix` in `postFetch` to patch
missing integrity fields — required for `fetchNpmDeps`/`buildNpmPackage` to work with these upstream lockfiles.
Because Laravel needs writable directories that can't live in the read-only Nix store, `postInstall` renames
`bootstrap` → `bootstrap-static` and `storage` → `storage-static` inside the derivation, then symlinks
`$out/.env`, `$out/storage`, `$out/public/storage`, and `$out/bootstrap` to paths under the module's `dataDir` /
`runtimeDir`. This means **the package output is only fully functional when paired with the NixOS module** (or
something else that populates those runtime paths the same way) — building the package alone will produce
dangling symlinks.
When bumping the Invoice Ninja or UI version in `package.nix`, all four hashes (`src.hash`, `uiSrc.hash`,
`ui`'s `npmDepsHash`, and the main package's `vendorHash`/`npmDeps.hash`) generally need to be recomputed —
expect Nix to report the correct hash on a failed build (or use `nix-prefetch`/`lib.fakeHash` workflow).
### `nixos-module/`
- `default.nix` — the flake's `nixosModules` output. Exposes the `invoiceninja` module and a small
`overlayNixpkgsForThisInstance` module that applies the flake's overlay (registers `pkgs.invoiceninja`) to
whatever nixpkgs instance imports it.
- `invoiceninja.nix` — the actual `services.invoiceninja` module. Key structural points:
- `cfg.settings` is a submodule with a `freeformType` (`pkgs.formats.keyValue`), so any Laravel `.env` key can be
set even if not explicitly declared as an option; explicitly declared options (APP_ENV, MAIL_MAILER,
SESSION_DRIVER, etc.) exist mainly to add type-checking/enums and sensible computed defaults (e.g.
SESSION_DRIVER/QUEUE_CONNECTION/CACHE_DRIVER default to redis → database → file depending on what's enabled).
- **Secrets are deliberately kept out of `cfg.settings`/the Nix store.** `secretFile` (required) and
`adminAccount.passwordFile` (required) are consumed via systemd `LoadCredential` in the
`invoiceninja-data-setup` service, not written into the generated `.env` derivation. The final `.env` is
assembled at activation time by concatenating the Nix-store-rendered non-secret settings with the secret file
contents directly on disk in `dataDir`.
- `invoiceninja-data-setup.service` (oneshot, runs on every activation) does all first-run and idempotent setup:
clears bootstrap/opcode cache, rebuilds `.env`, rsyncs static storage into `dataDir`, symlinks the runtime
`app.php`, runs `migrate --force` unconditionally on every activation (safe/idempotent — Laravel tracks
applied migrations in the DB and only runs new ones; this must NOT be marker-gated, since a later invoiceninja
version upgrade can ship new migrations that need to run on an already-migrated install), and guards the
remaining one-time actions (`db:seed`, admin account creation) behind marker files (`.db-seeded`,
`.admin-created`) in `dataDir`. `phpfpm-invoiceninja.service` and
`invoiceninja-queue-worker.service` both depend on it. It also unconditionally runs
`invoiceninja-manage cache:forget languages` on every activation, working around an upstream bug: migration
`2025_08_31_224356_additional_languages_af_ca_id.php` runs during `migrate` (before `db:seed`'s
`LanguageSeeder` has populated the other 42 rows) and does `Cache::forever('languages', Language::all())`,
permanently caching just the 3 languages it sees at that moment — without this, the language dropdown in
the app stays stuck at 3 entries forever, since `StaticServiceProvider` checks that cache key before ever
querying the DB again.
- `invoiceninja-scheduler.timer`/`.service` run `artisan schedule:run` every minute — this is what drives
Invoice Ninja's own internal cron-scheduled jobs (recurring invoices, scheduled reports, etc.); without it the
app's dashboard reports "cron jobs are not enabled". Laravel's scheduler is just a dispatcher, so this one
timer is sufficient regardless of how many scheduled tasks Invoice Ninja itself defines.
- `invoiceninja-manage` is a generated wrapper script (`invoiceninja-manage <artisan-args>`) that `cd`s into the
package output and runs `php artisan` as the configured service user (via `sudo` if invoked as another user).
It's added to `environment.systemPackages`, so it's the normal way to run artisan commands
(`migrate`, `queue:work`, `ninja:create-account`, etc.) by hand on a deployed system.
- Both `nginx` and `caddy` proxy backends are supported (`proxy.server`), each independently duplicating the
PHP-FPM passthrough config — if changing one, check whether the other needs the same change. Neither sets
CORS headers itself: Laravel's own CORS middleware already sends a complete set on every response, and an
earlier version of this module additionally set a second, static set of `Access-Control-*` headers at the
proxy level, which browsers reject as conflicting duplicates (server-to-server/curl checks don't notice,
since only browsers enforce CORS header validation).
- Caddy matches vhosts on the exact `Host` header, unlike nginx (where a single vhost acts as the default
regardless of `Host`). When `hostname == "localhost"`, the caddy vhost adds `serverAliases` for
`127.0.0.1:80`/`[::1]:80` too — otherwise visiting the VM via the IP literal instead of `localhost` matches no
vhost and Caddy silently returns an empty `200 OK` (a blank page with no error anywhere).
- A headless browser is required at runtime for PDF generation (`PDF_GENERATOR=snappdf`,
`SNAPPDF_CHROMIUM_PATH`) and is added to the PHP-FPM and data-setup services' `path`. This is
`nixpkgs.playwright-driver`'s `chromium-headless-shell` component (Google's official "Chrome for Testing"
headless-only build, selected via `playwright-driver.browsers.override { ... }` with every other
browser/ffmpeg disabled) rather than `pkgs.chromium` — snappdf only ever shells out to
`$SNAPPDF_CHROMIUM_PATH --headless --print-to-pdf=...`, so the full browser UI/GPU stack `pkgs.chromium` ships
is dead weight; the headless-shell closure is ~1.45GB smaller and needs no `allowUnfree`. Each PDF request
still launches a full instance of the underlying browser engine (not a lightweight renderer), and PHP-FPM's
default `pm.max_children = 8` means up to 8 of these can run concurrently — on a memory-constrained deployment
this can exhaust RAM (confirmed: even a couple of concurrent PDF requests OOM-killed a 1024MB VM). Size
deployments accordingly, or lower `phpfpm.settings."pm.max_children"`.
### `flake.nix`
Standard flake shape: `overlays.default` registers the package, `packages.<system>.invoiceninja` exposes it,
`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test-nginx`/`test-caddy` each build
the VM defined in `tests/default.nix` with the corresponding `proxyServer`. Only `x86_64-linux` is currently
supported.
## Conventions
- Nix files: 2-space indentation (see `.editorconfig`); Unix line endings; final newline required.
- Prefer `lib.mkDefault`/`lib.mkIf`/`lib.mkMerge` for module config so downstream users can override cleanly —
this is used pervasively in `invoiceninja.nix` and should be followed for new options/config.