nixos/kimai: init module & add test

This commit is contained in:
Ratchanan Srirattanamet 2024-11-02 05:50:17 +07:00
parent 0d946aac1e
commit 29e586e508
5 changed files with 433 additions and 0 deletions

View File

@ -1459,6 +1459,7 @@
./services/web-apps/kasmweb/default.nix ./services/web-apps/kasmweb/default.nix
./services/web-apps/kavita.nix ./services/web-apps/kavita.nix
./services/web-apps/keycloak.nix ./services/web-apps/keycloak.nix
./services/web-apps/kimai.nix
./services/web-apps/komga.nix ./services/web-apps/komga.nix
./services/web-apps/lanraragi.nix ./services/web-apps/lanraragi.nix
./services/web-apps/lemmy.nix ./services/web-apps/lemmy.nix

View File

@ -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 <https://www.kimai.org/documentation/local-yaml.html#localyaml>
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 </dev/urandom | head -c 20 >${appSecretFile}
fi
cat >${envFile} <<EOF
DATABASE_URL=${dbUri}
MAILER_FROM=kimai@example.com
MAILER_URL=null://null
APP_ENV=prod
APP_SECRET=$(cat ${appSecretFile})
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?\$
EOF
umask $oldUmask
# Run kimai:install to ensure database is created or updated.
# Note that kimai:update is an alias to kimai:install.
${pkg hostName cfg}/bin/console kimai:install
'';
serviceConfig = {
Type = "oneshot";
User = user;
Group = webserver.group;
EnvironmentFile = [ cfg.environmentFile ];
};
})
) eachSite)
(mapAttrs' (
hostName: cfg:
(nameValuePair "phpfpm-kimai-${hostName}.service" {
serviceConfig = {
EnvironmentFile = [ cfg.environmentFile ];
};
})
) eachSite)
(optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
"${cfg.webserver}".after = [ "mysql.service" ];
})
];
users.users.${user} = {
group = webserver.group;
isSystemUser = true;
};
}
(mkIf (cfg.webserver == "nginx") {
services.nginx = {
enable = true;
virtualHosts = mapAttrs (hostName: cfg: {
serverName = mkDefault hostName;
root = "${pkg hostName cfg}/share/php/kimai/public";
extraConfig = ''
index index.php;
'';
locations = {
"/" = {
priority = 200;
extraConfig = ''
try_files $uri /index.php$is_args$args;
'';
};
"~ ^/index\\.php(/|$)" = {
priority = 500;
extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools."kimai-${hostName}".socket};
fastcgi_index index.php;
include "${config.services.nginx.package}/conf/fastcgi.conf";
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
# Mitigate https://httpoxy.org/ vulnerabilities
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
'';
};
"~ \\.php$" = {
priority = 800;
extraConfig = ''
return 404;
'';
};
};
}) eachSite;
};
})
]);
}

View File

@ -514,6 +514,7 @@ in {
keycloak = discoverTests (import ./keycloak.nix); keycloak = discoverTests (import ./keycloak.nix);
keyd = handleTest ./keyd.nix {}; keyd = handleTest ./keyd.nix {};
keymap = handleTest ./keymap.nix {}; keymap = handleTest ./keymap.nix {};
kimai = handleTest ./kimai.nix {};
knot = handleTest ./knot.nix {}; knot = handleTest ./knot.nix {};
komga = handleTest ./komga.nix {}; komga = handleTest ./komga.nix {};
krb5 = discoverTests (import ./krb5); krb5 = discoverTests (import ./krb5);

23
nixos/tests/kimai.nix Normal file
View File

@ -0,0 +1,23 @@
import ./make-test-python.nix (
{ lib, ... }:
{
name = "kimai";
meta.maintainers = with lib.maintainers; [ peat-psuwit ];
nodes.machine =
{ ... }:
{
services.kimai.sites."localhost" = {
database.createLocally = true;
};
};
testScript = ''
machine.wait_for_unit("phpfpm-kimai-localhost.service")
machine.wait_for_unit("nginx.service")
machine.wait_for_open_port(80)
machine.succeed("curl -v --location --fail http://localhost/")
'';
}
)

View File

@ -2,6 +2,7 @@
php, php,
fetchFromGitHub, fetchFromGitHub,
lib, lib,
nixosTests,
}: }:
php.buildComposerProject (finalAttrs: { php.buildComposerProject (finalAttrs: {
@ -50,6 +51,10 @@ php.buildComposerProject (finalAttrs: {
ln -s "$out"/share/php/kimai/bin/console "$out"/bin/console ln -s "$out"/share/php/kimai/bin/console "$out"/bin/console
''; '';
passthru.tests = {
kimai = nixosTests.kimai;
};
meta = { meta = {
description = "Web-based multi-user time-tracking application"; description = "Web-based multi-user time-tracking application";
homepage = "https://www.kimai.org/"; homepage = "https://www.kimai.org/";