nixos/mattermost: modernize, support MySQL and mmctl

Based on #198040. Prioritizes backwards compatibility, including
database and plugin compatibility, while adding more sensible
defaults like database peer authentication.

Expand the scope of tests to include plugins (including building
from source) and testing that a piece of media uploads and downloads
to make sure the storage directory doesn't vanish.

authored by Morgan Jones and committed by Valentin Gagarin f8eac009 8944a425

+1247 -322
+709 -152
nixos/modules/services/web-apps/mattermost.nix
··· 5 ... 6 }: 7 8 - with lib; 9 10 - let 11 12 cfg = config.services.mattermost; 13 14 - database = "postgres://${cfg.localDatabaseUser}:${cfg.localDatabasePassword}@localhost:5432/${cfg.localDatabaseName}?sslmode=disable&connect_timeout=10"; 15 16 - postgresPackage = config.services.postgresql.package; 17 18 - createDb = 19 { 20 - statePath ? cfg.statePath, 21 - localDatabaseUser ? cfg.localDatabaseUser, 22 - localDatabasePassword ? cfg.localDatabasePassword, 23 - localDatabaseName ? cfg.localDatabaseName, 24 - useSudo ? true, 25 }: 26 - '' 27 - if ! test -e ${escapeShellArg "${statePath}/.db-created"}; then 28 - ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} 29 - ${postgresPackage}/bin/psql postgres -c \ 30 - "CREATE ROLE ${localDatabaseUser} WITH LOGIN NOCREATEDB NOCREATEROLE ENCRYPTED PASSWORD '${localDatabasePassword}'" 31 - ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"} 32 - ${postgresPackage}/bin/createdb \ 33 - --owner ${escapeShellArg localDatabaseUser} ${escapeShellArg localDatabaseName} 34 - touch ${escapeShellArg "${statePath}/.db-created"} 35 - fi 36 - ''; 37 38 mattermostPluginDerivations = 39 with pkgs; ··· 60 else 61 stdenv.mkDerivation { 62 name = "${cfg.package.name}-plugins"; 63 - nativeBuildInputs = [ 64 - autoPatchelfHook 65 - ] ++ mattermostPluginDerivations; 66 - buildInputs = [ 67 - cfg.package 68 - ]; 69 installPhase = '' 70 - mkdir -p $out/data/plugins 71 plugins=(${ 72 escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations) 73 }) 74 for plugin in "''${plugins[@]}"; do 75 - hash="$(sha256sum "$plugin" | cut -d' ' -f1)" 76 mkdir -p "$hash" 77 tar -C "$hash" -xzf "$plugin" 78 autoPatchelf "$hash" 79 - GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/data/plugins/$hash.tar.gz" . 80 rm -rf "$hash" 81 done 82 ''; ··· 89 }; 90 91 mattermostConfWithoutPlugins = recursiveUpdate { 92 - ServiceSettings.SiteURL = cfg.siteUrl; 93 - ServiceSettings.ListenAddress = cfg.listenAddress; 94 TeamSettings.SiteName = cfg.siteName; 95 - SqlSettings.DriverName = "postgres"; 96 - SqlSettings.DataSource = database; 97 - PluginSettings.Directory = "${cfg.statePath}/plugins/server"; 98 - PluginSettings.ClientDirectory = "${cfg.statePath}/plugins/client"; 99 - } cfg.extraConfig; 100 101 mattermostConf = recursiveUpdate mattermostConfWithoutPlugins ( 102 - lib.optionalAttrs (mattermostPlugins != null) { 103 - PluginSettings = { 104 - Enable = true; 105 - }; 106 - } 107 ); 108 109 mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf); 110 111 in 112 - 113 { 114 options = { 115 services.mattermost = { 116 enable = mkEnableOption "Mattermost chat server"; 117 118 package = mkPackageOption pkgs "mattermost" { }; 119 - 120 - statePath = mkOption { 121 - type = types.str; 122 - default = "/var/lib/mattermost"; 123 - description = "Mattermost working directory"; 124 - }; 125 126 siteUrl = mkOption { 127 type = types.str; ··· 137 description = "Name of this Mattermost site."; 138 }; 139 140 - listenAddress = mkOption { 141 type = types.str; 142 - default = ":8065"; 143 - example = "[::1]:8065"; 144 description = '' 145 - Address and port this Mattermost instance listens to. 146 ''; 147 }; 148 ··· 173 ''; 174 }; 175 176 - extraConfig = mkOption { 177 - type = types.attrs; 178 - default = { }; 179 - description = '' 180 - Additional configuration options as Nix attribute set in config.json schema. 181 - ''; 182 - }; 183 - 184 plugins = mkOption { 185 - type = types.listOf ( 186 - types.oneOf [ 187 - types.path 188 - types.package 189 - ] 190 - ); 191 default = [ ]; 192 example = "[ ./com.github.moussetc.mattermost.plugin.giphy-2.0.0.tar.gz ]"; 193 description = '' ··· 196 .tar.gz files. 197 ''; 198 }; 199 environmentFile = mkOption { 200 - type = types.nullOr types.path; 201 default = null; 202 description = '' 203 Environment file (see {manpage}`systemd.exec(5)` 204 "EnvironmentFile=" section for the syntax) which sets config options 205 - for mattermost (see [the mattermost documentation](https://docs.mattermost.com/configure/configuration-settings.html#environment-variables)). 206 207 Settings defined in the environment file will overwrite settings 208 set via nix or via the {option}`services.mattermost.extraConfig` ··· 213 ''; 214 }; 215 216 - localDatabaseCreate = mkOption { 217 - type = types.bool; 218 - default = true; 219 - description = '' 220 - Create a local PostgreSQL database for Mattermost automatically. 221 - ''; 222 - }; 223 224 - localDatabaseName = mkOption { 225 - type = types.str; 226 - default = "mattermost"; 227 - description = '' 228 - Local Mattermost database name. 229 - ''; 230 - }; 231 232 - localDatabaseUser = mkOption { 233 - type = types.str; 234 - default = "mattermost"; 235 - description = '' 236 - Local Mattermost database username. 237 - ''; 238 - }; 239 240 - localDatabasePassword = mkOption { 241 - type = types.str; 242 - default = "mmpgsecret"; 243 - description = '' 244 - Password for local Mattermost database user. 245 - ''; 246 }; 247 248 user = mkOption { ··· 261 ''; 262 }; 263 264 matterircd = { 265 enable = mkEnableOption "Mattermost IRC bridge"; 266 package = mkPackageOption pkgs "matterircd" { }; ··· 282 283 config = mkMerge [ 284 (mkIf cfg.enable { 285 - users.users = optionalAttrs (cfg.user == "mattermost") { 286 - mattermost = { 287 group = cfg.group; 288 - uid = config.ids.uids.mattermost; 289 - home = cfg.statePath; 290 }; 291 }; 292 293 - users.groups = optionalAttrs (cfg.group == "mattermost") { 294 - mattermost.gid = config.ids.gids.mattermost; 295 }; 296 297 - services.postgresql.enable = cfg.localDatabaseCreate; 298 299 - # The systemd service will fail to execute the preStart hook 300 - # if the WorkingDirectory does not exist 301 - systemd.tmpfiles.settings."10-mattermost".${cfg.statePath}.d = { }; 302 303 - systemd.services.mattermost = { 304 description = "Mattermost chat service"; 305 wantedBy = [ "multi-user.target" ]; 306 - after = [ 307 - "network.target" 308 - "postgresql.service" 309 ]; 310 311 preStart = 312 '' 313 - mkdir -p "${cfg.statePath}"/{data,config,logs,plugins} 314 - mkdir -p "${cfg.statePath}/plugins"/{client,server} 315 - ln -sf ${cfg.package}/{bin,fonts,i18n,templates,client} "${cfg.statePath}" 316 '' 317 - + lib.optionalString (mattermostPlugins != null) '' 318 - rm -rf "${cfg.statePath}/data/plugins" 319 - ln -sf ${mattermostPlugins}/data/plugins "${cfg.statePath}/data" 320 '' 321 - + lib.optionalString (!cfg.mutableConfig) '' 322 - rm -f "${cfg.statePath}/config/config.json" 323 - ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" 324 '' 325 - + lib.optionalString cfg.mutableConfig '' 326 - if ! test -e "${cfg.statePath}/config/.initial-created"; then 327 - rm -f ${cfg.statePath}/config/config.json 328 - ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json" 329 - touch "${cfg.statePath}/config/.initial-created" 330 fi 331 '' 332 - + lib.optionalString (cfg.mutableConfig && cfg.preferNixConfig) '' 333 - new_config="$(${pkgs.jq}/bin/jq -s '.[0] * .[1]' "${cfg.statePath}/config/config.json" ${mattermostConfJSON})" 334 335 - rm -f "${cfg.statePath}/config/config.json" 336 - echo "$new_config" > "${cfg.statePath}/config/config.json" 337 - '' 338 - + lib.optionalString cfg.localDatabaseCreate (createDb { }) 339 - + '' 340 - # Don't change permissions recursively on the data, current, and symlinked directories (see ln -sf command above). 341 - # This dramatically decreases startup times for installations with a lot of files. 342 - find . -maxdepth 1 -not -name data -not -name client -not -name templates -not -name i18n -not -name fonts -not -name bin -not -name . \ 343 - -exec chown "${cfg.user}:${cfg.group}" -R {} \; -exec chmod u+rw,g+r,o-rwx -R {} \; 344 345 - chown "${cfg.user}:${cfg.group}" "${cfg.statePath}/data" . 346 - chmod u+rw,g+r,o-rwx "${cfg.statePath}/data" . 347 ''; 348 - 349 - serviceConfig = { 350 - PermissionsStartOnly = true; 351 - User = cfg.user; 352 - Group = cfg.group; 353 - ExecStart = "${cfg.package}/bin/mattermost"; 354 - WorkingDirectory = "${cfg.statePath}"; 355 - Restart = "always"; 356 - RestartSec = "10"; 357 - LimitNOFILE = "49152"; 358 - EnvironmentFile = cfg.environmentFile; 359 - }; 360 - unitConfig.JoinsNamespaceOf = mkIf cfg.localDatabaseCreate "postgresql.service"; 361 - }; 362 }) 363 (mkIf cfg.matterircd.enable { 364 systemd.services.matterircd = { ··· 367 serviceConfig = { 368 User = "nobody"; 369 Group = "nogroup"; 370 - ExecStart = "${cfg.matterircd.package}/bin/matterircd ${escapeShellArgs cfg.matterircd.parameters}"; 371 WorkingDirectory = "/tmp"; 372 PrivateTmp = true; 373 Restart = "always"; ··· 376 }; 377 }) 378 ]; 379 }
··· 5 ... 6 }: 7 8 + let 9 + inherit (lib.strings) 10 + hasInfix 11 + hasSuffix 12 + escapeURL 13 + concatStringsSep 14 + escapeShellArg 15 + escapeShellArgs 16 + versionAtLeast 17 + optionalString 18 + ; 19 + 20 + inherit (lib.meta) getExe; 21 + 22 + inherit (lib.lists) singleton; 23 + 24 + inherit (lib.attrsets) mapAttrsToList recursiveUpdate optionalAttrs; 25 + 26 + inherit (lib.options) mkOption mkPackageOption mkEnableOption; 27 28 + inherit (lib.modules) 29 + mkRenamedOptionModule 30 + mkMerge 31 + mkIf 32 + mkDefault 33 + ; 34 + 35 + inherit (lib.trivial) warnIf throwIf; 36 + 37 + inherit (lib) types; 38 39 cfg = config.services.mattermost; 40 41 + # The directory to store mutable data within dataDir. 42 + mutableDataDir = "${cfg.dataDir}/data"; 43 44 + # The plugin directory. Note that this is the *post-unpack* plugin directory, 45 + # since Mattermost unpacks plugins to put them there. (Hence, mutable data.) 46 + pluginDir = "${mutableDataDir}/plugins"; 47 + 48 + # Mattermost uses this as a staging directory to unpack plugins, among possibly other things. 49 + # Ensure that it's inside mutableDataDir since it can get rather large. 50 + tempDir = "${mutableDataDir}/tmp"; 51 52 + # Creates a database URI. 53 + mkDatabaseUri = 54 { 55 + scheme, 56 + user ? null, 57 + password ? null, 58 + escapeUserAndPassword ? true, 59 + host ? null, 60 + escapeHost ? true, 61 + port ? null, 62 + path ? null, 63 + query ? { }, 64 }: 65 + let 66 + nullToEmpty = val: if val == null then "" else toString val; 67 + 68 + # Converts a list of URI attrs to a query string. 69 + toQuery = mapAttrsToList ( 70 + name: value: if value == null then null else (escapeURL name) + "=" + (escapeURL (toString value)) 71 + ); 72 + 73 + schemePart = if scheme == null then "" else "${escapeURL scheme}://"; 74 + userPart = 75 + let 76 + realUser = if escapeUserAndPassword then escapeURL user else user; 77 + realPassword = if escapeUserAndPassword then escapeURL password else password; 78 + in 79 + if user == null && password == null then 80 + "" 81 + else if user != null && password != null then 82 + "${realUser}:${realPassword}" 83 + else if user != null then 84 + realUser 85 + else 86 + throw "Either user or username and password must be provided"; 87 + hostPart = 88 + let 89 + realHost = if escapeHost then escapeURL (nullToEmpty host) else nullToEmpty host; 90 + in 91 + if userPart == "" then realHost else "@" + realHost; 92 + portPart = if port == null then "" else ":" + (toString port); 93 + pathPart = if path == null then "" else "/" + path; 94 + queryPart = if query == { } then "" else "?" + concatStringsSep "&" (toQuery query); 95 + in 96 + schemePart + userPart + hostPart + portPart + pathPart + queryPart; 97 + 98 + database = 99 + let 100 + hostIsPath = hasInfix "/" cfg.database.host; 101 + in 102 + if cfg.database.driver == "postgres" then 103 + if cfg.database.peerAuth then 104 + mkDatabaseUri { 105 + scheme = cfg.database.driver; 106 + inherit (cfg.database) user; 107 + path = escapeURL cfg.database.name; 108 + query = { 109 + host = cfg.database.socketPath; 110 + } // cfg.database.extraConnectionOptions; 111 + } 112 + else 113 + mkDatabaseUri { 114 + scheme = cfg.database.driver; 115 + inherit (cfg.database) user password; 116 + host = if hostIsPath then null else cfg.database.host; 117 + port = if hostIsPath then null else cfg.database.port; 118 + path = escapeURL cfg.database.name; 119 + query = 120 + optionalAttrs hostIsPath { host = cfg.database.host; } // cfg.database.extraConnectionOptions; 121 + } 122 + else if cfg.database.driver == "mysql" then 123 + if cfg.database.peerAuth then 124 + mkDatabaseUri { 125 + scheme = null; 126 + inherit (cfg.database) user; 127 + escapeUserAndPassword = false; 128 + host = "unix(${cfg.database.socketPath})"; 129 + escapeHost = false; 130 + path = escapeURL cfg.database.name; 131 + query = cfg.database.extraConnectionOptions; 132 + } 133 + else 134 + mkDatabaseUri { 135 + scheme = null; 136 + inherit (cfg.database) user password; 137 + escapeUserAndPassword = false; 138 + host = 139 + if hostIsPath then 140 + "unix(${cfg.database.host})" 141 + else 142 + "tcp(${cfg.database.host}:${toString cfg.database.port})"; 143 + escapeHost = false; 144 + path = escapeURL cfg.database.name; 145 + query = cfg.database.extraConnectionOptions; 146 + } 147 + else 148 + throw "Invalid database driver: ${cfg.database.driver}"; 149 150 mattermostPluginDerivations = 151 with pkgs; ··· 172 else 173 stdenv.mkDerivation { 174 name = "${cfg.package.name}-plugins"; 175 + nativeBuildInputs = [ autoPatchelfHook ] ++ mattermostPluginDerivations; 176 + buildInputs = [ cfg.package ]; 177 installPhase = '' 178 + mkdir -p $out 179 plugins=(${ 180 escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations) 181 }) 182 for plugin in "''${plugins[@]}"; do 183 + hash="$(sha256sum "$plugin" | awk '{print $1}')" 184 mkdir -p "$hash" 185 tar -C "$hash" -xzf "$plugin" 186 autoPatchelf "$hash" 187 + GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/$hash.tar.gz" . 188 rm -rf "$hash" 189 done 190 ''; ··· 197 }; 198 199 mattermostConfWithoutPlugins = recursiveUpdate { 200 + ServiceSettings = { 201 + SiteURL = cfg.siteUrl; 202 + ListenAddress = "${cfg.host}:${toString cfg.port}"; 203 + LocalModeSocketLocation = cfg.socket.path; 204 + EnableLocalMode = cfg.socket.enable; 205 + }; 206 TeamSettings.SiteName = cfg.siteName; 207 + SqlSettings.DriverName = cfg.database.driver; 208 + SqlSettings.DataSource = 209 + if cfg.database.fromEnvironment then 210 + null 211 + else 212 + warnIf (!cfg.database.peerAuth && cfg.database.password != null) '' 213 + Database password is set in Mattermost config! This password will end up in the Nix store. 214 + 215 + You may be able to simply set the following, if the database is on the same host 216 + and peer authentication is enabled: 217 + 218 + services.mattermost.database.peerAuth = true; 219 + 220 + Note that this is the default if you set system.stateVersion to 25.05 or later 221 + and the database host is localhost. 222 + 223 + Alternatively, you can write the following to ${ 224 + if cfg.environmentFile == null then "your environment file" else cfg.environmentFile 225 + }: 226 + 227 + MM_SQLSETTINGS_DATASOURCE=${database} 228 + 229 + Then set the following options: 230 + services.mattermost.environmentFile = "<your environment file>"; 231 + services.mattermost.database.fromEnvironment = true; 232 + '' database; 233 + FileSettings.Directory = cfg.dataDir; 234 + PluginSettings.Directory = "${pluginDir}/server"; 235 + PluginSettings.ClientDirectory = "${pluginDir}/client"; 236 + LogSettings.FileLocation = cfg.logDir; 237 + } cfg.settings; 238 239 mattermostConf = recursiveUpdate mattermostConfWithoutPlugins ( 240 + if mattermostPlugins == null then 241 + { } 242 + else 243 + { 244 + PluginSettings = { 245 + Enable = true; 246 + }; 247 + } 248 ); 249 250 mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf); 251 252 in 253 { 254 + imports = [ 255 + (mkRenamedOptionModule 256 + [ 257 + "services" 258 + "mattermost" 259 + "listenAddress" 260 + ] 261 + [ 262 + "services" 263 + "mattermost" 264 + "host" 265 + ] 266 + ) 267 + (mkRenamedOptionModule 268 + [ 269 + "services" 270 + "mattermost" 271 + "localDatabaseCreate" 272 + ] 273 + [ 274 + "services" 275 + "mattermost" 276 + "database" 277 + "create" 278 + ] 279 + ) 280 + (mkRenamedOptionModule 281 + [ 282 + "services" 283 + "mattermost" 284 + "localDatabasePassword" 285 + ] 286 + [ 287 + "services" 288 + "mattermost" 289 + "database" 290 + "password" 291 + ] 292 + ) 293 + (mkRenamedOptionModule 294 + [ 295 + "services" 296 + "mattermost" 297 + "localDatabaseUser" 298 + ] 299 + [ 300 + "services" 301 + "mattermost" 302 + "database" 303 + "user" 304 + ] 305 + ) 306 + (mkRenamedOptionModule 307 + [ 308 + "services" 309 + "mattermost" 310 + "localDatabaseName" 311 + ] 312 + [ 313 + "services" 314 + "mattermost" 315 + "database" 316 + "name" 317 + ] 318 + ) 319 + (mkRenamedOptionModule 320 + [ 321 + "services" 322 + "mattermost" 323 + "extraConfig" 324 + ] 325 + [ 326 + "services" 327 + "mattermost" 328 + "settings" 329 + ] 330 + ) 331 + (mkRenamedOptionModule 332 + [ 333 + "services" 334 + "mattermost" 335 + "statePath" 336 + ] 337 + [ 338 + "services" 339 + "mattermost" 340 + "dataDir" 341 + ] 342 + ) 343 + ]; 344 + 345 options = { 346 services.mattermost = { 347 enable = mkEnableOption "Mattermost chat server"; 348 349 package = mkPackageOption pkgs "mattermost" { }; 350 351 siteUrl = mkOption { 352 type = types.str; ··· 362 description = "Name of this Mattermost site."; 363 }; 364 365 + host = mkOption { 366 type = types.str; 367 + default = "127.0.0.1"; 368 + example = "0.0.0.0"; 369 + description = '' 370 + Host or address that this Mattermost instance listens on. 371 + ''; 372 + }; 373 + 374 + port = mkOption { 375 + type = types.port; 376 + default = 8065; 377 + description = '' 378 + Port for Mattermost server to listen on. 379 + ''; 380 + }; 381 + 382 + dataDir = mkOption { 383 + type = types.path; 384 + default = "/var/lib/mattermost"; 385 + description = '' 386 + Mattermost working directory. 387 + ''; 388 + }; 389 + 390 + socket = { 391 + enable = mkEnableOption "Mattermost control socket"; 392 + 393 + path = mkOption { 394 + type = types.path; 395 + default = "${cfg.dataDir}/mattermost.sock"; 396 + defaultText = ''''${config.mattermost.dataDir}/mattermost.sock''; 397 + description = '' 398 + Default location for the Mattermost control socket used by `mmctl`. 399 + ''; 400 + }; 401 + 402 + export = mkEnableOption "Export socket control to system environment variables"; 403 + }; 404 + 405 + logDir = mkOption { 406 + type = types.path; 407 + default = 408 + if versionAtLeast config.system.stateVersion "25.05" then 409 + "/var/log/mattermost" 410 + else 411 + "${cfg.dataDir}/logs"; 412 + defaultText = '' 413 + if versionAtLeast config.system.stateVersion "25.05" then "/var/log/mattermost" 414 + else "''${config.services.mattermost.dataDir}/logs"; 415 + ''; 416 + description = '' 417 + Mattermost log directory. 418 + ''; 419 + }; 420 + 421 + configDir = mkOption { 422 + type = types.path; 423 + default = 424 + if versionAtLeast config.system.stateVersion "25.05" then 425 + "/etc/mattermost" 426 + else 427 + "${cfg.dataDir}/config"; 428 + defaultText = '' 429 + if versionAtLeast config.system.stateVersion "25.05" then 430 + "/etc/mattermost" 431 + else 432 + "''${config.services.mattermost.dataDir}/config"; 433 + ''; 434 description = '' 435 + Mattermost config directory. 436 ''; 437 }; 438 ··· 463 ''; 464 }; 465 466 plugins = mkOption { 467 + type = with types; listOf (either path package); 468 default = [ ]; 469 example = "[ ./com.github.moussetc.mattermost.plugin.giphy-2.0.0.tar.gz ]"; 470 description = '' ··· 473 .tar.gz files. 474 ''; 475 }; 476 + 477 + environment = mkOption { 478 + type = with types; attrsOf (either int str); 479 + default = { }; 480 + description = '' 481 + Extra environment variables to export to the Mattermost process, in the systemd unit. 482 + ''; 483 + example = { 484 + MM_SERVICESETTINGS_SITEURL = "http://example.com"; 485 + }; 486 + }; 487 + 488 environmentFile = mkOption { 489 + type = with types; nullOr path; 490 default = null; 491 description = '' 492 Environment file (see {manpage}`systemd.exec(5)` 493 "EnvironmentFile=" section for the syntax) which sets config options 494 + for mattermost (see [the Mattermost documentation](https://docs.mattermost.com/configure/configuration-settings.html#environment-variables)). 495 496 Settings defined in the environment file will overwrite settings 497 set via nix or via the {option}`services.mattermost.extraConfig` ··· 502 ''; 503 }; 504 505 + database = { 506 + driver = mkOption { 507 + type = types.enum [ 508 + "postgres" 509 + "mysql" 510 + ]; 511 + default = "postgres"; 512 + description = '' 513 + The database driver to use (Postgres or MySQL). 514 + ''; 515 + }; 516 517 + create = mkOption { 518 + type = types.bool; 519 + default = true; 520 + description = '' 521 + Create a local PostgreSQL or MySQL database for Mattermost automatically. 522 + ''; 523 + }; 524 525 + peerAuth = mkOption { 526 + type = types.bool; 527 + default = versionAtLeast config.system.stateVersion "25.05" && cfg.database.host == "localhost"; 528 + defaultText = '' 529 + versionAtLeast config.system.stateVersion "25.05" && config.services.mattermost.database.host == "localhost" 530 + ''; 531 + description = '' 532 + If set, will use peer auth instead of connecting to a Postgres server. 533 + Use services.mattermost.database.socketPath to configure the socket path. 534 + ''; 535 + }; 536 + 537 + socketPath = mkOption { 538 + type = types.path; 539 + default = 540 + if cfg.database.driver == "postgres" then "/run/postgresql" else "/run/mysqld/mysqld.sock"; 541 + defaultText = '' 542 + if config.services.mattermost.database.driver == "postgres" then "/run/postgresql" else "/run/mysqld/mysqld.sock"; 543 + ''; 544 + description = '' 545 + The database (Postgres or MySQL) socket path. 546 + ''; 547 + }; 548 + 549 + fromEnvironment = mkOption { 550 + type = types.bool; 551 + default = false; 552 + description = '' 553 + Use services.mattermost.environmentFile to configure the database instead of writing the database URI 554 + to the Nix store. Useful if you use password authentication with peerAuth set to false. 555 + ''; 556 + }; 557 + 558 + name = mkOption { 559 + type = types.str; 560 + default = "mattermost"; 561 + description = '' 562 + Local Mattermost database name. 563 + ''; 564 + }; 565 + 566 + host = mkOption { 567 + type = types.str; 568 + default = "localhost"; 569 + example = "127.0.0.1"; 570 + description = '' 571 + Host to use for the database. Can also be set to a path if you'd like to connect 572 + to a socket using a username and password. 573 + ''; 574 + }; 575 + 576 + port = mkOption { 577 + type = types.port; 578 + default = if cfg.database.driver == "postgres" then 5432 else 3306; 579 + defaultText = '' 580 + if config.services.mattermost.database.type == "postgres" then 5432 else 3306 581 + ''; 582 + example = 3306; 583 + description = '' 584 + Port to use for the database. 585 + ''; 586 + }; 587 + 588 + user = mkOption { 589 + type = types.str; 590 + default = "mattermost"; 591 + description = '' 592 + Local Mattermost database username. 593 + ''; 594 + }; 595 + 596 + password = mkOption { 597 + type = types.str; 598 + default = "mmpgsecret"; 599 + description = '' 600 + Password for local Mattermost database user. If set and peerAuth is not true, 601 + will cause a warning nagging you to use environmentFile instead since it will 602 + end up in the Nix store. 603 + ''; 604 + }; 605 606 + extraConnectionOptions = mkOption { 607 + type = with types; attrsOf (either int str); 608 + default = 609 + if cfg.database.driver == "postgres" then 610 + { 611 + sslmode = "disable"; 612 + connect_timeout = 30; 613 + } 614 + else if cfg.database.driver == "mysql" then 615 + { 616 + charset = "utf8mb4,utf8"; 617 + writeTimeout = "30s"; 618 + readTimeout = "30s"; 619 + } 620 + else 621 + throw "Invalid database driver ${cfg.database.driver}"; 622 + defaultText = '' 623 + if config.mattermost.database.driver == "postgres" then 624 + { 625 + sslmode = "disable"; 626 + connect_timeout = 30; 627 + } 628 + else if config.mattermost.database.driver == "mysql" then 629 + { 630 + charset = "utf8mb4,utf8"; 631 + writeTimeout = "30s"; 632 + readTimeout = "30s"; 633 + } 634 + else 635 + throw "Invalid database driver"; 636 + ''; 637 + description = '' 638 + Extra options that are placed in the connection URI's query parameters. 639 + ''; 640 + }; 641 }; 642 643 user = mkOption { ··· 656 ''; 657 }; 658 659 + settings = mkOption { 660 + type = types.attrs; 661 + default = { }; 662 + description = '' 663 + Additional configuration options as Nix attribute set in config.json schema. 664 + ''; 665 + }; 666 + 667 matterircd = { 668 enable = mkEnableOption "Mattermost IRC bridge"; 669 package = mkPackageOption pkgs "matterircd" { }; ··· 685 686 config = mkMerge [ 687 (mkIf cfg.enable { 688 + users.users = { 689 + ${cfg.user} = { 690 group = cfg.group; 691 + uid = mkIf (cfg.user == "mattermost") config.ids.uids.mattermost; 692 + home = cfg.dataDir; 693 + isSystemUser = true; 694 + packages = [ cfg.package ]; 695 }; 696 }; 697 698 + users.groups = { 699 + ${cfg.group} = { 700 + gid = mkIf (cfg.group == "mattermost") config.ids.gids.mattermost; 701 + }; 702 }; 703 704 + services.postgresql = mkIf (cfg.database.driver == "postgres" && cfg.database.create) { 705 + enable = true; 706 + ensureDatabases = singleton cfg.database.name; 707 + ensureUsers = singleton { 708 + name = 709 + throwIf 710 + (cfg.database.peerAuth && (cfg.database.user != cfg.user || cfg.database.name != cfg.database.user)) 711 + '' 712 + Mattermost database peer auth is enabled and the user, database user, or database name mismatch. 713 + Peer authentication will not work. 714 + '' 715 + cfg.database.user; 716 + ensureDBOwnership = true; 717 + }; 718 + }; 719 720 + services.mysql = mkIf (cfg.database.driver == "mysql" && cfg.database.create) { 721 + enable = true; 722 + package = mkDefault pkgs.mariadb; 723 + ensureDatabases = singleton cfg.database.name; 724 + ensureUsers = singleton { 725 + name = cfg.database.user; 726 + ensurePermissions = { 727 + "${cfg.database.name}.*" = "ALL PRIVILEGES"; 728 + }; 729 + }; 730 + settings = rec { 731 + mysqld = { 732 + collation-server = mkDefault "utf8mb4_general_ci"; 733 + init-connect = mkDefault "SET NAMES utf8mb4"; 734 + character-set-server = mkDefault "utf8mb4"; 735 + }; 736 + mysqld_safe = mysqld; 737 + }; 738 + }; 739 740 + environment = { 741 + variables = mkIf cfg.socket.export { 742 + MMCTL_LOCAL = "true"; 743 + MMCTL_LOCAL_SOCKET_PATH = cfg.socket.path; 744 + }; 745 + }; 746 + 747 + systemd.tmpfiles.rules = 748 + [ 749 + "d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} - -" 750 + "d ${cfg.logDir} 0750 ${cfg.user} ${cfg.group} - -" 751 + "d ${cfg.configDir} 0750 ${cfg.user} ${cfg.group} - -" 752 + "d ${mutableDataDir} 0750 ${cfg.user} ${cfg.group} - -" 753 + 754 + # Make sure tempDir exists and is not a symlink. 755 + "R- ${tempDir} - - - - -" 756 + "d= ${tempDir} 0750 ${cfg.user} ${cfg.group} - -" 757 + 758 + # Ensure that pluginDir is a directory, as it could be a symlink on prior versions. 759 + "r- ${pluginDir} - - - - -" 760 + "d= ${pluginDir} 0750 ${cfg.user} ${cfg.group} - -" 761 + 762 + # Ensure that the plugin directories exist. 763 + "d= ${mattermostConf.PluginSettings.Directory} 0750 ${cfg.user} ${cfg.group} - -" 764 + "d= ${mattermostConf.PluginSettings.ClientDirectory} 0750 ${cfg.user} ${cfg.group} - -" 765 + 766 + # Link in some of the immutable data directories. 767 + "L+ ${cfg.dataDir}/bin - - - - ${cfg.package}/bin" 768 + "L+ ${cfg.dataDir}/fonts - - - - ${cfg.package}/fonts" 769 + "L+ ${cfg.dataDir}/i18n - - - - ${cfg.package}/i18n" 770 + "L+ ${cfg.dataDir}/templates - - - - ${cfg.package}/templates" 771 + "L+ ${cfg.dataDir}/client - - - - ${cfg.package}/client" 772 + ] 773 + ++ ( 774 + if mattermostPlugins == null then 775 + # Create the plugin tarball directory if it's a symlink. 776 + [ 777 + "r- ${cfg.dataDir}/plugins - - - - -" 778 + "d= ${cfg.dataDir}/plugins 0750 ${cfg.user} ${cfg.group} - -" 779 + ] 780 + else 781 + # Symlink the plugin tarball directory, removing anything existing. 782 + [ "L+ ${cfg.dataDir}/plugins - - - - ${mattermostPlugins}" ] 783 + ); 784 + 785 + systemd.services.mattermost = rec { 786 description = "Mattermost chat service"; 787 wantedBy = [ "multi-user.target" ]; 788 + after = mkMerge [ 789 + [ "network.target" ] 790 + (mkIf (cfg.database.driver == "postgres" && cfg.database.create) [ "postgresql.service" ]) 791 + (mkIf (cfg.database.driver == "mysql" && cfg.database.create) [ "mysql.service" ]) 792 + ]; 793 + requires = after; 794 + 795 + environment = mkMerge [ 796 + { 797 + # Use tempDir as this can get rather large, especially if Mattermost unpacks a large number of plugins. 798 + TMPDIR = tempDir; 799 + } 800 + cfg.environment 801 ]; 802 803 preStart = 804 '' 805 + dataDir=${escapeShellArg cfg.dataDir} 806 + configDir=${escapeShellArg cfg.configDir} 807 + logDir=${escapeShellArg cfg.logDir} 808 + package=${escapeShellArg cfg.package} 809 + nixConfig=${escapeShellArg mattermostConfJSON} 810 '' 811 + + optionalString (versionAtLeast config.system.stateVersion "25.05") '' 812 + # Migrate configs in the pre-25.05 directory structure. 813 + oldConfig="$dataDir/config/config.json" 814 + newConfig="$configDir/config.json" 815 + if [ "$oldConfig" != "$newConfig" ] && [ -f "$oldConfig" ] && [ ! -f "$newConfig" ]; then 816 + # Migrate the legacy config location to the new config location 817 + echo "Moving legacy config at $oldConfig to $newConfig" >&2 818 + mkdir -p "$configDir" 819 + mv "$oldConfig" "$newConfig" 820 + touch "$configDir/.initial-created" 821 + fi 822 + 823 + # Logs too. 824 + oldLogs="$dataDir/logs" 825 + newLogs="$logDir" 826 + if [ "$oldLogs" != "$newLogs" ] && [ -d "$oldLogs" ]; then 827 + # Migrate the legacy log location to the new log location. 828 + # Allow this to fail if there aren't any logs to move. 829 + echo "Moving legacy logs at $oldLogs to $newLogs" >&2 830 + mkdir -p "$newLogs" 831 + mv "$oldLogs"/* "$newLogs" || true 832 + fi 833 '' 834 + + optionalString (!cfg.mutableConfig) '' 835 + ${getExe pkgs.jq} -s '.[0] * .[1]' "$package/config/config.json" "$nixConfig" > "$configDir/config.json" 836 '' 837 + + optionalString cfg.mutableConfig '' 838 + if [ ! -e "$configDir/.initial-created" ]; then 839 + ${getExe pkgs.jq} -s '.[0] * .[1]' "$package/config/config.json" "$nixConfig" > "$configDir/config.json" 840 + touch "$configDir/.initial-created" 841 fi 842 '' 843 + + optionalString (cfg.mutableConfig && cfg.preferNixConfig) '' 844 + echo "$(${getExe pkgs.jq} -s '.[0] * .[1]' "$configDir/config.json" "$nixConfig")" > "$configDir/config.json" 845 + ''; 846 + 847 + serviceConfig = mkMerge [ 848 + { 849 + User = cfg.user; 850 + Group = cfg.group; 851 + ExecStart = "${getExe cfg.package} --config ${cfg.configDir}/config.json"; 852 + ReadWritePaths = [ 853 + cfg.dataDir 854 + cfg.logDir 855 + cfg.configDir 856 + ]; 857 + UMask = "0027"; 858 + Restart = "always"; 859 + RestartSec = 10; 860 + LimitNOFILE = 49152; 861 + LockPersonality = true; 862 + NoNewPrivileges = true; 863 + PrivateDevices = true; 864 + PrivateTmp = true; 865 + PrivateUsers = true; 866 + ProtectClock = true; 867 + ProtectControlGroups = true; 868 + ProtectHome = true; 869 + ProtectHostname = true; 870 + ProtectKernelLogs = true; 871 + ProtectKernelModules = true; 872 + ProtectKernelTunables = true; 873 + ProtectProc = "invisible"; 874 + ProtectSystem = "strict"; 875 + RestrictNamespaces = true; 876 + RestrictSUIDSGID = true; 877 + EnvironmentFile = cfg.environmentFile; 878 + WorkingDirectory = cfg.dataDir; 879 + } 880 + (mkIf (cfg.dataDir == "/var/lib/mattermost") { 881 + StateDirectory = baseNameOf cfg.dataDir; 882 + StateDirectoryMode = "0750"; 883 + }) 884 + (mkIf (cfg.logDir == "/var/log/mattermost") { 885 + LogsDirectory = baseNameOf cfg.logDir; 886 + LogsDirectoryMode = "0750"; 887 + }) 888 + (mkIf (cfg.configDir == "/etc/mattermost") { 889 + ConfigurationDirectory = baseNameOf cfg.configDir; 890 + ConfigurationDirectoryMode = "0750"; 891 + }) 892 + ]; 893 894 + unitConfig.JoinsNamespaceOf = mkMerge [ 895 + (mkIf (cfg.database.driver == "postgres" && cfg.database.create) [ "postgresql.service" ]) 896 + (mkIf (cfg.database.driver == "mysql" && cfg.database.create) [ "mysql.service" ]) 897 + ]; 898 + }; 899 900 + assertions = [ 901 + { 902 + # Make sure the URL doesn't have a trailing slash 903 + assertion = !(hasSuffix "/" cfg.siteUrl); 904 + message = '' 905 + services.mattermost.siteUrl should not have a trailing "/". 906 + ''; 907 + } 908 + { 909 + # Make sure this isn't a host/port pair 910 + assertion = !(hasInfix ":" cfg.host && !(hasInfix "[" cfg.host) && !(hasInfix "]" cfg.host)); 911 + message = '' 912 + services.mattermost.host should not include a port. Use services.mattermost.host for the address 913 + or hostname, and services.mattermost.port to specify the port separately. 914 ''; 915 + } 916 + ]; 917 }) 918 (mkIf cfg.matterircd.enable { 919 systemd.services.matterircd = { ··· 922 serviceConfig = { 923 User = "nobody"; 924 Group = "nogroup"; 925 + ExecStart = "${getExe cfg.matterircd.package} ${escapeShellArgs cfg.matterircd.parameters}"; 926 WorkingDirectory = "/tmp"; 927 PrivateTmp = true; 928 Restart = "always"; ··· 931 }; 932 }) 933 ]; 934 + 935 + meta.maintainers = with lib.maintainers; [ numinit ]; 936 }
+1 -1
nixos/tests/all-tests.nix
··· 593 matrix-synapse-workers = handleTest ./matrix/synapse-workers.nix {}; 594 mautrix-meta-postgres = handleTest ./matrix/mautrix-meta-postgres.nix {}; 595 mautrix-meta-sqlite = handleTest ./matrix/mautrix-meta-sqlite.nix {}; 596 - mattermost = handleTest ./mattermost.nix {}; 597 mealie = handleTest ./mealie.nix {}; 598 mediamtx = handleTest ./mediamtx.nix {}; 599 mediatomb = handleTest ./mediatomb.nix {};
··· 593 matrix-synapse-workers = handleTest ./matrix/synapse-workers.nix {}; 594 mautrix-meta-postgres = handleTest ./matrix/mautrix-meta-postgres.nix {}; 595 mautrix-meta-sqlite = handleTest ./matrix/mautrix-meta-sqlite.nix {}; 596 + mattermost = handleTest ./mattermost {}; 597 mealie = handleTest ./mealie.nix {}; 598 mediamtx = handleTest ./mediamtx.nix {}; 599 mediatomb = handleTest ./mediatomb.nix {};
-169
nixos/tests/mattermost.nix
··· 1 - import ./make-test-python.nix ( 2 - { pkgs, lib, ... }: 3 - let 4 - host = "smoke.test"; 5 - port = "8065"; 6 - url = "http://${host}:${port}"; 7 - siteName = "NixOS Smoke Tests, Inc."; 8 - 9 - makeMattermost = 10 - mattermostConfig: 11 - { config, ... }: 12 - { 13 - environment.systemPackages = [ 14 - pkgs.mattermost 15 - pkgs.curl 16 - pkgs.jq 17 - ]; 18 - networking.hosts = { 19 - "127.0.0.1" = [ host ]; 20 - }; 21 - services.mattermost = lib.recursiveUpdate { 22 - enable = true; 23 - inherit siteName; 24 - listenAddress = "0.0.0.0:${port}"; 25 - siteUrl = url; 26 - extraConfig = { 27 - SupportSettings.AboutLink = "https://nixos.org"; 28 - }; 29 - } mattermostConfig; 30 - }; 31 - in 32 - { 33 - name = "mattermost"; 34 - 35 - nodes = { 36 - mutable = makeMattermost { 37 - mutableConfig = true; 38 - extraConfig.SupportSettings.HelpLink = "https://search.nixos.org"; 39 - }; 40 - mostlyMutable = makeMattermost { 41 - mutableConfig = true; 42 - preferNixConfig = true; 43 - plugins = 44 - let 45 - mattermostDemoPlugin = pkgs.fetchurl { 46 - url = "https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.9.0/com.mattermost.demo-plugin-0.9.0.tar.gz"; 47 - sha256 = "1h4qi34gcxcx63z8wiqcf2aaywmvv8lys5g8gvsk13kkqhlmag25"; 48 - }; 49 - in 50 - [ 51 - mattermostDemoPlugin 52 - ]; 53 - }; 54 - immutable = makeMattermost { 55 - package = pkgs.mattermost.overrideAttrs (prev: { 56 - webapp = prev.webapp.overrideAttrs (prevWebapp: { 57 - # Ensure that users can add patches. 58 - postPatch = 59 - prevWebapp.postPatch or "" 60 - + '' 61 - substituteInPlace channels/src/root.html --replace-fail "Mattermost" "Patched Mattermost" 62 - ''; 63 - }); 64 - }); 65 - mutableConfig = false; 66 - extraConfig.SupportSettings.HelpLink = "https://search.nixos.org"; 67 - }; 68 - environmentFile = makeMattermost { 69 - mutableConfig = false; 70 - extraConfig.SupportSettings.AboutLink = "https://example.org"; 71 - environmentFile = pkgs.writeText "mattermost-env" '' 72 - MM_SUPPORTSETTINGS_ABOUTLINK=https://nixos.org 73 - ''; 74 - }; 75 - }; 76 - 77 - testScript = 78 - let 79 - expectConfig = 80 - jqExpression: 81 - pkgs.writeShellScript "expect-config" '' 82 - set -euo pipefail 83 - echo "Expecting config to match: "${lib.escapeShellArg jqExpression} >&2 84 - curl ${lib.escapeShellArg url} >/dev/null 85 - config="$(curl ${lib.escapeShellArg "${url}/api/v4/config/client?format=old"})" 86 - echo "Config: $(echo "$config" | ${pkgs.jq}/bin/jq)" >&2 87 - [[ "$(echo "$config" | ${pkgs.jq}/bin/jq -r ${lib.escapeShellArg ".SiteName == $siteName and .Version == ($mattermostName / $sep)[-1] and (${jqExpression})"} --arg siteName ${lib.escapeShellArg siteName} --arg mattermostName ${lib.escapeShellArg pkgs.mattermost.name} --arg sep '-')" = "true" ]] 88 - ''; 89 - 90 - setConfig = 91 - jqExpression: 92 - pkgs.writeShellScript "set-config" '' 93 - set -euo pipefail 94 - mattermostConfig=/var/lib/mattermost/config/config.json 95 - newConfig="$(${pkgs.jq}/bin/jq -r ${lib.escapeShellArg jqExpression} $mattermostConfig)" 96 - rm -f $mattermostConfig 97 - echo "$newConfig" > "$mattermostConfig" 98 - ''; 99 - 100 - in 101 - '' 102 - start_all() 103 - 104 - ## Mutable node tests ## 105 - mutable.wait_for_unit("mattermost.service") 106 - mutable.wait_for_open_port(8065) 107 - mutable.succeed("curl -L http://localhost:8065/index.html | grep '${siteName}'") 108 - mutable.succeed("curl -L http://localhost:8065/index.html | grep 'Mattermost'") 109 - 110 - # Get the initial config 111 - mutable.succeed("${expectConfig ''.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"''}") 112 - 113 - # Edit the config 114 - mutable.succeed("${setConfig ''.SupportSettings.AboutLink = "https://mattermost.com"''}") 115 - mutable.succeed("${setConfig ''.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"''}") 116 - mutable.systemctl("restart mattermost.service") 117 - mutable.wait_for_open_port(8065) 118 - 119 - # AboutLink and HelpLink should be changed 120 - mutable.succeed("${expectConfig ''.AboutLink == "https://mattermost.com" and .HelpLink == "https://nixos.org/nixos/manual"''}") 121 - 122 - ## Mostly mutable node tests ## 123 - mostlyMutable.wait_for_unit("mattermost.service") 124 - mostlyMutable.wait_for_open_port(8065) 125 - mostlyMutable.succeed("curl -L http://localhost:8065/index.html | grep '${siteName}'") 126 - mostlyMutable.succeed("curl -L http://localhost:8065/index.html | grep 'Mattermost'") 127 - 128 - # Get the initial config 129 - mostlyMutable.succeed("${expectConfig ''.AboutLink == "https://nixos.org"''}") 130 - 131 - # Edit the config 132 - mostlyMutable.succeed("${setConfig ''.SupportSettings.AboutLink = "https://mattermost.com"''}") 133 - mostlyMutable.succeed("${setConfig ''.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"''}") 134 - mostlyMutable.systemctl("restart mattermost.service") 135 - mostlyMutable.wait_for_open_port(8065) 136 - 137 - # AboutLink should be overridden by NixOS configuration; HelpLink should be what we set above 138 - mostlyMutable.succeed("${expectConfig ''.AboutLink == "https://nixos.org" and .HelpLink == "https://nixos.org/nixos/manual"''}") 139 - 140 - ## Immutable node tests ## 141 - immutable.wait_for_unit("mattermost.service") 142 - immutable.wait_for_open_port(8065) 143 - # Since we patched it, it doesn't replace the site name at runtime anymore 144 - immutable.succeed("curl -L http://localhost:8065/index.html | grep 'Patched Mattermost'") 145 - 146 - # Get the initial config 147 - immutable.succeed("${expectConfig ''.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"''}") 148 - 149 - # Edit the config 150 - immutable.succeed("${setConfig ''.SupportSettings.AboutLink = "https://mattermost.com"''}") 151 - immutable.succeed("${setConfig ''.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"''}") 152 - immutable.systemctl("restart mattermost.service") 153 - immutable.wait_for_open_port(8065) 154 - 155 - # Our edits should be ignored on restart 156 - immutable.succeed("${expectConfig ''.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"''}") 157 - 158 - 159 - ## Environment File node tests ## 160 - environmentFile.wait_for_unit("mattermost.service") 161 - environmentFile.wait_for_open_port(8065) 162 - environmentFile.succeed("curl -L http://localhost:8065/index.html | grep '${siteName}'") 163 - environmentFile.succeed("curl -L http://localhost:8065/index.html | grep 'Mattermost'") 164 - 165 - # Settings in the environment file should override settings set otherwise 166 - environmentFile.succeed("${expectConfig ''.AboutLink == "https://nixos.org"''}") 167 - ''; 168 - } 169 - )
···
+537
nixos/tests/mattermost/default.nix
···
··· 1 + import ../make-test-python.nix ( 2 + { pkgs, lib, ... }: 3 + let 4 + host = "smoke.test"; 5 + port = 8065; 6 + url = "http://${host}:${toString port}"; 7 + siteName = "NixOS Smoke Tests, Inc."; 8 + 9 + makeMattermost = 10 + mattermostConfig: extraConfig: 11 + lib.mkMerge [ 12 + ( 13 + { config, ... }: 14 + { 15 + environment = { 16 + systemPackages = [ 17 + pkgs.mattermost 18 + pkgs.curl 19 + pkgs.jq 20 + ]; 21 + }; 22 + networking.hosts = { 23 + "127.0.0.1" = [ host ]; 24 + }; 25 + 26 + # Assume that Postgres won't update across stateVersion. 27 + services.postgresql = { 28 + package = lib.mkForce pkgs.postgresql; 29 + initialScript = lib.mkIf (!config.services.mattermost.database.peerAuth) ( 30 + pkgs.writeText "init.sql" '' 31 + create role ${config.services.mattermost.database.user} with login nocreatedb nocreaterole encrypted password '${config.services.mattermost.database.password}'; 32 + '' 33 + ); 34 + }; 35 + 36 + system.stateVersion = lib.mkDefault "25.05"; 37 + 38 + services.mattermost = lib.recursiveUpdate { 39 + enable = true; 40 + inherit siteName; 41 + host = "0.0.0.0"; 42 + inherit port; 43 + siteUrl = url; 44 + socket = { 45 + enable = true; 46 + export = true; 47 + }; 48 + database = { 49 + peerAuth = lib.mkDefault true; 50 + }; 51 + settings = { 52 + SupportSettings.AboutLink = "https://nixos.org"; 53 + PluginSettings.AutomaticPrepackagedPlugins = false; 54 + AnnouncementSettings = { 55 + # Disable this since it doesn't work in the sandbox and causes a timeout. 56 + AdminNoticesEnabled = false; 57 + UserNoticesEnabled = false; 58 + }; 59 + }; 60 + } mattermostConfig; 61 + 62 + # Upgrade to the latest Mattermost. 63 + specialisation.latest.configuration = { 64 + services.mattermost.package = lib.mkForce pkgs.mattermostLatest; 65 + system.stateVersion = lib.mkVMOverride "25.05"; 66 + }; 67 + } 68 + ) 69 + extraConfig 70 + ]; 71 + 72 + makeMysql = 73 + mattermostConfig: extraConfig: 74 + lib.mkMerge [ 75 + mattermostConfig 76 + ( 77 + { pkgs, config, ... }: 78 + { 79 + services.mattermost.database = { 80 + driver = lib.mkForce "mysql"; 81 + peerAuth = lib.mkForce true; 82 + }; 83 + } 84 + ) 85 + extraConfig 86 + ]; 87 + in 88 + { 89 + name = "mattermost"; 90 + 91 + nodes = rec { 92 + postgresMutable = 93 + makeMattermost 94 + { 95 + mutableConfig = true; 96 + settings.SupportSettings.HelpLink = "https://search.nixos.org"; 97 + } 98 + { 99 + # Last version to support the "old" config layout. 100 + system.stateVersion = lib.mkForce "24.11"; 101 + 102 + # First version to support the "new" config layout. 103 + specialisation.upgrade.configuration.system.stateVersion = lib.mkVMOverride "25.05"; 104 + }; 105 + postgresMostlyMutable = makeMattermost { 106 + mutableConfig = true; 107 + preferNixConfig = true; 108 + plugins = with pkgs; [ 109 + # Build the demo plugin. 110 + (mattermost.buildPlugin { 111 + pname = "mattermost-plugin-starter-template"; 112 + version = "0.1.0"; 113 + src = fetchFromGitHub { 114 + owner = "mattermost"; 115 + repo = "mattermost-plugin-starter-template"; 116 + # Newer versions have issues with their dependency lockfile. 117 + rev = "7c98e89ac1a268ce8614bc665571b7bbc9a70df2"; 118 + hash = "sha256-uyfxB0GZ45qL9ssWUord0eKQC6S0TlCTtjTOXWtK4H0="; 119 + }; 120 + vendorHash = "sha256-Jl4F9YkHNqiFP9/yeyi4vTntqxMk/J1zhEP6QLSvJQA="; 121 + npmDepsHash = "sha256-z08nc4XwT+uQjQlZiUydJyh8mqeJoYdPFWuZpw9k99s="; 122 + }) 123 + 124 + # Build the todos plugin. 125 + (mattermost.buildPlugin { 126 + pname = "mattermost-plugin-todo"; 127 + version = "0.8-pre"; 128 + src = fetchFromGitHub { 129 + owner = "mattermost-community"; 130 + repo = "mattermost-plugin-todo"; 131 + # 0.7.1 didn't work, seems to use an older set of node dependencies. 132 + rev = "f25dc91ea401c9f0dcd4abcebaff10eb8b9836e5"; 133 + hash = "sha256-OM+m4rTqVtolvL5tUE8RKfclqzoe0Y38jLU60Pz7+HI="; 134 + }; 135 + vendorHash = "sha256-5KpechSp3z/Nq713PXYruyNxveo6CwrCSKf2JaErbgg="; 136 + npmDepsHash = "sha256-o2UOEkwb8Vx2lDWayNYgng0GXvmS6lp/ExfOq3peyMY="; 137 + extraGoModuleAttrs = { 138 + npmFlags = [ "--legacy-peer-deps" ]; 139 + }; 140 + }) 141 + ]; 142 + } { }; 143 + postgresImmutable = makeMattermost { 144 + package = pkgs.mattermost.overrideAttrs (prev: { 145 + webapp = prev.webapp.overrideAttrs (prevWebapp: { 146 + # Ensure that users can add patches. 147 + postPatch = 148 + prevWebapp.postPatch or "" 149 + + '' 150 + substituteInPlace channels/src/root.html --replace-fail "Mattermost" "Patched Mattermost" 151 + ''; 152 + }); 153 + }); 154 + mutableConfig = false; 155 + 156 + # Make sure something other than the default works. 157 + user = "mmuser"; 158 + group = "mmgroup"; 159 + 160 + database = { 161 + # Ensure that this gets tested on Postgres. 162 + peerAuth = false; 163 + }; 164 + settings.SupportSettings.HelpLink = "https://search.nixos.org"; 165 + } { }; 166 + postgresEnvironmentFile = makeMattermost { 167 + mutableConfig = false; 168 + database.fromEnvironment = true; 169 + settings.SupportSettings.AboutLink = "https://example.org"; 170 + environmentFile = pkgs.writeText "mattermost-env" '' 171 + MM_SQLSETTINGS_DATASOURCE=postgres:///mattermost?host=/run/postgresql 172 + MM_SUPPORTSETTINGS_ABOUTLINK=https://nixos.org 173 + ''; 174 + } { }; 175 + 176 + mysqlMutable = makeMysql postgresMutable { }; 177 + mysqlMostlyMutable = makeMysql postgresMostlyMutable { }; 178 + mysqlImmutable = makeMysql postgresImmutable { 179 + # Let's try to use this on MySQL. 180 + services.mattermost.database = { 181 + peerAuth = lib.mkForce true; 182 + user = lib.mkForce "mmuser"; 183 + name = lib.mkForce "mmuser"; 184 + }; 185 + }; 186 + mysqlEnvironmentFile = makeMysql postgresEnvironmentFile { 187 + services.mattermost.environmentFile = lib.mkForce ( 188 + pkgs.writeText "mattermost-env" '' 189 + MM_SQLSETTINGS_DATASOURCE=mattermost@unix(/run/mysqld/mysqld.sock)/mattermost?charset=utf8mb4,utf8&writeTimeout=30s 190 + MM_SUPPORTSETTINGS_ABOUTLINK=https://nixos.org 191 + '' 192 + ); 193 + }; 194 + }; 195 + 196 + testScript = 197 + { nodes, ... }: 198 + let 199 + expectConfig = pkgs.writeShellScript "expect-config" '' 200 + set -euo pipefail 201 + config="$(curl ${lib.escapeShellArg "${url}/api/v4/config/client?format=old"})" 202 + echo "Config: $(echo "$config" | ${pkgs.jq}/bin/jq)" >&2 203 + [[ "$(echo "$config" | ${pkgs.jq}/bin/jq -r ${lib.escapeShellArg ".SiteName == $siteName and .Version == $mattermostVersion and "}"($1)" --arg siteName ${lib.escapeShellArg siteName} --arg mattermostVersion "$2" --arg sep '-')" = "true" ]] 204 + ''; 205 + 206 + setConfig = pkgs.writeShellScript "set-config" '' 207 + set -eo pipefail 208 + mattermostConfig=/etc/mattermost/config.json 209 + nixosVersion="$2" 210 + if [ -z "$nixosVersion" ]; then 211 + nixosVersion="$(nixos-version)" 212 + fi 213 + nixosVersion="$(echo "$nixosVersion" | sed -nr 's/^([0-9]{2})\.([0-9]{2}).*/\1\2/p')" 214 + echo "NixOS version: $nixosVersion" >&2 215 + if [ "$nixosVersion" -lt 2505 ]; then 216 + mattermostConfig=/var/lib/mattermost/config/config.json 217 + fi 218 + newConfig="$(${pkgs.jq}/bin/jq -r "$1" "$mattermostConfig")" 219 + echo "New config @ $mattermostConfig: $(echo "$newConfig" | ${pkgs.jq}/bin/jq)" >&2 220 + truncate -s 0 "$mattermostConfig" 221 + echo "$newConfig" >> "$mattermostConfig" 222 + ''; 223 + 224 + expectPlugins = pkgs.writeShellScript "expect-plugins" '' 225 + set -euo pipefail 226 + case "$1" in 227 + ""|*[!0-9]*) 228 + plugins="$(curl ${lib.escapeShellArg "${url}/api/v4/plugins/webapp"})" 229 + echo "Plugins: $(echo "$plugins" | ${pkgs.jq}/bin/jq)" >&2 230 + [[ "$(echo "$plugins" | ${pkgs.jq}/bin/jq -r "$1")" == "true" ]] 231 + ;; 232 + *) 233 + code="$(curl -s -o /dev/null -w "%{http_code}" ${lib.escapeShellArg "${url}/api/v4/plugins/webapp"})" 234 + [[ "$code" == "$1" ]] 235 + ;; 236 + esac 237 + ''; 238 + 239 + ensurePost = pkgs.writeShellScript "ensure-post" '' 240 + set -euo pipefail 241 + 242 + url="$1" 243 + failIfNotFound="$2" 244 + 245 + # Make sure the user exists 246 + thingExists='(type == "array" and length > 0)' 247 + userExists="($thingExists and ("'.[0].username == "nixos"))' 248 + if mmctl user list --json | jq | tee /dev/stderr | jq -e "$userExists | not"; then 249 + if [ "$failIfNotFound" -ne 0 ]; then 250 + echo "User didn't exist!" >&2 251 + exit 1 252 + else 253 + mmctl user create \ 254 + --email tests@nixos.org \ 255 + --username nixos --password nixosrules --system-admin --email-verified >&2 256 + 257 + # Make sure the user exists. 258 + while mmctl user list --json | jq | tee /dev/stderr | jq -e "$userExists | not"; do 259 + sleep 1 260 + done 261 + fi 262 + fi 263 + 264 + # Auth. 265 + mmctl auth login "$url" --name nixos --username nixos --password nixosrules 266 + 267 + # Make sure the team exists 268 + teamExists="($thingExists and ("'.[0].display_name == "NixOS Smoke Tests, Inc."))' 269 + if mmctl team list --json | jq | tee /dev/stderr | jq -e "$teamExists | not"; then 270 + if [ "$failIfNotFound" -ne 0 ]; then 271 + echo "Team didn't exist!" >&2 272 + exit 1 273 + else 274 + mmctl team create \ 275 + --name nixos \ 276 + --display-name "NixOS Smoke Tests, Inc." 277 + 278 + # Teams take a second to create. 279 + while mmctl team list --json | jq | tee /dev/stderr | jq -e "$teamExists | not"; do 280 + sleep 1 281 + done 282 + 283 + # Add the user. 284 + mmctl team users add nixos tests@nixos.org 285 + fi 286 + fi 287 + 288 + authToken="$(cat ~/.config/mmctl/config | jq -r '.nixos.authToken')" 289 + authHeader="Authorization: Bearer $authToken" 290 + acceptHeader="Accept: application/json; charset=UTF-8" 291 + 292 + # Make sure the test post exists. 293 + postContents="pls enjoy this NixOS meme I made" 294 + postAttachment=${./test.jpg} 295 + postAttachmentSize="$(stat -c%s $postAttachment)" 296 + postAttachmentHash="$(sha256sum $postAttachment | awk '{print $1}')" 297 + postAttachmentId="" 298 + postPredicate='select(.message == $message and (.file_ids | length) > 0 and (.metadata.files[0].size | tonumber) == ($size | tonumber))' 299 + postExists="($thingExists and ("'(.[] | '"$postPredicate"' | length) > 0))' 300 + if mmctl post list nixos:off-topic --json | jq | tee /dev/stderr | jq --arg message "$postContents" --arg size "$postAttachmentSize" -e "$postExists | not"; then 301 + if [ "$failIfNotFound" -ne 0 ]; then 302 + echo "Post didn't exist!" >&2 303 + exit 1 304 + else 305 + # Can't use mmcli for this seemingly. 306 + channelId="$(mmctl channel list nixos --json | jq | tee /dev/stderr | jq -r '.[] | select(.name == "off-topic") | .id')" 307 + echo "Channel ID: $channelId" >&2 308 + 309 + # Upload the file. 310 + echo "Uploading file at $postAttachment (size: $postAttachmentSize)..." >&2 311 + postAttachmentId="$(curl "$url/api/v4/files" -X POST -H "$acceptHeader" -H "$authHeader" \ 312 + -F "files=@$postAttachment" -F "channel_id=$channelId" -F "client_ids=test" | jq | tee /dev/stderr | jq -r '.file_infos[0].id')" 313 + 314 + # Create the post with it attached. 315 + postJson="$(echo '{}' | jq -c --arg channelId "$channelId" --arg message "$postContents" --arg fileId "$postAttachmentId" \ 316 + '{channel_id: $channelId, message: $message, file_ids: [$fileId]}')" 317 + echo "Creating post with contents $postJson..." >&2 318 + curl "$url/api/v4/posts" -X POST -H "$acceptHeader" -H "$authHeader" --json "$postJson" | jq >&2 319 + fi 320 + fi 321 + 322 + if mmctl post list nixos:off-topic --json | jq | tee /dev/stderr | jq --arg message "$postContents" --arg size "$postAttachmentSize" -e "$postExists"; then 323 + # Get the attachment ID. 324 + getPostAttachmentId=".[] | $postPredicate | .file_ids[0]" 325 + postAttachmentId="$(mmctl post list nixos:off-topic --json | jq | tee /dev/stderr | \ 326 + jq --arg message "$postContents" --arg size "$postAttachmentSize" -r "$getPostAttachmentId")" 327 + 328 + echo "Expected post attachment hash: $postAttachmentHash" >&2 329 + actualPostAttachmentHash="$(curl "$url/api/v4/files/$postAttachmentId?download=1" -H "$authHeader" | sha256sum | awk '{print $1}')" 330 + echo "Actual post attachment hash: $postAttachmentHash" >&2 331 + if [ "$actualPostAttachmentHash" != "$postAttachmentHash" ]; then 332 + echo "Post attachment hash mismatched!" >&2 333 + exit 1 334 + else 335 + echo "Post attachment hash was OK!" >&2 336 + exit 0 337 + fi 338 + else 339 + echo "Post didn't exist when it should have!" >&2 340 + exit 1 341 + fi 342 + ''; 343 + in 344 + '' 345 + import shlex 346 + 347 + def wait_mattermost_up(node, site_name="${siteName}"): 348 + node.systemctl("start mattermost.service") 349 + node.wait_for_unit("mattermost.service") 350 + node.wait_for_open_port(8065) 351 + node.succeed(f"curl {shlex.quote('${url}')} >/dev/null") 352 + node.succeed(f"curl {shlex.quote('${url}')}/index.html | grep {shlex.quote(site_name)}") 353 + 354 + def restart_mattermost(node, site_name="${siteName}"): 355 + node.systemctl("restart mattermost.service") 356 + wait_mattermost_up(node, site_name) 357 + 358 + def expect_config(node, mattermost_version, *configs): 359 + for config in configs: 360 + node.succeed(f"${expectConfig} {shlex.quote(config)} {shlex.quote(mattermost_version)}") 361 + 362 + def expect_plugins(node, jq_or_code): 363 + node.succeed(f"${expectPlugins} {shlex.quote(str(jq_or_code))}") 364 + 365 + def ensure_post(node, fail_if_not_found=False): 366 + node.succeed(f"${ensurePost} {shlex.quote('${url}')} {1 if fail_if_not_found else 0}") 367 + 368 + def set_config(node, *configs, nixos_version='25.05'): 369 + for config in configs: 370 + args = [shlex.quote("${setConfig}")] 371 + args.append(shlex.quote(config)) 372 + if nixos_version: 373 + args.append(shlex.quote(str(nixos_version))) 374 + node.succeed(' '.join(args)) 375 + 376 + def run_mattermost_tests(mutableToplevel: str, mutable, 377 + mostlyMutableToplevel: str, mostlyMutable, 378 + immutableToplevel: str, immutable, 379 + environmentFileToplevel: str, environmentFile): 380 + esr, latest = '${pkgs.mattermost.version}', '${pkgs.mattermostLatest.version}' 381 + 382 + ## Mutable node tests ## 383 + mutable.start() 384 + wait_mattermost_up(mutable) 385 + 386 + # Get the initial config 387 + expect_config(mutable, esr, '.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"') 388 + 389 + # Edit the config and make a post 390 + set_config( 391 + mutable, 392 + '.SupportSettings.AboutLink = "https://mattermost.com"', 393 + '.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"', 394 + nixos_version='24.11' # Default 'mutable' config is an old version 395 + ) 396 + ensure_post(mutable) 397 + restart_mattermost(mutable) 398 + 399 + # AboutLink and HelpLink should be changed, and the post should exist 400 + expect_config(mutable, esr, '.AboutLink == "https://mattermost.com" and .HelpLink == "https://nixos.org/nixos/manual"') 401 + ensure_post(mutable, fail_if_not_found=True) 402 + 403 + # Switch to the newer config 404 + mutable.succeed(f"{mutableToplevel}/specialisation/upgrade/bin/switch-to-configuration switch") 405 + wait_mattermost_up(mutable) 406 + 407 + # AboutLink and HelpLink should be changed, still, and the post should still exist 408 + expect_config(mutable, esr, '.AboutLink == "https://mattermost.com" and .HelpLink == "https://nixos.org/nixos/manual"') 409 + ensure_post(mutable, fail_if_not_found=True) 410 + 411 + # Switch to the latest Mattermost version 412 + mutable.succeed(f"{mutableToplevel}/specialisation/latest/bin/switch-to-configuration switch") 413 + wait_mattermost_up(mutable) 414 + 415 + # AboutLink and HelpLink should be changed, still, and the post should still exist 416 + expect_config(mutable, latest, '.AboutLink == "https://mattermost.com" and .HelpLink == "https://nixos.org/nixos/manual"') 417 + ensure_post(mutable, fail_if_not_found=True) 418 + 419 + mutable.shutdown() 420 + 421 + ## Mostly mutable node tests ## 422 + mostlyMutable.start() 423 + wait_mattermost_up(mostlyMutable) 424 + 425 + # Get the initial config 426 + expect_config(mostlyMutable, esr, '.AboutLink == "https://nixos.org"') 427 + 428 + # No plugins. 429 + expect_plugins(mostlyMutable, 'length == 0') 430 + 431 + # Edit the config and make a post 432 + set_config( 433 + mostlyMutable, 434 + '.SupportSettings.AboutLink = "https://mattermost.com"', 435 + '.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"', 436 + '.PluginSettings.PluginStates."com.mattermost.plugin-todo".Enable = true' 437 + ) 438 + ensure_post(mostlyMutable) 439 + restart_mattermost(mostlyMutable) 440 + 441 + # AboutLink should be overridden by NixOS configuration; HelpLink should be what we set above 442 + expect_config(mostlyMutable, esr, '.AboutLink == "https://nixos.org" and .HelpLink == "https://nixos.org/nixos/manual"') 443 + 444 + # Single plugin that's now enabled. 445 + expect_plugins(mostlyMutable, 'length == 1') 446 + 447 + # Post should exist. 448 + ensure_post(mostlyMutable, fail_if_not_found=True) 449 + 450 + # Switch to the latest Mattermost version 451 + mostlyMutable.succeed(f"{mostlyMutableToplevel}/specialisation/latest/bin/switch-to-configuration switch") 452 + wait_mattermost_up(mostlyMutable) 453 + 454 + # AboutLink should be overridden and the post should still exist 455 + expect_config(mostlyMutable, latest, '.AboutLink == "https://nixos.org" and .HelpLink == "https://nixos.org/nixos/manual"') 456 + ensure_post(mostlyMutable, fail_if_not_found=True) 457 + 458 + mostlyMutable.shutdown() 459 + 460 + ## Immutable node tests ## 461 + immutable.start() 462 + wait_mattermost_up(immutable, "Patched Mattermost") 463 + 464 + # Get the initial config 465 + expect_config(immutable, esr, '.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"') 466 + 467 + # Edit the config and make a post 468 + set_config( 469 + immutable, 470 + '.SupportSettings.AboutLink = "https://mattermost.com"', 471 + '.SupportSettings.HelpLink = "https://nixos.org/nixos/manual"' 472 + ) 473 + ensure_post(immutable) 474 + restart_mattermost(immutable, "Patched Mattermost") 475 + 476 + # Our edits should be ignored on restart 477 + expect_config(immutable, esr, '.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"') 478 + 479 + # No plugins. 480 + expect_plugins(immutable, 'length == 0') 481 + 482 + # Post should exist. 483 + ensure_post(immutable, fail_if_not_found=True) 484 + 485 + # Switch to the latest Mattermost version 486 + immutable.succeed(f"{immutableToplevel}/specialisation/latest/bin/switch-to-configuration switch") 487 + wait_mattermost_up(immutable) 488 + 489 + # AboutLink and HelpLink should be changed, still, and the post should still exist 490 + expect_config(immutable, latest, '.AboutLink == "https://nixos.org" and .HelpLink == "https://search.nixos.org"') 491 + ensure_post(immutable, fail_if_not_found=True) 492 + 493 + immutable.shutdown() 494 + 495 + ## Environment File node tests ## 496 + environmentFile.start() 497 + wait_mattermost_up(environmentFile) 498 + ensure_post(environmentFile) 499 + 500 + # Settings in the environment file should override settings set otherwise, and the post should exist 501 + expect_config(environmentFile, esr, '.AboutLink == "https://nixos.org"') 502 + ensure_post(environmentFile, fail_if_not_found=True) 503 + 504 + # Switch to the latest Mattermost version 505 + environmentFile.succeed(f"{environmentFileToplevel}/specialisation/latest/bin/switch-to-configuration switch") 506 + wait_mattermost_up(environmentFile) 507 + 508 + # AboutLink should be changed still, and the post should still exist 509 + expect_config(environmentFile, latest, '.AboutLink == "https://nixos.org"') 510 + ensure_post(environmentFile, fail_if_not_found=True) 511 + 512 + environmentFile.shutdown() 513 + 514 + run_mattermost_tests( 515 + "${nodes.mysqlMutable.system.build.toplevel}", 516 + mysqlMutable, 517 + "${nodes.mysqlMostlyMutable.system.build.toplevel}", 518 + mysqlMostlyMutable, 519 + "${nodes.mysqlImmutable.system.build.toplevel}", 520 + mysqlImmutable, 521 + "${nodes.mysqlEnvironmentFile.system.build.toplevel}", 522 + mysqlEnvironmentFile 523 + ) 524 + 525 + run_mattermost_tests( 526 + "${nodes.postgresMutable.system.build.toplevel}", 527 + postgresMutable, 528 + "${nodes.postgresMostlyMutable.system.build.toplevel}", 529 + postgresMostlyMutable, 530 + "${nodes.postgresImmutable.system.build.toplevel}", 531 + postgresImmutable, 532 + "${nodes.postgresEnvironmentFile.system.build.toplevel}", 533 + postgresEnvironmentFile 534 + ) 535 + ''; 536 + } 537 + )
nixos/tests/mattermost/test.jpg

This is a binary file and will not be displayed.