From 29e586e508e60e50a0dad49a36cecca968fe4728 Mon Sep 17 00:00:00 2001 From: Ratchanan Srirattanamet Date: Sat, 2 Nov 2024 05:50:17 +0700 Subject: [PATCH] nixos/kimai: init module & add test --- nixos/modules/module-list.nix | 1 + nixos/modules/services/web-apps/kimai.nix | 403 ++++++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/kimai.nix | 23 ++ pkgs/by-name/ki/kimai/package.nix | 5 + 5 files changed, 433 insertions(+) create mode 100644 nixos/modules/services/web-apps/kimai.nix create mode 100644 nixos/tests/kimai.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index c991a7ec2502..5b85b0c5fad7 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1459,6 +1459,7 @@ ./services/web-apps/kasmweb/default.nix ./services/web-apps/kavita.nix ./services/web-apps/keycloak.nix + ./services/web-apps/kimai.nix ./services/web-apps/komga.nix ./services/web-apps/lanraragi.nix ./services/web-apps/lemmy.nix diff --git a/nixos/modules/services/web-apps/kimai.nix b/nixos/modules/services/web-apps/kimai.nix new file mode 100644 index 000000000000..06cb547b613a --- /dev/null +++ b/nixos/modules/services/web-apps/kimai.nix @@ -0,0 +1,403 @@ +{ + 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} <