erpnext-nix/modules/erpnext.nix
teutat3s ceb04d96de
module: fix DNS by adding resolv.conf, ssl dirs to
BindReadOnlyPaths
This should fix temporary name resolution errors observed in erpnext.

Minor cleanup and explanatory comment for confinement.packages

We don't use pkgs from path, but prefer explicitly referring to pkgs
2023-07-18 12:23:09 +02:00

462 lines
14 KiB
Nix

{ 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}
}
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
'';
};
};
};
}