{ 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-admin-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."; }; userPasswordFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/secrets/erpnext-db-user-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-db-root-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."; }; automaticMigrations = mkEnableOption (lib.mdDoc "automatic migrations for database schema and data") // { default = true; }; }; 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."; }; 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 = [ "erpnext.your.domain" "erp.your.domain" ]; # Disable access logs logFormat = '' output discard ''; } ''; 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}`hostName` is `''${domain}`, TLS is active by default, and handled by caddy. Additionally, you probably want to set the caddy email option, when enabling this: {option}`services.caddy.email 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_USER_PASSWORD#"; redis_cache = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=1"; redis_queue = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=2"; redis_socketio = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=0"; 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" "/etc/resolv.conf:/etc/resolv.conf" "/etc/ssl:/etc/ssl" "/etc/static/ssl:/etc/static/ssl" "/run/agenix:/run/agenix" "${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('$(cat "${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.virtualHosts."${cfg.domain}" = 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} { header_up X-Frappe-Site-Name "${cfg.domain}" header_up Origin "{scheme}://${cfg.domain}" } } 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; # pkgs listed here get added to the services' BindReadOnlyPaths # The same is true for pkgs referred to in ExecStartPre, ExecStart, etc. # Explicitily listing these pkgs here for visibility packages = [ penv pkgs.coreutils # Dependency for 'bench new-site' subcommand pkgs.mariadb-client pkgs.replace-secret ]; }; environment = { PYTHON_PATH = "${penv}/${pkgs.python3.sitePackages}"; }; 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 # Fail on error set -e ${pkgs.coreutils}/bin/install -m0600 ${commonSiteConfigFile} ${cfg.benchDir}/sites/common_site_config.json ${pkgs.replace-secret}/bin/replace-secret \ '#NIXOS_ERPNEXT_DB_USER_PASSWORD#' \ ${cfg.database.userPasswordFile} \ ${cfg.benchDir}/sites/common_site_config.json ADMIN_PASSWORD="$(${pkgs.coreutils}/bin/cat "${cfg.adminPasswordFile}")" DB_ROOT_PASSWORD="$(${pkgs.coreutils}/bin/cat "${cfg.database.rootPasswordFile}")" # Upstream initializes the database with this command ${penv}/bin/bench new-site ${cfg.domain} \ --mariadb-root-password "$DB_ROOT_PASSWORD" \ --admin-password "$ADMIN_PASSWORD" \ --install-app erpnext ${pkgs.coreutils}/bin/touch ${escapeShellArg "${cfg.workDir}/.db-created"} fi ${lib.optionalString cfg.database.automaticMigrations '' # Migrate the database ${penv}/bin/bench --site ${cfg.domain} migrate ''} ''; 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; after = [ "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; after = [ "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; after = [ "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; after = [ "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 ''; }; }; }; }