From 7095204d0638944021b2cb6c58e5a17da16a8acf Mon Sep 17 00:00:00 2001 From: teutat3s Date: Sun, 9 Jul 2023 14:46:22 +0200 Subject: [PATCH] Initial NixOS module --- modules/erpnext.nix | 442 ++++++++++++++++++++++++++++++++++++++ test-vm/configuration.nix | 251 +--------------------- 2 files changed, 449 insertions(+), 244 deletions(-) create mode 100644 modules/erpnext.nix diff --git a/modules/erpnext.nix b/modules/erpnext.nix new file mode 100644 index 0000000..5dcccbb --- /dev/null +++ b/modules/erpnext.nix @@ -0,0 +1,442 @@ +{ config, lib, pkgs, ... }: +with lib; +let + cfg = config.services.erpnext; + pkg = cfg.package; + defaultUser = "erpnext"; +in +{ + # interface + options.services.erpnext = { + enable = mkOption { + type = lib.types.bool; + default = false; + description = lib.mdDoc '' + Enable ERPNext. + + When started, the ERPNext database is automatically created if it doesn't + exist. + ''; + }; + + domain = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc '' + Domain name of your server. + ''; + }; + + workDir = mkOption { + type = types.str; + default = "/var/lib/erpnext"; + description = lib.mdDoc "Working directory of ERPNext."; + }; + + benchDir = mkOption { + type = types.str; + default = "${cfg.workDir}/bench"; + description = lib.mdDoc "Bench directory for ERPNext."; + }; + + adminPasswordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/erpnext-password"; + description = lib.mdDoc '' + A file containing the Administrator user password. + ''; + }; + + database = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Database host address."; + }; + port = mkOption { + type = types.port; + default = 3306; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "erpnext"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = "erpnext"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/erpnext-db-password"; + description = lib.mdDoc '' + A file containing the MariaDB erpnext user password. + ''; + }; + rootPasswordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/secrets/erpnext-root-db-password"; + description = lib.mdDoc '' + A file containing the MariaDB root user password. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + redis = { + host = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Redis host address."; + }; + port = mkOption { + type = types.port; + default = 6379; + description = lib.mdDoc "Redis host port."; + }; + createLocally = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc "Create the redis server locally."; + }; + }; + + socketIoPort = mkOption { + type = types.port; + default = 9000; + description = lib.mdDoc "Local socket.io HTTP server port."; + }; + + #configJsonFile = mkOption { + # type = types.nullOr types.path; + # default = null; + # example = "/run/secrets/erpnext-config.json"; + # description = lib.mdDoc '' + # A file containing the ERPNext config. + # ''; + #}; + + webserver = { + bindAddress = mkOption { + type = types.str; + default = "localhost"; + description = lib.mdDoc "Web interface address."; + }; + bindPort = mkOption { + type = types.port; + default = 9090; + description = lib.mdDoc "Web interface port."; + }; + }; + + caddy = mkOption { + type = types.nullOr types.attrs; + default = null; + example = lib.literalExpression '' + { + serverAliases = [ + "dolibarr.''${config.networking.domain}" + "erp.''${config.networking.domain}" + ]; + enableACME = false; + } + ''; + description = lib.mdDoc '' + With this option, you can customize a caddy virtual host. + Set to {} if you do not need any customization to the virtual host. + If enabled, then by default, the {option}`serverName` is + `''${domain}`, + SSL is active, and certificates are acquired via ACME. + If this is set to null (the default), no caddy virtualHost will be configured. + ''; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = lib.mdDoc "User under which ERPNext runs."; + }; + + package = mkOption { + type = types.package; + default = pkgs.python3.pkgs.erpnext; + defaultText = literalExpression "pkgs.python3.pkgs.erpnext"; + description = lib.mdDoc "The ERPNext package to use."; + }; + }; + + # implementation + config = + let + penv = pkgs.python3.buildEnv.override { + extraLibs = [ + pkgs.python3.pkgs.frappe + pkgs.python3.pkgs.erpnext + pkgs.python3.pkgs.bench + ]; + }; + appsFile = pkgs.writeText "erpnext-apps.txt" '' + frappe + erpnext + ''; + # In a module, this could be provided by a use as a file as it could + # contain secrets and we don't want this in the nix-store. But here it + # is OK. + commonSiteConfig = { + db_host = "${cfg.database.host}"; + db_port = "${toString cfg.database.port}"; + db_name = "${cfg.database.name}"; + db_password = "#NIXOS_ERPNEXT_DB_PASSWORD#"; + redis_cache = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; + redis_queue = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; + redis_socketio = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; + socketio_port = "${toString cfg.socketIoPort}"; + }; + commonSiteConfigFile = pkgs.writeText "erpnext-common_site_config.json" (builtins.toJSON commonSiteConfig); + + defaultServiceConfig = { + User = cfg.user; + NoNewPrivileges = true; + Type = "simple"; + BindReadOnlyPaths = [ + "/etc/hosts:/etc/hosts" + "${pkgs.frappe-app}:${pkgs.frappe-app}" + "${pkgs.frappe-app}/share/apps/frappe:${cfg.benchDir}/apps/frappe" + "${pkgs.erpnext-app}:${pkgs.erpnext-app}" + "${pkgs.erpnext-app}/share/apps/erpnext:${cfg.benchDir}/apps/erpnext" + "${pkgs.frappe-erpnext-assets}/share/sites/assets:${cfg.benchDir}/sites/assets" + "${appsFile}:${cfg.benchDir}/sites/apps.txt" + "${penv}:${cfg.benchDir}/env" + ]; + WorkingDirectory = "${cfg.benchDir}"; + # Expands to /var/lib/erpnext, see: 'man 5 systemd.exec' + StateDirectory = "erpnext"; + }; + in mkIf cfg.enable + { + services.mysql = mkIf cfg.database.createLocally { + enable = true; + package = pkgs.mariadb; + ensureUsers = [{ + name = "root"; + ensurePermissions = { + "*.*" = "ALL PRIVILEGES"; + }; + }]; + ensureDatabases = [ "root" ]; + }; + + services.redis.servers = mkIf cfg.redis.createLocally { + # Queue, naming it "" makes it use default values. + "".enable = true; + }; + + users = optionalAttrs (cfg.user == defaultUser) { + users.${defaultUser} = { + description = "User to run ERPNext"; + group = defaultUser; + uid = 327; + # TODO assign an appropriate ID when merging this into nixos/nixpkgs + #uid = config.ids.uids.erpnext; + home = cfg.workDir; + }; + + groups.${defaultUser} = { + gid = 327; + # TODO assign an appropriate ID when merging this into nixos/nixpkgs + #gid = config.ids.gids.erpnext; + }; + }; + + systemd.services.erpnext-setup-mysql = mkIf cfg.database.createLocally { + enable = true; + before = [ "erpnext-web.service" ]; + after = [ "mysql.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + script = '' + ${pkgs.mariadb-client}/bin/mysql -e "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('${builtins.readFile cfg.database.rootPasswordFile}')"; + ''; + serviceConfig = { + RemainAfterExit = true; + Type = "oneshot"; + }; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.benchDir}/apps' 0750 ${cfg.user} ${config.users.users.${cfg.user}.group}" + "d '${cfg.benchDir}/config/pids' 0750 ${cfg.user} ${config.users.users.${cfg.user}.group}" + "d '${cfg.benchDir}/logs' 0750 ${cfg.user} ${config.users.users.${cfg.user}.group}" + "d '${cfg.benchDir}/sites' 0750 ${cfg.user} ${config.users.users.${cfg.user}.group}" + ]; + + systemd.services.erpnext-nodejs-socketio = { + enable = true; + after = [ "erpnext-web.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + description = "ERPNext Node.js HTTP server for socket.io "; + confinement = { + enable = true; + packages = [ pkgs.nodejs ]; + }; + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${pkgs.nodejs}/bin/node ${cfg.benchDir}/apps/frappe/socketio.js + ''; + }; + }; + + services.caddy.enable = mkIf (cfg.caddy != null) true; + services.caddy.email = "admins@pub.solar"; + services.caddy.globalConfig = '' + local_certs + ''; + services.caddy.virtualHosts."${cfg.domain}:8081" = mkIf (cfg.caddy != null) (lib.mkMerge [ + cfg.caddy + ({ + extraConfig = '' + handle /assets/* { + root * ${pkgs.frappe-erpnext-assets}/share/sites + file_server + } + handle /socket.io/* { + reverse_proxy :${toString cfg.socketIoPort} + } + + reverse_proxy :${toString cfg.webserver.bindPort} + ''; + }) + ]); + + systemd.services.erpnext-web = { + enable = true; + wantedBy = [ "multi-user.target" ]; + after = [ + "mysql.service" + "redis.service" + "redis-socketio.service" + "systemd-tmpfiles-setup.service" + ]; + description = "ERPNext web server"; + confinement = { + enable = true; + packages = [ pkgs.mariadb-client penv ]; + }; + environment = { + PYTHON_PATH = "${penv}/${pkgs.python3.sitePackages}"; + }; + path = [ pkgs.mariadb-client penv ]; + serviceConfig = defaultServiceConfig // { + TimeoutStartSec = "300s"; + Restart = "on-failure"; + ExecStartPre = assert cfg.adminPasswordFile != null && cfg.database.rootPasswordFile != null; pkgs.writeScript "erpnext-web-init" '' + #!/bin/sh + if ! test -e ${escapeShellArg "${cfg.workDir}/.db-created"}; then + install -m0600 ${commonSiteConfigFile} ${cfg.benchDir}/sites/common_site_config.json + ${pkgs.replace-secret}/bin/replace-secret \ + '#NIXOS_ERPNEXT_DB_PASSWORD#' \ + ${cfg.database.passwordFile} \ + ${cfg.benchDir}/sites/common_site_config.json + + ADMIN_PASSWORD="$(cat "${cfg.adminPasswordFile}")" + ROOT_DB_PASSWORD="$(cat "${cfg.database.rootPasswordFile}")" + + # Upstream initializes the database with this command + ${penv}/bin/bench new-site localhost \ + --mariadb-root-password "$ROOT_DB_PASSWORD" \ + --admin-password "$ADMIN_PASSWORD" \ + --install-app erpnext + + touch ${escapeShellArg "${cfg.workDir}/.db-created"} + fi + ''; + ExecStart = '' + ${penv}/bin/gunicorn \ + --chdir="${cfg.benchDir}/sites" \ + --bind=${cfg.webserver.bindAddress}:${toString cfg.webserver.bindPort} \ + --threads=4 \ + --workers=3 \ + --worker-class=gthread \ + --worker-tmp-dir=/dev/shm \ + --timeout=120 \ + --preload \ + frappe.app:application + ''; + }; + }; + systemd.services.erpnext-queue-short = { + enable = true; + before = [ "erpnext-web.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + description = "ERPNext short queue server"; + confinement = { + enable = true; + packages = [ penv ]; + }; + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${penv}/bin/bench worker --queue short + ''; + }; + }; + systemd.services.erpnext-queue-default = { + enable = true; + before = [ "erpnext-web.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + description = "ERPNext default queue server"; + confinement = { + enable = true; + packages = [ penv ]; + }; + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${penv}/bin/bench worker --queue default + ''; + }; + }; + systemd.services.erpnext-queue-long = { + enable = true; + before = [ "erpnext-web.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + description = "ERPNext long queue server"; + confinement = { + enable = true; + packages = [ penv ]; + }; + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${penv}/bin/bench worker --queue long + ''; + }; + }; + systemd.services.erpnext-scheduler = { + enable = true; + before = [ "erpnext-web.service" ]; + wantedBy = [ "erpnext-web.service" ]; + partOf = [ "erpnext-web.service" ]; + description = "ERPNext scheduler server"; + confinement = { + enable = true; + packages = [ penv ]; + }; + serviceConfig = defaultServiceConfig // { + ExecStart = '' + ${penv}/bin/bench schedule + ''; + }; + }; + }; +} diff --git a/test-vm/configuration.nix b/test-vm/configuration.nix index c486f14..b013e7f 100644 --- a/test-vm/configuration.nix +++ b/test-vm/configuration.nix @@ -4,58 +4,10 @@ "${modulesPath}/profiles/minimal.nix" "${modulesPath}/profiles/qemu-guest.nix" "${modulesPath}/virtualisation/qemu-vm.nix" + ../modules/erpnext.nix ]; - config = - let - penv = pkgs.python3.buildEnv.override { - extraLibs = [ - pkgs.python3.pkgs.frappe - pkgs.python3.pkgs.erpnext - pkgs.python3.pkgs.bench - ]; - }; - appsFile = pkgs.writeText "erpnext-apps.txt" '' - frappe - erpnext - ''; - # In a module, this could be provided by a use as a file as it could - # contain secrets and we don't want this in the nix-store. But here it - # is OK. - commonSiteConfig = pkgs.writeText "erpnext-common_site_config.json" '' - { - "db_host": "localhost", - "db_port": 3306, - "db_name": "erpnext" , - "db_password": "erpnext" , - "redis_cache": "redis://localhost:6379", - "redis_queue": "redis://localhost:6379", - "redis_socketio": "redis://localhost:12311", - "socketio_port": 3000 - } - ''; - defaultServiceConfig = { - User = "erpnext"; - NoNewPrivileges = true; - Type = "simple"; - BindReadOnlyPaths = [ - "/etc/hosts:/etc/hosts" - "${pkgs.frappe-app}:${pkgs.frappe-app}" - "${pkgs.frappe-app}/share/apps/frappe:/var/lib/erpnext/bench/apps/frappe" - "${pkgs.erpnext-app}:${pkgs.erpnext-app}" - "${pkgs.erpnext-app}/share/apps/erpnext:/var/lib/erpnext/bench/apps/erpnext" - "${pkgs.frappe-erpnext-assets}/share/sites/assets:/var/lib/erpnext/bench/sites/assets" - "${appsFile}:/var/lib/erpnext/bench/sites/apps.txt" - "${commonSiteConfig}:/var/lib/erpnext/bench/sites/common_site_config.json" - "${penv}:/var/lib/erpnext/bench/env" - ]; - BindPaths = [ - "/var/lib/erpnext:/var/lib/erpnext" - ]; - WorkingDirectory = "/var/lib/erpnext/bench"; - }; - in - { + config = { services.qemuGuest.enable = true; system.stateVersion = "23.05"; @@ -108,201 +60,12 @@ neovim ]; - services.mysql = { + services.erpnext = { enable = true; - package = pkgs.mariadb; - ensureUsers = [{ - name = "root"; - ensurePermissions = { - "*.*" = "ALL PRIVILEGES"; - }; - }]; - ensureDatabases = [ "root" ]; - }; - - services.redis.servers = { - # Queue, naming it "" makes it use default values. - "".enable = true; - - socketio = { - enable = true; - port = 12311; - }; - }; - - users.users.erpnext = { - description = "User to run erpnext"; - group = "erpnext"; - isSystemUser = true; - home = "/var/lib/erpnext"; - createHome = true; - }; - users.groups.erpnext = {}; - - systemd.services.erpnext-setup-mysql = { - enable = true; - before = [ "erpnext-web.service" ]; - after = [ "mysql.service" ]; - wantedBy = [ "erpnext-web.service" ]; - partOf = [ "erpnext-web.service" ]; - script = '' - ${pkgs.mariadb-client}/bin/mysql -e "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('password')"; - ''; - serviceConfig = { - RemainAfterExit = true; - Type = "oneshot"; - }; - }; - - systemd.services.erpnext-ensure-bench-dir = { - enable = true; - before = [ "erpnext-web.service" ]; - wantedBy = [ "erpnext-web.service" ]; - partOf = [ "erpnext-web.service" ]; - script = '' - for subdir in apps sites config/pids logs; do - mkdir -p /var/lib/erpnext/bench/$subdir - done - ''; - serviceConfig = { - RemainAfterExit = true; - Type = "oneshot"; - User = "erpnext"; - }; - }; - - systemd.services.erpnext-nodejs-socketio = { - enable = true; - before = [ "erpnext-web.service" ]; - wantedBy = [ "erpnext-web.service" ]; - partOf = [ "erpnext-web.service" ]; - description = "ERPNext Node.js HTTP server for socket.io "; - confinement = { - enable = true; - packages = [ pkgs.nodejs ]; - }; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${pkgs.nodejs}/bin/node /var/lib/erpnext/bench/apps/frappe/socketio.js - ''; - }; - }; - - services.caddy = { - enable = true; - email = "admins@pub.solar"; - globalConfig = '' - local_certs - ''; - virtualHosts = { - "localhost:8081" = { - extraConfig = '' - handle /assets/* { - root * ${pkgs.frappe-erpnext-assets}/share/sites - file_server - } - handle /socket.io/* { - reverse_proxy :3000 - } - - reverse_proxy :9090 - ''; - }; - }; - }; - - systemd.services.erpnext-web = { - enable = true; - wantedBy = [ "multi-user.target" ]; - after = [ "mysql.service" "redis.service" "redis-socketio.service" ]; - description = "ERPNext web server"; - confinement = { - enable = true; - packages = [ pkgs.mariadb-client penv ]; - }; - serviceConfig = defaultServiceConfig // { - TimeoutStartSec = "300s"; - ExecStartPre = pkgs.writeScript "erpnext-server.worker-init" '' - #!/bin/sh - export PYTHON_PATH=${penv}/${pkgs.python3.sitePackages} - export PATH="${pkgs.mariadb-client}/bin:${pkgs.nodejs}/bin:${penv}/bin:$PATH" - - # Upstream initializes the DB with this command - bench new-site localhost --mariadb-root-password password --admin-password admin --install-app erpnext || true - ''; - ExecStart = '' - ${penv}/bin/gunicorn \ - --chdir="/var/lib/erpnext/bench/sites" \ - --bind=0.0.0.0:9090 \ - --threads=4 \ - --workers=2 \ - --worker-class=gthread \ - --worker-tmp-dir=/dev/shm \ - --timeout=120 \ - --preload \ - frappe.app:application - ''; - }; - }; - systemd.services.erpnext-queue-short = { - enable = true; - wantedBy = [ "multi-user.target" ]; - after = [ "mysql.service" "redis.service" "redis-socketio.service" ]; - description = "ERPNext short queue server"; - confinement = { - enable = true; - packages = [ penv ]; - }; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${penv}/bin/bench worker --queue short - ''; - }; - }; - systemd.services.erpnext-queue-default = { - enable = true; - wantedBy = [ "multi-user.target" ]; - after = [ "mysql.service" "redis.service" "redis-socketio.service" ]; - description = "ERPNext default queue server"; - confinement = { - enable = true; - packages = [ penv ]; - }; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${penv}/bin/bench worker --queue default - ''; - }; - }; - systemd.services.erpnext-queue-long = { - enable = true; - wantedBy = [ "multi-user.target" ]; - after = [ "mysql.service" "redis.service" "redis-socketio.service" ]; - description = "ERPNext long queue server"; - confinement = { - enable = true; - packages = [ penv ]; - }; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${penv}/bin/bench worker --queue long - ''; - }; - }; - systemd.services.erpnext-scheduler = { - enable = true; - wantedBy = [ "multi-user.target" ]; - after = [ "mysql.service" "redis.service" "redis-socketio.service" ]; - description = "ERPNext scheduler server"; - confinement = { - enable = true; - packages = [ penv ]; - }; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${penv}/bin/bench schedule - ''; - }; + adminPasswordFile = ../adminpass.txt; + database.rootPasswordFile = ../dbrootpass.txt; + database.passwordFile = ../dbuserpass.txt; + caddy = {}; }; }; }