at 25.11-pre 18 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7 8let 9 inherit (lib) 10 mkBefore 11 mkDefault 12 mkEnableOption 13 mkPackageOption 14 mkIf 15 mkOption 16 mkRemovedOptionModule 17 types 18 ; 19 inherit (lib) concatStringsSep literalExpression mapAttrsToList; 20 inherit (lib) optional optionalAttrs optionalString; 21 22 cfg = config.services.redmine; 23 format = pkgs.formats.yaml { }; 24 bundle = "${cfg.package}/share/redmine/bin/bundle"; 25 26 databaseSettings = { 27 production = 28 { 29 adapter = cfg.database.type; 30 database = 31 if cfg.database.type == "sqlite3" then "${cfg.stateDir}/database.sqlite3" else cfg.database.name; 32 } 33 // optionalAttrs (cfg.database.type != "sqlite3") { 34 host = 35 if (cfg.database.type == "postgresql" && cfg.database.socket != null) then 36 cfg.database.socket 37 else 38 cfg.database.host; 39 port = cfg.database.port; 40 username = cfg.database.user; 41 } 42 // optionalAttrs (cfg.database.type != "sqlite3" && cfg.database.passwordFile != null) { 43 password = "#dbpass#"; 44 } 45 // optionalAttrs (cfg.database.type == "mysql2" && cfg.database.socket != null) { 46 socket = cfg.database.socket; 47 }; 48 }; 49 50 databaseYml = format.generate "database.yml" databaseSettings; 51 52 configurationYml = format.generate "configuration.yml" cfg.settings; 53 additionalEnvironment = pkgs.writeText "additional_environment.rb" cfg.extraEnv; 54 55 unpackTheme = unpack "theme"; 56 unpackPlugin = unpack "plugin"; 57 unpack = 58 id: 59 ( 60 name: source: 61 pkgs.stdenv.mkDerivation { 62 name = "redmine-${id}-${name}"; 63 nativeBuildInputs = [ pkgs.unzip ]; 64 buildCommand = '' 65 mkdir -p $out 66 cd $out 67 unpackFile ${source} 68 ''; 69 } 70 ); 71 72 mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql2"; 73 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "postgresql"; 74 75in 76{ 77 imports = [ 78 (mkRemovedOptionModule [ 79 "services" 80 "redmine" 81 "extraConfig" 82 ] "Use services.redmine.settings instead.") 83 (mkRemovedOptionModule [ 84 "services" 85 "redmine" 86 "database" 87 "password" 88 ] "Use services.redmine.database.passwordFile instead.") 89 ]; 90 91 # interface 92 options = { 93 services.redmine = { 94 enable = mkEnableOption "Redmine, a project management web application"; 95 96 package = mkPackageOption pkgs "redmine" { 97 example = "redmine.override { ruby = pkgs.ruby_3_2; }"; 98 }; 99 100 user = mkOption { 101 type = types.str; 102 default = "redmine"; 103 description = "User under which Redmine is ran."; 104 }; 105 106 group = mkOption { 107 type = types.str; 108 default = "redmine"; 109 description = "Group under which Redmine is ran."; 110 }; 111 112 address = mkOption { 113 type = types.str; 114 default = "0.0.0.0"; 115 description = "IP address Redmine should bind to."; 116 }; 117 118 port = mkOption { 119 type = types.port; 120 default = 3000; 121 description = "Port on which Redmine is ran."; 122 }; 123 124 stateDir = mkOption { 125 type = types.path; 126 default = "/var/lib/redmine"; 127 description = "The state directory, logs and plugins are stored here."; 128 }; 129 130 settings = mkOption { 131 type = format.type; 132 default = { }; 133 description = '' 134 Redmine configuration ({file}`configuration.yml`). Refer to 135 <https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration> 136 for details. 137 ''; 138 example = literalExpression '' 139 { 140 email_delivery = { 141 delivery_method = "smtp"; 142 smtp_settings = { 143 address = "mail.example.com"; 144 port = 25; 145 }; 146 }; 147 } 148 ''; 149 }; 150 151 extraEnv = mkOption { 152 type = types.lines; 153 default = ""; 154 description = '' 155 Extra configuration in additional_environment.rb. 156 157 See <https://svn.redmine.org/redmine/trunk/config/additional_environment.rb.example> 158 for details. 159 ''; 160 example = '' 161 config.logger.level = Logger::DEBUG 162 ''; 163 }; 164 165 themes = mkOption { 166 type = types.attrsOf types.path; 167 default = { }; 168 description = "Set of themes."; 169 example = literalExpression '' 170 { 171 dkuk-redmine_alex_skin = builtins.fetchurl { 172 url = "https://bitbucket.org/dkuk/redmine_alex_skin/get/1842ef675ef3.zip"; 173 sha256 = "0hrin9lzyi50k4w2bd2b30vrf1i4fi1c0gyas5801wn8i7kpm9yl"; 174 }; 175 } 176 ''; 177 }; 178 179 plugins = mkOption { 180 type = types.attrsOf types.path; 181 default = { }; 182 description = "Set of plugins."; 183 example = literalExpression '' 184 { 185 redmine_env_auth = builtins.fetchurl { 186 url = "https://github.com/Intera/redmine_env_auth/archive/0.6.zip"; 187 sha256 = "0yyr1yjd8gvvh832wdc8m3xfnhhxzk2pk3gm2psg5w9jdvd6skak"; 188 }; 189 } 190 ''; 191 }; 192 193 database = { 194 type = mkOption { 195 type = types.enum [ 196 "mysql2" 197 "postgresql" 198 "sqlite3" 199 ]; 200 example = "postgresql"; 201 default = "mysql2"; 202 description = "Database engine to use."; 203 }; 204 205 host = mkOption { 206 type = types.str; 207 default = "localhost"; 208 description = "Database host address."; 209 }; 210 211 port = mkOption { 212 type = types.port; 213 default = if cfg.database.type == "postgresql" then 5432 else 3306; 214 defaultText = literalExpression "3306"; 215 description = "Database host port."; 216 }; 217 218 name = mkOption { 219 type = types.str; 220 default = "redmine"; 221 description = "Database name."; 222 }; 223 224 user = mkOption { 225 type = types.str; 226 default = "redmine"; 227 description = "Database user."; 228 }; 229 230 passwordFile = mkOption { 231 type = types.nullOr types.path; 232 default = null; 233 example = "/run/keys/redmine-dbpassword"; 234 description = '' 235 A file containing the password corresponding to 236 {option}`database.user`. 237 ''; 238 }; 239 240 socket = mkOption { 241 type = types.nullOr types.path; 242 default = 243 if mysqlLocal then 244 "/run/mysqld/mysqld.sock" 245 else if pgsqlLocal then 246 "/run/postgresql" 247 else 248 null; 249 defaultText = literalExpression "/run/mysqld/mysqld.sock"; 250 example = "/run/mysqld/mysqld.sock"; 251 description = "Path to the unix socket file to use for authentication."; 252 }; 253 254 createLocally = mkOption { 255 type = types.bool; 256 default = true; 257 description = "Create the database and database user locally."; 258 }; 259 }; 260 261 components = { 262 subversion = mkOption { 263 type = types.bool; 264 default = false; 265 description = "Subversion integration."; 266 }; 267 268 mercurial = mkOption { 269 type = types.bool; 270 default = false; 271 description = "Mercurial integration."; 272 }; 273 274 git = mkOption { 275 type = types.bool; 276 default = false; 277 description = "git integration."; 278 }; 279 280 cvs = mkOption { 281 type = types.bool; 282 default = false; 283 description = "cvs integration."; 284 }; 285 286 breezy = mkOption { 287 type = types.bool; 288 default = false; 289 description = "bazaar integration."; 290 }; 291 292 imagemagick = mkOption { 293 type = types.bool; 294 default = false; 295 description = "Allows exporting Gant diagrams as PNG."; 296 }; 297 298 ghostscript = mkOption { 299 type = types.bool; 300 default = false; 301 description = "Allows exporting Gant diagrams as PDF."; 302 }; 303 304 minimagick_font_path = mkOption { 305 type = types.str; 306 default = ""; 307 description = "MiniMagick font path"; 308 example = "/run/current-system/sw/share/X11/fonts/LiberationSans-Regular.ttf"; 309 }; 310 }; 311 }; 312 }; 313 314 # implementation 315 config = mkIf cfg.enable { 316 317 assertions = [ 318 { 319 assertion = 320 cfg.database.type != "sqlite3" -> cfg.database.passwordFile != null || cfg.database.socket != null; 321 message = "one of services.redmine.database.socket or services.redmine.database.passwordFile must be set"; 322 } 323 { 324 assertion = cfg.database.createLocally -> cfg.database.user == cfg.user; 325 message = "services.redmine.database.user must be set to ${cfg.user} if services.redmine.database.createLocally is set true"; 326 } 327 { 328 assertion = pgsqlLocal -> cfg.database.user == cfg.database.name; 329 message = "services.redmine.database.user and services.redmine.database.name must be the same when using a local postgresql database"; 330 } 331 { 332 assertion = 333 (cfg.database.createLocally && cfg.database.type != "sqlite3") -> cfg.database.socket != null; 334 message = "services.redmine.database.socket must be set if services.redmine.database.createLocally is set to true and no sqlite database is used"; 335 } 336 { 337 assertion = cfg.database.createLocally -> cfg.database.host == "localhost"; 338 message = "services.redmine.database.host must be set to localhost if services.redmine.database.createLocally is set to true"; 339 } 340 { 341 assertion = cfg.components.imagemagick -> cfg.components.minimagick_font_path != ""; 342 message = "services.redmine.components.minimagick_font_path must be configured with a path to a font file if services.redmine.components.imagemagick is set to true."; 343 } 344 ]; 345 346 services.redmine.settings = { 347 production = { 348 scm_subversion_command = optionalString cfg.components.subversion "${pkgs.subversion}/bin/svn"; 349 scm_mercurial_command = optionalString cfg.components.mercurial "${pkgs.mercurial}/bin/hg"; 350 scm_git_command = optionalString cfg.components.git "${pkgs.git}/bin/git"; 351 scm_cvs_command = optionalString cfg.components.cvs "${pkgs.cvs}/bin/cvs"; 352 scm_bazaar_command = optionalString cfg.components.breezy "${pkgs.breezy}/bin/bzr"; 353 imagemagick_convert_command = optionalString cfg.components.imagemagick "${pkgs.imagemagick}/bin/convert"; 354 gs_command = optionalString cfg.components.ghostscript "${pkgs.ghostscript}/bin/gs"; 355 minimagick_font_path = "${cfg.components.minimagick_font_path}"; 356 }; 357 }; 358 359 services.redmine.extraEnv = mkBefore '' 360 config.logger = Logger.new("${cfg.stateDir}/log/production.log", 14, 1048576) 361 config.logger.level = Logger::INFO 362 ''; 363 364 services.mysql = mkIf mysqlLocal { 365 enable = true; 366 package = mkDefault pkgs.mariadb; 367 ensureDatabases = [ cfg.database.name ]; 368 ensureUsers = [ 369 { 370 name = cfg.database.user; 371 ensurePermissions = { 372 "${cfg.database.name}.*" = "ALL PRIVILEGES"; 373 }; 374 } 375 ]; 376 }; 377 378 services.postgresql = mkIf pgsqlLocal { 379 enable = true; 380 ensureDatabases = [ cfg.database.name ]; 381 ensureUsers = [ 382 { 383 name = cfg.database.user; 384 ensureDBOwnership = true; 385 } 386 ]; 387 }; 388 389 # create symlinks for the basic directory layout the redmine package expects 390 systemd.tmpfiles.rules = [ 391 "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" 392 "d '${cfg.stateDir}/cache' 0750 ${cfg.user} ${cfg.group} - -" 393 "d '${cfg.stateDir}/config' 0750 ${cfg.user} ${cfg.group} - -" 394 "d '${cfg.stateDir}/files' 0750 ${cfg.user} ${cfg.group} - -" 395 "d '${cfg.stateDir}/log' 0750 ${cfg.user} ${cfg.group} - -" 396 "d '${cfg.stateDir}/plugins' 0750 ${cfg.user} ${cfg.group} - -" 397 "d '${cfg.stateDir}/public' 0750 ${cfg.user} ${cfg.group} - -" 398 "d '${cfg.stateDir}/public/assets' 0750 ${cfg.user} ${cfg.group} - -" 399 "d '${cfg.stateDir}/public/plugin_assets' 0750 ${cfg.user} ${cfg.group} - -" 400 "d '${cfg.stateDir}/themes' 0750 ${cfg.user} ${cfg.group} - -" 401 "d '${cfg.stateDir}/tmp' 0750 ${cfg.user} ${cfg.group} - -" 402 403 "d /run/redmine - - - - -" 404 "d /run/redmine/public - - - - -" 405 "L+ /run/redmine/config - - - - ${cfg.stateDir}/config" 406 "L+ /run/redmine/files - - - - ${cfg.stateDir}/files" 407 "L+ /run/redmine/log - - - - ${cfg.stateDir}/log" 408 "L+ /run/redmine/plugins - - - - ${cfg.stateDir}/plugins" 409 "L+ /run/redmine/public/assets - - - - ${cfg.stateDir}/public/assets" 410 "L+ /run/redmine/public/plugin_assets - - - - ${cfg.stateDir}/public/plugin_assets" 411 "L+ /run/redmine/themes - - - - ${cfg.stateDir}/themes" 412 "L+ /run/redmine/tmp - - - - ${cfg.stateDir}/tmp" 413 ]; 414 415 systemd.services.redmine = { 416 after = 417 [ "network.target" ] 418 ++ optional mysqlLocal "mysql.service" 419 ++ optional pgsqlLocal "postgresql.service"; 420 wantedBy = [ "multi-user.target" ]; 421 environment.RAILS_ENV = "production"; 422 environment.RAILS_CACHE = "${cfg.stateDir}/cache"; 423 environment.REDMINE_LANG = "en"; 424 environment.SCHEMA = "${cfg.stateDir}/cache/schema.db"; 425 path = 426 with pkgs; 427 [ 428 ] 429 ++ optional cfg.components.subversion subversion 430 ++ optional cfg.components.mercurial mercurial 431 ++ optional cfg.components.git git 432 ++ optional cfg.components.cvs cvs 433 ++ optional cfg.components.breezy breezy 434 ++ optional cfg.components.imagemagick imagemagick 435 ++ optional cfg.components.ghostscript ghostscript; 436 437 preStart = '' 438 rm -rf "${cfg.stateDir}/plugins/"* 439 rm -rf "${cfg.stateDir}/themes/"* 440 441 # start with a fresh config directory 442 # the config directory is copied instead of linked as some mutable data is stored in there 443 find "${cfg.stateDir}/config" ! -name "secret_token.rb" -type f -exec rm -f {} + 444 cp -r ${cfg.package}/share/redmine/config.dist/* "${cfg.stateDir}/config/" 445 446 chmod -R u+w "${cfg.stateDir}/config" 447 448 # link in the application configuration 449 ln -fs ${configurationYml} "${cfg.stateDir}/config/configuration.yml" 450 451 # link in the additional environment configuration 452 ln -fs ${additionalEnvironment} "${cfg.stateDir}/config/additional_environment.rb" 453 454 455 # link in all user specified themes 456 for theme in ${concatStringsSep " " (mapAttrsToList unpackTheme cfg.themes)}; do 457 ln -fs $theme/* "${cfg.stateDir}/themes" 458 done 459 460 # link in redmine provided themes 461 ln -sf ${cfg.package}/share/redmine/themes.dist/* "${cfg.stateDir}/themes/" 462 463 464 # link in all user specified plugins 465 for plugin in ${concatStringsSep " " (mapAttrsToList unpackPlugin cfg.plugins)}; do 466 ln -fs $plugin/* "${cfg.stateDir}/plugins/''${plugin##*-redmine-plugin-}" 467 done 468 469 470 # handle database.passwordFile & permissions 471 cp -f ${databaseYml} "${cfg.stateDir}/config/database.yml" 472 473 ${optionalString ((cfg.database.type != "sqlite3") && (cfg.database.passwordFile != null)) '' 474 DBPASS="$(head -n1 ${cfg.database.passwordFile})" 475 sed -e "s,#dbpass#,$DBPASS,g" -i "${cfg.stateDir}/config/database.yml" 476 ''} 477 478 chmod 440 "${cfg.stateDir}/config/database.yml" 479 480 481 # generate a secret token if required 482 if ! test -e "${cfg.stateDir}/config/initializers/secret_token.rb"; then 483 ${bundle} exec rake generate_secret_token 484 chmod 440 "${cfg.stateDir}/config/initializers/secret_token.rb" 485 fi 486 487 # execute redmine required commands prior to starting the application 488 ${bundle} exec rake db:migrate 489 ${bundle} exec rake redmine:plugins:migrate 490 ${bundle} exec rake redmine:load_default_data 491 ${bundle} exec rake assets:precompile 492 ''; 493 494 serviceConfig = { 495 Type = "simple"; 496 User = cfg.user; 497 Group = cfg.group; 498 TimeoutSec = "300"; 499 WorkingDirectory = "${cfg.package}/share/redmine"; 500 ExecStart = "${bundle} exec rails server -u webrick -e production -b ${toString cfg.address} -p ${toString cfg.port} -P '${cfg.stateDir}/redmine.pid'"; 501 AmbientCapabilities = ""; 502 CapabilityBoundingSet = ""; 503 LockPersonality = true; 504 MemoryDenyWriteExecute = true; 505 NoNewPrivileges = true; 506 PrivateDevices = true; 507 PrivateMounts = true; 508 PrivateTmp = true; 509 ProcSubset = "pid"; 510 ProtectClock = true; 511 ProtectControlGroups = true; 512 ProtectHome = true; 513 ProtectHostname = true; 514 ProtectKernelLogs = true; 515 ProtectKernelModules = true; 516 ProtectKernelTunables = true; 517 ProtectProc = "noaccess"; 518 ProtectSystem = "full"; 519 RemoveIPC = true; 520 RestrictAddressFamilies = [ 521 "AF_UNIX" 522 "AF_INET" 523 ]; 524 RestrictNamespaces = true; 525 RestrictRealtime = true; 526 RestrictSUIDSGID = true; 527 SystemCallArchitectures = "native"; 528 UMask = 27; 529 }; 530 531 }; 532 533 users.users = optionalAttrs (cfg.user == "redmine") { 534 redmine = { 535 group = cfg.group; 536 home = cfg.stateDir; 537 uid = config.ids.uids.redmine; 538 }; 539 }; 540 541 users.groups = optionalAttrs (cfg.group == "redmine") { 542 redmine.gid = config.ids.gids.redmine; 543 }; 544 545 }; 546 547 meta.maintainers = with lib.maintainers; [ felixsinger ]; 548}