From c150445adf286ba3340d1573fc551a7172ad36f1 Mon Sep 17 00:00:00 2001 From: kurogeek Date: Thu, 29 Jan 2026 20:15:25 +0700 Subject: [PATCH] nixos/inventree: add module services and test Co-authored-by: Bobbe <34186858+30350n@users.noreply.github.com> --- nixos/modules/module-list.nix | 1 + nixos/modules/services/misc/inventree.nix | 411 ++++++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/inventree.nix | 33 ++ 4 files changed, 446 insertions(+) create mode 100644 nixos/modules/services/misc/inventree.nix create mode 100644 nixos/tests/inventree.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 44e0deb68dae..4192ce33fada 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -881,6 +881,7 @@ ./services/misc/ihaskell.nix ./services/misc/iio-niri.nix ./services/misc/input-remapper.nix + ./services/misc/inventree.nix ./services/misc/invidious-router.nix ./services/misc/irkerd.nix ./services/misc/jackett.nix diff --git a/nixos/modules/services/misc/inventree.nix b/nixos/modules/services/misc/inventree.nix new file mode 100644 index 000000000000..2c4b1df16621 --- /dev/null +++ b/nixos/modules/services/misc/inventree.nix @@ -0,0 +1,411 @@ +{ + config, + pkgs, + lib, + ... +}: +let + cfg = config.services.inventree; + pkg = cfg.package; + + mysqlLocal = cfg.database.createLocally && cfg.database.dbtype == "mysql"; + pgsqlLocal = cfg.database.createLocally && cfg.database.dbtype == "postgresql"; + + manage = pkgs.writeShellScriptBin "inventree-manage" '' + set -a + ${lib.toShellVars cfg.settings} + ${lib.optionalString ( + cfg.database.passwordFile != null + ) ''INVENTREE_DB_PASSWORD="$(<${lib.escapeShellArg cfg.database.passwordFile})"''} + set +a + + pushd ${lib.escapeShellArg cfg.dataDir} + expectedUser=${lib.escapeShellArg cfg.user} + sudo=() + if [[ "$USER" != "$expectedUser" ]]; then + ${ + if config.security.sudo.enable then + ''sudo+=(${config.security.wrapperDir}/sudo -u "$expectedUser" -E)'' + else + ''printf 'Aborting, inventree-manage must be run as user %s\n!' "$expectedUser" >&2; exit 2'' + } + fi + exec "''${sudo[@]}" ${cfg.package}/bin/inventree "$@" + ''; + +in +{ + meta.buildDocsInSandbox = false; + + meta.maintainers = with lib.maintainers; [ + kurogeek + ]; + + options.services.inventree = { + enable = lib.mkEnableOption "inventree"; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/inventree"; + description = "Inventree's data storage path. Will be `/var/lib/inventree` by default."; + }; + + package = lib.mkOption { + type = lib.types.package; + description = "Which package to use for the InvenTree instance."; + default = pkgs.inventree; + defaultText = lib.literalExpression "pkgs.inventree"; + }; + + adminPasswordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/keys/inventree-password"; + description = "Path to a file containing admin password"; + }; + + secretKeyFile = lib.mkOption { + type = lib.types.path; + default = "${cfg.dataDir}/secret_key.txt"; + defaultText = lib.literalExpression ''"''${cfg.dataDir}/secret_key.txt"''; + example = "/run/keys/inventree-secret-key"; + description = '' + Path to a file containing the secret key + ''; + }; + + database = { + dbtype = lib.mkOption { + type = lib.types.nullOr ( + lib.types.enum [ + "postgresql" + "mysql" + ] + ); + default = "postgresql"; + description = "Database type."; + }; + dbhost = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "localhost"; + description = "Database host or socket path."; + }; + dbport = lib.mkOption { + type = lib.types.nullOr lib.types.port; + default = null; + example = 5432; + description = "Database host port."; + }; + dbname = lib.mkOption { + type = lib.types.str; + default = "inventree"; + description = "Database name."; + }; + dbuser = lib.mkOption { + type = lib.types.str; + default = "inventree"; + description = "Database username."; + }; + passwordFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + example = "/run/keys/inventree-dbpassword"; + description = '' + A file containing the password corresponding to + . + ''; + }; + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create the database and database user locally."; + }; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = "localhost"; + example = "inventree.example.com"; + description = '' + The INVENTREE_SITE_URL option defines the base URL for the + InvenTree server. This is a critical setting, and it is required + for correct operation of the server. If not specified, the + server will attempt to determine the site URL automatically - + but this may not always be correct! + + The site URL is the URL that users will use to access the + InvenTree server. For example, if the server is accessible at + `https://inventree.example.com`, the site URL should be set to + `https://inventree.example.com`. Note that this is not + necessarily the same as the internal URL that the server is + running on - the internal URL will depend entirely on your + server configuration and may be obscured by a reverse proxy or + other such setup. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "inventree"; + description = "User under which InvenTree runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "inventree"; + description = "Group under which InvenTree runs."; + }; + + settings = lib.mkOption { + type = + with lib.types; + attrsOf ( + nullOr (oneOf [ + path + str + ]) + ); + default = { }; + description = '' + InvenTree config options. + + See [the documentation](https://docs.inventree.org/en/stable/start/config/) for available options. + ''; + example = { + INVENTREE_CACHE_ENABLED = true; + INVENTREE_CACHE_HOST = "localhost"; + + INVENTREE_EMAIL_HOST = "smtp.example.com"; + INVENTREE_EMAIL_PORT = 25; + }; + }; + + }; + + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + services.inventree.settings = { + INVENTREE_DB_ENGINE = cfg.database.dbtype; + INVENTREE_DB_NAME = cfg.database.dbname; + INVENTREE_DB_HOST = cfg.database.dbhost; + INVENTREE_DB_USER = cfg.database.dbuser; + INVENTREE_DB_PORT = if cfg.database.dbport != null then toString cfg.database.dbport else null; + + INVENTREE_CONFIG_FILE = lib.mkDefault "${cfg.dataDir}/config/config.yaml"; + INVENTREE_OIDC_PRIVATE_KEY_FILE = lib.mkDefault "${cfg.dataDir}/config/oidc_private_key.txt"; + INVENTREE_STATIC_ROOT = lib.mkDefault "${cfg.package}/lib/inventree/static"; + INVENTREE_MEDIA_ROOT = lib.mkDefault "${cfg.dataDir}/data/media"; + INVENTREE_BACKUP_DIR = lib.mkDefault "${cfg.dataDir}/data/backups"; + INVENTREE_SITE_URL = lib.mkDefault "http://${cfg.domain}"; + INVENTREE_PLUGIN_FILE = lib.mkDefault "${cfg.dataDir}/data/plugins/plugins.txt"; + INVENTREE_PLUGIN_DIR = lib.mkDefault "${cfg.dataDir}/data/plugins"; + INVENTREE_ADMIN_USER = lib.mkDefault "admin"; + INVENTREE_ADMIN_EMAIL = lib.mkDefault "admin@${cfg.domain}"; + INVENTREE_ADMIN_PASSWORD_FILE = lib.mkDefault cfg.adminPasswordFile; + INVENTREE_SECRET_KEY_FILE = lib.mkDefault cfg.secretKeyFile; + INVENTREE_AUTO_UPDATE = lib.mkDefault "false"; + }; + environment.systemPackages = [ manage ]; + systemd.tmpfiles.rules = ( + map (dir: "d ${dir} 0755 inventree inventree") [ + "${cfg.dataDir}" + "${cfg.dataDir}/config" + "${cfg.dataDir}/data" + "${cfg.dataDir}/data/media" + "${cfg.dataDir}/data/backups" + "${cfg.dataDir}/data/plugins" + ] + ); + + services.postgresql = lib.mkIf pgsqlLocal { + enable = true; + ensureDatabases = [ cfg.database.dbname ]; + ensureUsers = [ + { + name = cfg.database.dbuser; + ensureDBOwnership = true; + } + ]; + }; + + services.mysql = lib.mkIf mysqlLocal { + enable = true; + package = lib.mkDefault pkgs.mariadb; + ensureDatabases = [ cfg.database.dbname ]; + ensureUsers = [ + { + name = cfg.database.dbuser; + ensurePermissions = { + "${cfg.database.dbname}.*" = "ALL PRIVILEGES"; + }; + } + ]; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts.${cfg.domain} = { + locations = + let + unixPath = config.systemd.sockets.inventree-server.socketConfig.ListenStream; + in + { + "/" = { + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Forwarded-By $server_addr:$server_port; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header CLIENT_IP $remote_addr; + + proxy_pass_request_headers on; + + proxy_redirect off; + + client_max_body_size 100M; + + proxy_buffering off; + proxy_request_buffering off; + ''; + proxyPass = "http://unix:${unixPath}"; + }; + "/auth" = { + extraConfig = '' + internal; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + ''; + proxyPass = "http://unix:${unixPath}:/auth/"; + }; + "/static/" = { + alias = "${cfg.settings.INVENTREE_STATIC_ROOT}/"; + extraConfig = '' + autoindex on; + + # Caching settings + expires 30d; + add_header Pragma public; + add_header Cache-Control "public"; + ''; + }; + "/media/" = { + alias = "${cfg.settings.INVENTREE_MEDIA_ROOT}/"; + extraConfig = '' + auth_request /auth; + add_header Content-disposition "attachment"; + ''; + }; + }; + }; + + systemd.services.inventree-setup = { + description = "Inventree setup"; + wantedBy = [ "inventree.target" ]; + partOf = [ "inventree.target" ]; + after = lib.optional mysqlLocal "mysql.service" ++ lib.optional pgsqlLocal "postgresql.target"; + requires = lib.optional mysqlLocal "mysql.service" ++ lib.optional pgsqlLocal "postgresql.target"; + before = [ + "inventree-server.service" + "inventree-qcluster.service" + ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + RemainAfterExit = true; + PrivateTmp = true; + } + // lib.optionalAttrs (cfg.database.passwordFile != null) { + LoadCredential = "db_password:${cfg.database.passwordFile}"; + }; + environment = cfg.settings; + script = '' + set -euo pipefail + umask u=rwx,g=,o= + + ${ + lib.optionalString (cfg.database.passwordFile != null) '' + INVENTREE_DB_PASSWORD=$(<"$CREDENTIALS_DIRECTORY/db_password") + '' + } \ + exec ${pkg}/bin/inventree migrate + ''; + }; + + systemd.services.inventree-server = { + description = "Inventree Gunicorn service"; + requiredBy = [ "inventree.target" ]; + partOf = [ "inventree.target" ]; + environment = cfg.settings; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + StateDirectory = "inventree"; + PrivateTmp = true; + } + // lib.optionalAttrs (cfg.database.passwordFile != null) { + LoadCredential = "db_password:${cfg.database.passwordFile}"; + }; + script = '' + ${ + lib.optionalString (cfg.database.passwordFile != null) '' + INVENTREE_DB_PASSWORD=$(<"$CREDENTIALS_DIRECTORY/db_password") + '' + } \ + exec ${pkg}/bin/gunicorn InvenTree.wsgi + ''; + }; + + systemd.sockets.inventree-server = { + wantedBy = [ "sockets.target" ]; + partOf = [ "inventree.target" ]; + socketConfig.ListenStream = "/run/inventree/gunicorn.socket"; + }; + + systemd.services.inventree-qcluster = { + description = "InvenTree qcluster server"; + requiredBy = [ "inventree.target" ]; + wantedBy = [ "inventree.target" ]; + partOf = [ "inventree.target" ]; + environment = cfg.settings; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + StateDirectory = "inventree"; + PrivateTmp = true; + } + // lib.optionalAttrs (cfg.database.passwordFile != null) { + LoadCredential = "db_password:${cfg.database.passwordFile}"; + }; + script = '' + ${ + lib.optionalString (cfg.database.passwordFile != null) '' + INVENTREE_DB_PASSWORD=$(<"$CREDENTIALS_DIRECTORY/db_password") + '' + } \ + exec ${pkg}/bin/inventree qcluster + ''; + }; + + systemd.targets.inventree = { + description = "Target for all InvenTree services"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + }; + + users = lib.optionalAttrs (cfg.user == cfg.user) { + users.${cfg.user} = { + group = cfg.group; + isSystemUser = true; + home = cfg.dataDir; + }; + groups.${cfg.group}.members = [ cfg.user ]; + }; + } + ] + ); +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 5dc1b20b0293..23467350403d 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -781,6 +781,7 @@ in installer = handleTest ./installer.nix { }; installer-systemd-stage-1 = handleTest ./installer-systemd-stage-1.nix { }; intune = runTest ./intune.nix; + inventree = runTest ./inventree.nix; invidious = runTest ./invidious.nix; invoiceplane = runTest ./invoiceplane.nix; iodine = runTest ./iodine.nix; diff --git a/nixos/tests/inventree.nix b/nixos/tests/inventree.nix new file mode 100644 index 000000000000..34fe8730ae67 --- /dev/null +++ b/nixos/tests/inventree.nix @@ -0,0 +1,33 @@ +{ lib, ... }: +{ + name = "inventree"; + meta.maintainers = with lib.maintainers; [ + kurogeek + ]; + + nodes = { + psqlTest = { + services.inventree = { + enable = true; + }; + }; + mysqlTest = { + services.inventree = { + enable = true; + database.dbtype = "mysql"; + }; + }; + }; + testScript = '' + start_all() + psqlTest.wait_for_unit("inventree.target") + psqlTest.wait_for_unit("inventree-server.service") + psqlTest.wait_for_open_unix_socket("/run/inventree/gunicorn.socket") + psqlTest.wait_until_succeeds("curl -sf http://localhost/web") + + mysqlTest.wait_for_unit("inventree.target") + mysqlTest.wait_for_unit("inventree-server.service") + mysqlTest.wait_for_open_unix_socket("/run/inventree/gunicorn.socket") + mysqlTest.wait_until_succeeds("curl -sf http://localhost/web") + ''; +}