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.
This commit is contained in:
0m.ax 2026-11-01 19:54:55 +01:00
commit 5fb8e8d724
9 changed files with 761 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
result
result-*

109
README.md Normal file
View file

@ -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.<system>.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:<you>/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://<host>: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 = "<commit for v${version}>";
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.

27
flake.lock generated Normal file
View file

@ -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
}

98
flake.nix Normal file
View file

@ -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
];
};
}
);
};
}

174
nixos/module.nix Normal file
View file

@ -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";
};
};
};
}

204
pkgs/bambuddy/default.nix Normal file
View file

@ -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";
};
}

28
pkgs/bambuddy/wrapper.sh Normal file
View file

@ -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}" \
"$@"

27
tests/import-check.nix Normal file
View file

@ -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
''

92
tests/service-test.nix Normal file
View file

@ -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 '<title>Bambuddy</title>'")
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"
)
'';
}
)