From 5fb8e8d724b8eef47217cab9f30023517a981a64 Mon Sep 17 00:00:00 2001 From: "0m.ax" Date: Sun, 1 Nov 2026 19:54:55 +0100 Subject: [PATCH] Add Nix package, NixOS module, and tests for bambuddy - packages.bambuddy: FastAPI backend + prebuilt React frontend assembled into the app-root layout upstream expects, behind a single `bambuddy` launcher (uvicorn --loop asyncio, ffmpeg on PATH, XDG state defaults). Pins upstream maziggy/bambuddy v1.2.5.7. - nixosModules.default: services.bambuddy with host/port/dataDir, openFirewall, environment + environmentFile, extraPackages, DynamicUser + StateDirectory, systemd hardening, and CAP_NET_BIND_SERVICE for the virtual-printer FTPS/RTSP ports. - checks.bambuddy-import: backend import + admin CLI smoke test. - checks.bambuddy-service: two-node NixOS VM test covering the health endpoint, UI/static assets/manifest, SQLite + logs in the StateDirectory, journal startup check, cross-node reachability with openFirewall, custom host/port/env node, and loopback-only binding. - overlay, run app, devShell, README, .gitignore. --- .gitignore | 2 + README.md | 109 ++++++++++++++++++++ flake.lock | 27 +++++ flake.nix | 98 ++++++++++++++++++ nixos/module.nix | 174 ++++++++++++++++++++++++++++++++ pkgs/bambuddy/default.nix | 204 ++++++++++++++++++++++++++++++++++++++ pkgs/bambuddy/wrapper.sh | 28 ++++++ tests/import-check.nix | 27 +++++ tests/service-test.nix | 92 +++++++++++++++++ 9 files changed, 761 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nixos/module.nix create mode 100644 pkgs/bambuddy/default.nix create mode 100644 pkgs/bambuddy/wrapper.sh create mode 100644 tests/import-check.nix create mode 100644 tests/service-test.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750baeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +result +result-* diff --git a/README.md b/README.md new file mode 100644 index 0000000..40d3c73 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# bambuddy-nix + +Nix packaging for [Bambuddy](https://github.com/maziggy/bambuddy) — a +self-hosted command center for Bambu Lab 3D printers (works offline via +Developer Mode, no cloud dependency). + +This flake provides: + +- **`packages..bambuddy`** — the app: FastAPI backend + prebuilt React + frontend behind a single `bambuddy` launcher +- **`nixosModules.bambuddy`** — a `services.bambuddy` NixOS module +- **checks** — an import smoke test and NixOS VM integration tests + +## Quick start (NixOS) + +```nix +# flake.nix +{ + inputs.bambuddy-nix.url = "github:/bambuddy-nix"; + + outputs = { self, nixpkgs, bambuddy-nix, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + bambuddy-nix.nixosModules.default + ./configuration.nix + ]; + }; + }; +} +``` + +```nix +# configuration.nix +services.bambuddy = { + enable = true; + port = 8000; # web UI / API + openFirewall = true; # reachable from the LAN +}; +``` + +The dashboard is then available at `http://:8000`. Add your printer(s) +through the first-run wizard — the printer must be on the same LAN with +Developer Mode enabled. + +### Module options + +| Option | Default | Description | +| --- | --- | --- | +| `services.bambuddy.enable` | `false` | Enable the service | +| `services.bambuddy.package` | flake's `bambuddy` | Package to run | +| `services.bambuddy.host` | `"0.0.0.0"` | Bind address (use `127.0.0.1` behind a reverse proxy) | +| `services.bambuddy.port` | `8000` | Web UI / API port | +| `services.bambuddy.dataDir` | `/var/lib/bambuddy` | SQLite DB, print archive, plate calibration, logs | +| `services.bambuddy.openFirewall` | `false` | Open the web port for LAN access | +| `services.bambuddy.environment` | `{}` | Extra env vars (see upstream `.env.example`) | +| `services.bambuddy.environmentFile` | `null` | Env file for secrets (`MFA_ENCRYPTION_KEY`, OIDC, …) | +| `services.bambuddy.extraPackages` | `[]` | Extra tools on the service PATH (e.g. `tailscale`) | + +The service runs as a `DynamicUser` with a `StateDirectory`, ships `ffmpeg` +on its PATH (timelapses/camera), grants `CAP_NET_BIND_SERVICE` (virtual +printer FTPS/RTSP ports), and starts after `network-online.target`. + +### Reverse proxy + +Bambuddy serves everything (UI, API, websockets, camera streams) on one port +with absolute `/assets` URLs — proxy `Location /` to `http://127.0.0.1:8000` +with websocket upgrade support. + +## Running without NixOS + +```sh +nix run .#bambuddy # binds 0.0.0.0:8000, state in ~/.local/share/bambuddy +PORT=9000 HOST=127.0.0.1 nix run .#bambuddy +``` + +## Tests + +```sh +nix flake check # everything +nix build .#checks.x86_64-linux.bambuddy-import # backend import smoke test +nix build .#checks.x86_64-linux.bambuddy-service # NixOS VM integration test +``` + +The VM test boots two virtual machines: one with the default configuration +(checks `/health`, the React UI, static assets, manifest, SQLite/StateDirectory +init and cross-node firewall access) and one exercising the module options +(custom host/port, extra environment, loopback-only binding). + +## Updating + +The package pins an upstream release in `pkgs/bambuddy/default.nix`: + +```nix +version = "1.2.5.7"; +src = fetchFromGitHub { + owner = "maziggy"; + repo = "bambuddy"; + rev = ""; + hash = "sha256-..."; +}; +``` + +To bump: change `version` + `rev`, update `hash`, and refresh +`npmDepsHash` (a failed build prints the expected value). + +## License + +Bambuddy is AGPL-3.0; this packaging is provided under the same license. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d777f2e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1793341285, + "narHash": "sha256-Y1CiZ4SoBUjLQyQM/pY+rS2dXD3CHLPstF0lmOc73D4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c89083e451e891152365b799595cf7790a43325", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4eba3a0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,98 @@ +{ + description = "Bambuddy — self-hosted command center for Bambu Lab 3D printers (package + NixOS module)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + + pkgsFor = system: nixpkgs.legacyPackages.${system}; + in + { + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + rec { + bambuddy = pkgs.callPackage ./pkgs/bambuddy { }; + default = bambuddy; + } + ); + + overlays.default = final: prev: { + bambuddy = self.packages.${final.stdenv.hostPlatform.system}.bambuddy; + }; + + nixosModules = rec { + # Wires the module against this flake's own package as the default, + # so `services.bambuddy.enable = true;` works out of the box. + bambuddy = + { lib, pkgs, ... }: + { + imports = [ ./nixos/module.nix ]; + services.bambuddy.package = lib.mkOptionDefault self.packages.${pkgs.stdenv.hostPlatform.system}.bambuddy; + }; + default = bambuddy; + }; + + checks = forAllSystems ( + system: + let + pkgs = pkgsFor system; + bambuddy = self.packages.${system}.bambuddy; + in + { + bambuddy-import = pkgs.callPackage ./tests/import-check.nix { inherit bambuddy; }; + } + // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + bambuddy-service = pkgs.callPackage ./tests/service-test.nix { + inherit bambuddy; + nixosModule = self.nixosModules.default; + }; + } + ); + + apps = forAllSystems ( + system: + let + bambuddy = self.packages.${system}.bambuddy; + in + { + default = { + type = "app"; + program = "${bambuddy}/bin/bambuddy"; + meta.description = "Run the Bambuddy server"; + }; + } + ); + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + bambuddy = self.packages.${system}.bambuddy; + in + { + default = pkgs.mkShell { + packages = [ + bambuddy.passthru.pythonEnv + pkgs.nodejs + pkgs.ffmpeg + pkgs.nixfmt + ]; + }; + } + ); + }; +} diff --git a/nixos/module.nix b/nixos/module.nix new file mode 100644 index 0000000..af4af1a --- /dev/null +++ b/nixos/module.nix @@ -0,0 +1,174 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.bambuddy; + + # The default dataDir maps to a systemd StateDirectory, which gets proper + # ownership/permission handling for the DynamicUser. Custom paths must be + # writable already; we grant access via ReadWritePaths. + stateDirName = "bambuddy"; + defaultDataDir = "/var/lib/${stateDirName}"; + dataDirManaged = cfg.dataDir == defaultDataDir; +in +{ + options.services.bambuddy = { + enable = lib.mkEnableOption "Bambuddy, a self-hosted command center for Bambu Lab 3D printers"; + + package = lib.mkOption { + type = lib.types.package; + description = '' + The bambuddy package to run. Defaults to the package built by this + flake. + ''; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = '' + Address the web UI and API bind to. Use `127.0.0.1` when Bambuddy + should only be reachable via a reverse proxy on the same host. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8000; + description = "Port the web UI and API listen on."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = defaultDataDir; + description = '' + Persistent state directory: SQLite database, print archive, plate + calibration data and logs live here. The default path under + `/var/lib` is created automatically. Custom paths are used as-is and + must be writable by the service user (a DynamicUser). + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Open the web port in the firewall so the dashboard is reachable from + the LAN. Bambuddy itself always initiates printer connections + (MQTT/FTP/RTSP), so no other inbound ports are required for printer + communication. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression '' + { + LOG_LEVEL = "DEBUG"; + VIRTUAL_PRINTER_PASV_ADDRESS = "192.168.1.10"; + BAMBUDDY_EXTERNAL_ROOTS = "/mnt/nas/3d-prints"; + } + ''; + description = '' + Extra environment variables for Bambuddy. See upstream + `.env.example` for the full catalogue (e.g. `DATABASE_URL` for + external PostgreSQL, `MFA_ENCRYPTION_KEY`, `SLICER_API_URL`, + `BAMBUDDY_EXTERNAL_ROOTS`, `USE_SYSTEM_TRUST_STORE`). + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/secrets/bambuddy.env"; + description = '' + Environment file (KEY=value lines) loaded into the service. Useful + for secrets that should not live in the world-readable Nix store, + such as `MFA_ENCRYPTION_KEY` or OIDC credentials. + ''; + }; + + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + example = lib.literalExpression "[ pkgs.tailscale ]"; + description = '' + Additional packages made available on the service PATH (ffmpeg is + always included). For example, add `tailscale` when using the + virtual printer's Tailscale certificate integration. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + + systemd.services.bambuddy = { + description = "Bambuddy — self-hosted command center for Bambu Lab 3D printers"; + documentation = [ "https://wiki.bambuddy.cool" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = { + HOST = cfg.host; + PORT = toString cfg.port; + DATA_DIR = cfg.dataDir; + LOG_DIR = "${cfg.dataDir}/logs"; + HOME = cfg.dataDir; + MPLCONFIGDIR = "${cfg.dataDir}/.cache/matplotlib"; + UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN = "10"; + } // cfg.environment; + + path = [ pkgs.ffmpeg ] ++ cfg.extraPackages; + + serviceConfig = { + ExecStart = lib.getExe cfg.package; + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) [ cfg.environmentFile ]; + + DynamicUser = true; + StateDirectory = lib.mkIf dataDirManaged stateDirName; + ReadWritePaths = lib.mkIf (!dataDirManaged) [ cfg.dataDir ]; + + # The virtual printer FTP (990) and RTSP camera proxy (322) listeners + # bind privileged ports as the unprivileged service user — the same + # NET_BIND_SERVICE grant the upstream docker-compose.yml uses. + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + + Restart = "on-failure"; + RestartSec = 5; + TimeoutStopSec = 30; + + # Hardening. Bambuddy talks to printers over MQTT/FTP/RTSP and shells + # out to ffmpeg, hence AF_INET/AF_UNIX and an exec-capable /tmp. + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + "AF_NETLINK" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + SystemCallArchitectures = "native"; + }; + }; + }; +} diff --git a/pkgs/bambuddy/default.nix b/pkgs/bambuddy/default.nix new file mode 100644 index 0000000..523a3fa --- /dev/null +++ b/pkgs/bambuddy/default.nix @@ -0,0 +1,204 @@ +{ + lib, + stdenv, + fetchFromGitHub, + buildNpmPackage, + python3, + makeWrapper, + ffmpeg, +}: +let + pname = "bambuddy"; + version = "1.2.5.7"; + + src = fetchFromGitHub { + owner = "maziggy"; + repo = "bambuddy"; + rev = "188df76f8a7342542c0876dac68990ea2a8c8313"; # v1.2.5.7 + hash = "sha256-KmlAdFoSIMa6Wahd4XPBvaBtpVR/5LPNMyaimuDZVb0="; + }; + + # Runtime Python dependencies, mirroring upstream requirements.txt. + # All of these ship in nixpkgs. Optional heavy extras (opencv4, + # fast-simplification) are included for feature parity with the upstream + # Docker image, which installs requirements.txt in full; the affected + # features (plate detection, STL thumbnails) degrade gracefully if absent. + pythonDeps = + ps: with ps; [ + # Web framework + fastapi + uvicorn + uvloop + httptools + websockets + python-dotenv + + # Database + sqlalchemy + aiosqlite + asyncpg + greenlet + + # Pydantic / settings + pydantic + pydantic-settings + + # Bambu Lab printer communication (MQTT / FTP) + paho-mqtt + aioftp + pyftpdlib + cryptography + pyopenssl + asyncssh + + # 3MF processing + defusedxml + + # Excel export + openpyxl + + # Utilities + python-multipart + aiofiles + + # QR codes / PDF generation / PDF thumbnails + qrcode + reportlab + pypdfium2 + + # STL thumbnails + trimesh + matplotlib + fast-simplification + networkx + lxml + + # System monitoring + psutil + + # Authentication + pyjwt + passlib + bcrypt + ldap3 + pyotp + + # HTTP clients + idna + httpx + certifi + curl-cffi + urllib3 + aiohttp + + # Plate detection + opencv4 + numpy + pillow + ]; + + pythonEnv = python3.withPackages pythonDeps; + + + # Prebuilt React frontend. `vite build` emits to `../static` (the app root, + # next to `backend/`), matching the upstream Dockerfile. + frontend = buildNpmPackage { + pname = "${pname}-web"; + inherit version src; + + sourceRoot = "${src.name}/frontend"; + npmDepsHash = "sha256-S+WU4jgs2DPWHHfspeo5k8vPECK+5SidGa8kzI4K4lw="; + + # The source tree arrives from the store read-only, but `vite build` + # empties and writes ../static (the repo ships a committed static/ dir). + preBuild = '' + chmod -R u+w "$NIX_BUILD_TOP/${src.name}" + rm -rf ../static + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/share + cp -r ../static $out/share/static + runHook postInstall + ''; + + meta = { + description = "Web frontend for Bambuddy"; + license = lib.licenses.agpl3Only; + platforms = lib.platforms.unix; + }; + }; +in +stdenv.mkDerivation { + inherit pname version src; + + dontBuild = true; + + nativeBuildInputs = [ makeWrapper ]; + + buildInputs = [ pythonEnv ]; + + passthru = { + inherit pythonEnv frontend; + }; + + installPhase = '' + runHook preInstall + + # App root: `backend/` (Python sources) + `static/` (prebuilt frontend). + # backend.app.core.config resolves both relative to this root, exactly + # like the upstream Docker image (/app/backend + /app/static). + mkdir -p $out/share/bambuddy + cp -r ${src}/backend $out/share/bambuddy/backend + cp -r ${frontend}/share/static $out/share/bambuddy/static + chmod -R u+w $out/share/bambuddy + + # The upstream test suite is not shipped in the runtime package. + rm -rf $out/share/bambuddy/backend/tests + find $out/share/bambuddy -type d -name __pycache__ -prune -exec rm -rf {} + + + install -Dm644 ${src}/LICENSE -t $out/share/licenses/bambuddy + + mkdir -p $out/bin + install -Dm755 ${./wrapper.sh} $out/bin/bambuddy + # Substitute at build time so @out@ resolves to THIS package's path. + # (A separate replaceVars derivation would substitute its own outPath + # instead — which silently broke the PYTHONPATH in the launcher.) + substituteInPlace $out/bin/bambuddy \ + --replace-warn '@out@' "$out" \ + --replace-warn '@pythonEnv@' "${pythonEnv}" + # ffmpeg is used for timelapse conversion and camera stream re-encoding. + wrapProgram $out/bin/bambuddy \ + --prefix PATH : ${lib.makeBinPath [ ffmpeg ]} + + runHook postInstall + ''; + + # Fix up the interpreter reference in the substituted wrapper. + preFixup = '' + patchShebangs $out/bin/bambuddy + ''; + + meta = { + description = "Self-hosted command center for Bambu Lab 3D printers"; + longDescription = '' + Bambuddy is a self-hosted command center for Bambu Lab 3D printers — + from a single A1 to an entire print farm. Monitor printers in real + time, archive every print automatically, slice and queue jobs locally, + and manage the whole workflow without Bambu's cloud. + + This package bundles the FastAPI backend and the prebuilt React + frontend behind a single `bambuddy` launcher. Configuration is done via + environment variables (DATA_DIR, LOG_DIR, HOST, PORT, ...) — see the + NixOS module (`services.bambuddy`) or upstream `.env.example` for the + full list. + ''; + homepage = "https://bambuddy.cool/"; + changelog = "https://github.com/maziggy/bambuddy/releases/tag/v${version}"; + license = lib.licenses.agpl3Only; + sourceProvenance = with lib.sourceTypes; [ fromSource ]; + platforms = lib.platforms.unix; + mainProgram = "bambuddy"; + }; +} diff --git a/pkgs/bambuddy/wrapper.sh b/pkgs/bambuddy/wrapper.sh new file mode 100644 index 0000000..e2d14b2 --- /dev/null +++ b/pkgs/bambuddy/wrapper.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Bambuddy launcher. +# +# Starts the FastAPI backend via uvicorn, mirroring the upstream Docker CMD +# (`uvicorn backend.app.main:app --loop asyncio`). PYTHONPATH points at the +# app root (@out@/share/bambuddy) which contains the `backend` package and +# the prebuilt `static/` frontend — the layout backend.app.core.config expects. +set -euo pipefail + +# State locations. The NixOS module always sets these explicitly; the +# defaults keep a bare `bambuddy` invocation out of the read-only store. +export DATA_DIR="${DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bambuddy}" +export LOG_DIR="${LOG_DIR:-$DATA_DIR/logs}" +export HOME="${HOME:-$DATA_DIR}" +export MPLCONFIGDIR="${MPLCONFIGDIR:-$DATA_DIR/.cache/matplotlib}" + +mkdir -p "$DATA_DIR" "$LOG_DIR" "$MPLCONFIGDIR" + +export PYTHONPATH="@out@/share/bambuddy${PYTHONPATH:+:$PYTHONPATH}" + +# `--loop asyncio` matches the upstream Dockerfile: uvloop has a TLS bug that +# can truncate VP FTP uploads. Override with UVICORN_LOOP=uvloop if desired. +exec "@pythonEnv@/bin/uvicorn" backend.app.main:app \ + --host "${HOST:-0.0.0.0}" \ + --port "${PORT:-8000}" \ + --loop "${UVICORN_LOOP:-asyncio}" \ + --timeout-graceful-shutdown "${UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN:-5}" \ + "$@" diff --git a/tests/import-check.nix b/tests/import-check.nix new file mode 100644 index 0000000..0340e5f --- /dev/null +++ b/tests/import-check.nix @@ -0,0 +1,27 @@ +{ + lib, + runCommand, + bambuddy, +}: +# Fast package-level smoke test: import the full backend (which pulls in +# every router, service and optional dependency) inside a sandbox with a +# writable state dir, and exercise the admin CLI entrypoint. Catches broken +# Python dependency closures long before the (much slower) VM test runs. +runCommand "bambuddy-import-check" + { + nativeBuildInputs = [ bambuddy.passthru.pythonEnv ]; + meta = { + description = "Bambuddy: verify the backend imports with the packaged dependency closure"; + }; + } + '' + export DATA_DIR="$PWD/state" LOG_DIR="$PWD/state/logs" HOME="$PWD/state" MPLCONFIGDIR="$PWD/state/mpl" + mkdir -p "$DATA_DIR" "$LOG_DIR" + export PYTHONPATH="${bambuddy}/share/bambuddy" + + python -c 'import backend.app.main; print("backend.app.main imports cleanly")' + + python -m backend.app.cli --help > /dev/null + + touch $out + '' diff --git a/tests/service-test.nix b/tests/service-test.nix new file mode 100644 index 0000000..c7b9f71 --- /dev/null +++ b/tests/service-test.nix @@ -0,0 +1,92 @@ +{ + lib, + pkgs, + bambuddy, + nixosModule, +}: +# Integration test: boots two NixOS VMs with the bambuddy NixOS module. +# +# * `server` uses the default configuration (port 8000, firewall open) and +# checks that the service starts, the API responds, the prebuilt React +# frontend is served with its static assets, and persistent state +# (SQLite database, logs) lands in the StateDirectory. +# * `custom` exercises the module options (custom host/port, extra +# environment) and verifies the dashboard is reachable cross-node once +# the firewall is opened. +pkgs.testers.runNixOSTest ( + { ... }: + { + name = "bambuddy-service"; + + nodes = { + server = + { pkgs, ... }: + { + imports = [ nixosModule ]; + services.bambuddy = { + enable = true; + openFirewall = true; + }; + environment.systemPackages = [ pkgs.curl ]; + }; + + custom = + { pkgs, ... }: + { + imports = [ nixosModule ]; + services.bambuddy = { + enable = true; + host = "127.0.0.1"; + port = 8123; + environment.LOG_LEVEL = "DEBUG"; + }; + environment.systemPackages = with pkgs; [ + curl + iproute2 + ]; + }; + }; + + testScript = '' + server.start() + custom.start() + + server.wait_for_unit("bambuddy.service") + server.wait_for_open_port(8000) + + with subtest("health endpoint responds"): + server.succeed("curl -fsS http://127.0.0.1:8000/health | grep -q healthy") + + with subtest("uvicorn started cleanly"): + server.succeed( + "journalctl -u bambuddy.service --no-pager | grep -q 'Application startup complete'" + ) + + with subtest("web UI is served from the packaged static build"): + server.succeed("curl -fsS http://127.0.0.1:8000/ | grep -q 'Bambuddy'") + server.succeed("curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/img/favicon-32x32.png | grep -q '^200$'") + server.succeed("curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/fonts/inter-latin.woff2 | grep -q '^200$'") + server.succeed("curl -fsS http://127.0.0.1:8000/manifest.json | grep -qi bambuddy") + + with subtest("persistent state lands in the StateDirectory"): + server.succeed("test -f /var/lib/bambuddy/bambuddy.db") + server.succeed("test -d /var/lib/bambuddy/logs") + + with subtest("dashboard is reachable from another machine (openFirewall)"): + custom.succeed("curl -m 10 -fsS http://server:8000/health | grep -q healthy") + + with subtest("custom host/port options apply"): + custom.wait_for_unit("bambuddy.service") + custom.wait_for_open_port(8123) + custom.succeed("curl -fsS http://127.0.0.1:8123/health | grep -q healthy") + # host = 127.0.0.1 must not appear on any external address + custom.fail("ss -tln | grep -E '0\\.0\\.0\\.0:8123|\\[::\\]:8123|\\*:8123'") + server.fail("curl -m 5 -fsS http://custom:8123/health") + + with subtest("extra environment reaches the service"): + custom.succeed( + "systemctl show bambuddy.service -p Environment | grep -q LOG_LEVEL=DEBUG" + ) + ''; + } +)