{ config, pkgs, lib, ... }: with lib; let cfg = config.services.kimai; eachSite = cfg.sites; user = "kimai"; webserver = config.services.${cfg.webserver}; stateDir = hostName: "/var/lib/kimai/${hostName}"; pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { pname = "kimai-${hostName}"; src = cfg.package; version = src.version; installPhase = '' mkdir -p $out cp -r * $out/ # Symlink .env file. This will be dynamically created at the service # startup. ln -sf ${stateDir hostName}/.env $out/share/php/kimai/.env # Symlink the var/ folder # TODO: we may have to symlink individual folders if we want to also # manage plugins from Nix. rm -rf $out/share/php/kimai/var ln -s ${stateDir hostName} $out/share/php/kimai/var # Symlink local.yaml. ln -s ${kimaiConfig hostName cfg} $out/share/php/kimai/config/packages/local.yaml ''; }; kimaiConfig = hostName: cfg: pkgs.writeTextFile { name = "kimai-config-${hostName}.yaml"; text = generators.toYAML { } cfg.settings; }; siteOpts = { lib, name, config, ... }: { options = { package = mkPackageOption pkgs "kimai" { }; database = { host = mkOption { type = types.str; default = "localhost"; description = "Database host address."; }; port = mkOption { type = types.port; default = 3306; description = "Database host port."; }; name = mkOption { type = types.str; default = "kimai"; description = "Database name."; }; user = mkOption { type = types.str; default = "kimai"; description = "Database user."; }; passwordFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/keys/kimai-dbpassword"; description = '' A file containing the password corresponding to {option}`database.user`. ''; }; socket = mkOption { type = types.nullOr types.path; default = null; defaultText = literalExpression "/run/mysqld/mysqld.sock"; description = "Path to the unix socket file to use for authentication."; }; charset = mkOption { type = types.str; default = "utf8mb4"; description = "Database charset."; }; serverVersion = mkOption { type = types.nullOr types.str; default = null; description = '' MySQL *exact* version string. Not used if `createdLocally` is set, but must be set otherwise. See https://www.kimai.org/documentation/installation.html#column-table_name-in-where-clause-is-ambiguous for how to set this value, especially if you're using MariaDB. ''; }; createLocally = mkOption { type = types.bool; default = true; description = "Create the database and database user locally."; }; }; poolConfig = mkOption { type = with types; attrsOf (oneOf [ str int bool ]); default = { "pm" = "dynamic"; "pm.max_children" = 32; "pm.start_servers" = 2; "pm.min_spare_servers" = 2; "pm.max_spare_servers" = 4; "pm.max_requests" = 500; }; description = '' Options for the Kimai PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. ''; }; settings = mkOption { type = types.attrsOf types.anything; default = { }; description = '' Structural Kimai's local.yaml configuration. Refer to for details. ''; example = literalExpression '' { kimai = { timesheet = { rounding = { default = { begin = 15; end = 15; }; }; }; }; } ''; }; environmentFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/secrets/kimai.env"; description = '' Securely pass environment variabels to Kimai. This can be used to set other environement variables such as MAILER_URL. ''; }; }; }; in { # interface options = { services.kimai = { sites = mkOption { type = types.attrsOf (types.submodule siteOpts); default = { }; description = "Specification of one or more Kimai sites to serve"; }; webserver = mkOption { type = types.enum [ "nginx" ]; default = "nginx"; description = '' The webserver to configure for the PHP frontend. At the moment, only `nginx` is supported. PRs are welcome for support for other web servers. ''; }; }; }; # implementation config = mkIf (eachSite != { }) (mkMerge [ { assertions = (mapAttrsToList (hostName: cfg: { assertion = cfg.database.createLocally -> cfg.database.user == user; message = ''services.kimai.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; }) eachSite) ++ (mapAttrsToList (hostName: cfg: { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; message = ''services.kimai.sites."${hostName}".database.passwordFile cannot be specified if services.kimai.sites."${hostName}".database.createLocally is set to true.''; }) eachSite) ++ (mapAttrsToList (hostName: cfg: { assertion = !cfg.database.createLocally -> cfg.database.serverVersion != null; message = ''services.kimai.sites."${hostName}".database.serverVersion must be specified if services.kimai.sites."${hostName}".database.createLocally is set to false.''; }) eachSite); services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { enable = true; package = mkDefault pkgs.mariadb; ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; ensureUsers = mapAttrsToList (hostName: cfg: { name = cfg.database.user; ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; }) eachSite; }; services.phpfpm.pools = mapAttrs' ( hostName: cfg: (nameValuePair "kimai-${hostName}" { inherit user; group = webserver.group; settings = { "listen.owner" = webserver.user; "listen.group" = webserver.group; } // cfg.poolConfig; }) ) eachSite; } { systemd.tmpfiles.rules = flatten ( mapAttrsToList (hostName: cfg: [ "d '${stateDir hostName}' 0770 ${user} ${webserver.group} - -" ]) eachSite ); systemd.services = mkMerge [ (mapAttrs' ( hostName: cfg: (nameValuePair "kimai-init-${hostName}" { wantedBy = [ "multi-user.target" ]; before = [ "phpfpm-kimai-${hostName}.service" ]; after = optional cfg.database.createLocally "mysql.service"; script = let envFile = "${stateDir hostName}/.env"; appSecretFile = "${stateDir hostName}/.app_secret"; mysql = "${config.services.mysql.package}/bin/mysql"; dbUser = cfg.database.user; dbPwd = if cfg.database.passwordFile != null then ":$(cat ${cfg.database.passwordFile})" else ""; dbHost = cfg.database.host; dbPort = toString cfg.database.port; dbName = cfg.database.name; dbCharset = cfg.database.charset; dbUnixSocket = if cfg.database.socket != null then "&unixSocket=${cfg.database.socket}" else ""; # Note: serverVersion is a shell variable. See below. dbUri = "mysql://${dbUser}${dbPwd}@${dbHost}:${dbPort}" + "/${dbName}?charset=${dbCharset}" + "&serverVersion=$serverVersion${dbUnixSocket}"; in '' set -eu serverVersion=${ if !cfg.database.createLocally then cfg.database.serverVersion else # Obtain MySQL version string dynamically from the running # instance. Doctrine ORM's doc said it should be possible to # autodetect this, however Kimai's doc insists that it has to # be set. # https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#mysql # https://stackoverflow.com/q/9558867 "$(${mysql} --silent --skip-column-names --execute 'SELECT VERSION();')" } # Create .env file containing DATABASE_URL and other default # variables. Set umask to make sure .env is not readable by # unrelated users. oldUmask=$(umask) umask 177 if ! [ -e ${appSecretFile} ]; then tr -dc A-Za-z0-9 ${appSecretFile} fi cat >${envFile} <