diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7714107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/result +/nixos.qcow2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..758399e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# 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` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test`) via `nom build`. +- `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, enables nginx, and points at the checked-in test fixtures (`invoice_ninja_test_password`, + `test.env`). +- `default.nix` — the interactive VM config (`nixosConfigurations.test` in `flake.nix`), used for manual, + click-around verification via `just build-vm` / `just boot-vm`. Adds the qemu-guest profile, a root login, and + `nixpkgs.config.allowUnfree = true` (required to build `pkgs.chromium`). 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. +- `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks..invoiceninja` in + `flake.nix`). Builds its own `pkgs` (with `allowUnfree` baked in — `nixosTest` forbids setting `nixpkgs.config` + on a node once `pkgs` is externally supplied) and boots a VM with `common.nix`, then: waits for + mysql/redis/phpfpm/queue-worker/nginx to be active, checks the `invoiceninja-data-setup` marker files + (`.initial-migration`, `.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, and logs + in via `/api/v1/login` using the test admin credentials as an end-to-end check that migrations/seeding/account + creation all worked. 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`, and guards one-time actions (`migrate`, `db:seed`, admin account creation) behind marker files + (`.initial-migration`, `.db-seeded`, `.admin-created`) in `dataDir`. `phpfpm-invoiceninja.service` and + `invoiceninja-queue-worker.service` both depend on it. + - `invoiceninja-manage` is a generated wrapper script (`invoiceninja-manage `) 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 + CORS headers and PHP-FPM passthrough config — if changing one, check whether the other needs the same change. + - Chromium 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`. + +### `flake.nix` + +Standard flake shape: `overlays.default` registers the package, `packages..invoiceninja` exposes it, +`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test` builds the VM defined in +`tests/`. 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. diff --git a/flake.nix b/flake.nix index 8c137c1..a5352f4 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,10 @@ default = pkgsBySystem.${system}.invoiceninja; }); + checks = forEachSystem (system: { + invoiceninja = import ./tests/nixos-test.nix { inherit system; nixpkgsPath = nixpkgs; }; + }); + nixosModules = import ./nixos-module { overlays = overlayList; }; }; } diff --git a/justfile b/justfile index 4a777e6..470011f 100644 --- a/justfile +++ b/justfile @@ -31,3 +31,8 @@ boot-vm: ./result/bin/run-nixos-vm reset +[doc('Run the automated NixOS VM test')] +[group('main')] +test: + nix flake check -L + diff --git a/tests/common.nix b/tests/common.nix new file mode 100644 index 0000000..39a089a --- /dev/null +++ b/tests/common.nix @@ -0,0 +1,15 @@ +{ ... }: + +{ + imports = [ ../nixos-module/invoiceninja.nix ]; + + services.invoiceninja = { + enable = true; + proxy.server = "nginx"; + adminAccount.passwordFile = ./invoice_ninja_test_password; + secretFile = ./test.env; + settings.APP_DEBUG = true; + }; + + networking.firewall.enable = false; +} diff --git a/tests/default.nix b/tests/default.nix index 3eb6a12..8f0c6f5 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -3,25 +3,26 @@ { imports = [ (modulesPath + "/profiles/qemu-guest.nix") - ../nixos-module/invoiceninja.nix + ./common.nix ]; + # Required to build pkgs.chromium, which the module pulls in for PDF generation. nixpkgs.config.allowUnfree = true; + # `nix flake check` evaluates config.system.build.toplevel directly, which asserts a real root + # filesystem and bootloader are configured. `just build-vm` builds config.system.build.vm + # instead, where the qemu-vm module overrides both of these with a tmpfs root and direct + # kernel boot (virtualisation.useBootLoader defaults to false) - so these values just need to + # satisfy the assertions and are otherwise unused. + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + boot.loader.grub.enable = false; + users.users.root = { initialPassword = "toor"; }; - services.invoiceninja = { - enable = true; - proxy.server = "nginx"; - adminAccount.passwordFile = ./invoice_ninja_test_password; - secretFile = ./test.env; - settings.APP_DEBUG = true; - }; - - networking.firewall.enable = false; - services.resolved.enable = true; } - diff --git a/tests/nixos-test.nix b/tests/nixos-test.nix new file mode 100644 index 0000000..c444d99 --- /dev/null +++ b/tests/nixos-test.nix @@ -0,0 +1,63 @@ +{ nixpkgsPath, system }: + +let + # nixosTest forbids setting `nixpkgs.config` on a node once `pkgs` is externally supplied, + # so allowUnfree (required to build pkgs.chromium) has to be baked into this pkgs instance + # up front rather than set from within common.nix like the interactive VM does. + pkgs = import nixpkgsPath { + inherit system; + config.allowUnfree = true; + }; + + adminEmail = "admin@email.com"; + adminPassword = pkgs.lib.strings.trim (builtins.readFile ./invoice_ninja_test_password); +in +pkgs.testers.nixosTest { + name = "invoiceninja"; + + nodes.machine = { + imports = [ ./common.nix ]; + }; + + testScript = '' + machine.start() + + machine.wait_for_unit("mysql.service") + machine.wait_for_unit("redis-invoiceninja.service") + machine.wait_for_unit("phpfpm-invoiceninja.service") + machine.wait_for_unit("invoiceninja-queue-worker.service") + machine.wait_for_unit("nginx.service") + machine.wait_for_open_port(80) + + # phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup, + # so systemd already waited for it to finish above. Confirm the one-time setup steps + # it guards actually completed, rather than just that the unit exited. + with subtest("one-time setup (migrate, seed, admin account) completed"): + machine.succeed("test -f /var/lib/invoiceninja/.initial-migration") + machine.succeed("test -f /var/lib/invoiceninja/.db-seeded") + machine.succeed("test -f /var/lib/invoiceninja/.admin-created") + + with subtest("application shell is served over HTTP"): + response = machine.succeed("curl -sf http://localhost/") + assert "Invoice Ninja" in response, f"unexpected response from /: {response}" + + with subtest("web UI login page is served"): + # The UI is a client-side-routed React SPA, so curl (no JS) only sees the same + # index.html shell as "/" - this confirms nginx/Laravel resolve the /login route + # to that shell instead of 404ing, which is what a browser needs before it can + # render the actual login form. + login_page = machine.succeed("curl -sf http://localhost/login") + assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}" + + with subtest("invoiceninja-manage wrapper can run artisan commands"): + machine.succeed("invoiceninja-manage migrate:status") + + with subtest("admin account can log in via the API"): + login_response = machine.succeed( + "curl -sf -X POST http://localhost/api/v1/login " + + "-H 'Content-Type: application/json' " + + "-d '{\"email\":\"${adminEmail}\",\"password\":\"${adminPassword}\"}'" + ) + assert "${adminEmail}" in login_response, f"login did not succeed: {login_response}" + ''; +}