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