nixos/kimai: init module & add test
This commit is contained in:
parent
0d946aac1e
commit
29e586e508
@ -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
|
||||
|
403
nixos/modules/services/web-apps/kimai.nix
Normal file
403
nixos/modules/services/web-apps/kimai.nix
Normal 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;
|
||||
};
|
||||
})
|
||||
|
||||
]);
|
||||
}
|
@ -514,6 +514,7 @@ in {
|
||||
keycloak = discoverTests (import ./keycloak.nix);
|
||||
keyd = handleTest ./keyd.nix {};
|
||||
keymap = handleTest ./keymap.nix {};
|
||||
kimai = handleTest ./kimai.nix {};
|
||||
knot = handleTest ./knot.nix {};
|
||||
komga = handleTest ./komga.nix {};
|
||||
krb5 = discoverTests (import ./krb5);
|
||||
|
23
nixos/tests/kimai.nix
Normal file
23
nixos/tests/kimai.nix
Normal 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/")
|
||||
'';
|
||||
}
|
||||
)
|
@ -2,6 +2,7 @@
|
||||
php,
|
||||
fetchFromGitHub,
|
||||
lib,
|
||||
nixosTests,
|
||||
}:
|
||||
|
||||
php.buildComposerProject (finalAttrs: {
|
||||
@ -50,6 +51,10 @@ php.buildComposerProject (finalAttrs: {
|
||||
ln -s "$out"/share/php/kimai/bin/console "$out"/bin/console
|
||||
'';
|
||||
|
||||
passthru.tests = {
|
||||
kimai = nixosTests.kimai;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Web-based multi-user time-tracking application";
|
||||
homepage = "https://www.kimai.org/";
|
||||
|
Loading…
Reference in New Issue
Block a user