grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / databases / mysql.nix
blob4b2e83e71e206b9b5317736dbb88c6f36e365d41
1 { config, lib, pkgs, ... }:
3 with lib;
5 let
7   cfg = config.services.mysql;
9   isMariaDB = lib.getName cfg.package == lib.getName pkgs.mariadb;
10   isOracle = lib.getName cfg.package == lib.getName pkgs.mysql80;
11   # Oracle MySQL has supported "notify" service type since 8.0
12   hasNotify = isMariaDB || (isOracle && versionAtLeast cfg.package.version "8.0");
14   mysqldOptions =
15     "--user=${cfg.user} --datadir=${cfg.dataDir} --basedir=${cfg.package}";
17   format = pkgs.formats.ini { listsAsDuplicateKeys = true; };
18   configFile = format.generate "my.cnf" cfg.settings;
23   imports = [
24     (mkRemovedOptionModule [ "services" "mysql" "pidDir" ] "Don't wait for pidfiles, describe dependencies through systemd.")
25     (mkRemovedOptionModule [ "services" "mysql" "rootPassword" ] "Use socket authentication or set the password outside of the nix store.")
26     (mkRemovedOptionModule [ "services" "mysql" "extraOptions" ] "Use services.mysql.settings.mysqld instead.")
27     (mkRemovedOptionModule [ "services" "mysql" "bind" ] "Use services.mysql.settings.mysqld.bind-address instead.")
28     (mkRemovedOptionModule [ "services" "mysql" "port" ] "Use services.mysql.settings.mysqld.port instead.")
29   ];
31   ###### interface
33   options = {
35     services.mysql = {
37       enable = mkEnableOption "MySQL server";
39       package = mkOption {
40         type = types.package;
41         example = literalExpression "pkgs.mariadb";
42         description = ''
43           Which MySQL derivation to use. MariaDB packages are supported too.
44         '';
45       };
47       user = mkOption {
48         type = types.str;
49         default = "mysql";
50         description = ''
51           User account under which MySQL runs.
53           ::: {.note}
54           If left as the default value this user will automatically be created
55           on system activation, otherwise you are responsible for
56           ensuring the user exists before the MySQL service starts.
57           :::
58         '';
59       };
61       group = mkOption {
62         type = types.str;
63         default = "mysql";
64         description = ''
65           Group account under which MySQL runs.
67           ::: {.note}
68           If left as the default value this group will automatically be created
69           on system activation, otherwise you are responsible for
70           ensuring the user exists before the MySQL service starts.
71           :::
72         '';
73       };
75       dataDir = mkOption {
76         type = types.path;
77         example = "/var/lib/mysql";
78         description = ''
79           The data directory for MySQL.
81           ::: {.note}
82           If left as the default value of `/var/lib/mysql` this directory will automatically be created before the MySQL
83           server starts, otherwise you are responsible for ensuring the directory exists with appropriate ownership and permissions.
84           :::
85         '';
86       };
88       configFile = mkOption {
89         type = types.path;
90         default = configFile;
91         defaultText = ''
92           A configuration file automatically generated by NixOS.
93         '';
94         description = ''
95           Override the configuration file used by MySQL. By default,
96           NixOS generates one automatically from {option}`services.mysql.settings`.
97         '';
98         example = literalExpression ''
99           pkgs.writeText "my.cnf" '''
100             [mysqld]
101             datadir = /var/lib/mysql
102             bind-address = 127.0.0.1
103             port = 3336
105             !includedir /etc/mysql/conf.d/
106           ''';
107         '';
108       };
110       settings = mkOption {
111         type = format.type;
112         default = {};
113         description = ''
114           MySQL configuration. Refer to
115           <https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html>,
116           <https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html>,
117           and <https://mariadb.com/kb/en/server-system-variables/>
118           for details on supported values.
120           ::: {.note}
121           MySQL configuration options such as `--quick` should be treated as
122           boolean options and provided values such as `true`, `false`,
123           `1`, or `0`. See the provided example below.
124           :::
125         '';
126         example = literalExpression ''
127           {
128             mysqld = {
129               key_buffer_size = "6G";
130               table_cache = 1600;
131               log-error = "/var/log/mysql_err.log";
132               plugin-load-add = [ "server_audit" "ed25519=auth_ed25519" ];
133             };
134             mysqldump = {
135               quick = true;
136               max_allowed_packet = "16M";
137             };
138           }
139         '';
140       };
142       initialDatabases = mkOption {
143         type = types.listOf (types.submodule {
144           options = {
145             name = mkOption {
146               type = types.str;
147               description = ''
148                 The name of the database to create.
149               '';
150             };
151             schema = mkOption {
152               type = types.nullOr types.path;
153               default = null;
154               description = ''
155                 The initial schema of the database; if null (the default),
156                 an empty database is created.
157               '';
158             };
159           };
160         });
161         default = [];
162         description = ''
163           List of database names and their initial schemas that should be used to create databases on the first startup
164           of MySQL. The schema attribute is optional: If not specified, an empty database is created.
165         '';
166         example = literalExpression ''
167           [
168             { name = "foodatabase"; schema = ./foodatabase.sql; }
169             { name = "bardatabase"; }
170           ]
171         '';
172       };
174       initialScript = mkOption {
175         type = types.nullOr types.path;
176         default = null;
177         description = "A file containing SQL statements to be executed on the first startup. Can be used for granting certain permissions on the database.";
178       };
180       ensureDatabases = mkOption {
181         type = types.listOf types.str;
182         default = [];
183         description = ''
184           Ensures that the specified databases exist.
185           This option will never delete existing databases, especially not when the value of this
186           option is changed. This means that databases created once through this option or
187           otherwise have to be removed manually.
188         '';
189         example = [
190           "nextcloud"
191           "matomo"
192         ];
193       };
195       ensureUsers = mkOption {
196         type = types.listOf (types.submodule {
197           options = {
198             name = mkOption {
199               type = types.str;
200               description = ''
201                 Name of the user to ensure.
202               '';
203             };
204             ensurePermissions = mkOption {
205               type = types.attrsOf types.str;
206               default = {};
207               description = ''
208                 Permissions to ensure for the user, specified as attribute set.
209                 The attribute names specify the database and tables to grant the permissions for,
210                 separated by a dot. You may use wildcards here.
211                 The attribute values specfiy the permissions to grant.
212                 You may specify one or multiple comma-separated SQL privileges here.
214                 For more information on how to specify the target
215                 and on which privileges exist, see the
216                 [GRANT syntax](https://mariadb.com/kb/en/library/grant/).
217                 The attributes are used as `GRANT ''${attrName} ON ''${attrValue}`.
218               '';
219               example = literalExpression ''
220                 {
221                   "database.*" = "ALL PRIVILEGES";
222                   "*.*" = "SELECT, LOCK TABLES";
223                 }
224               '';
225             };
226           };
227         });
228         default = [];
229         description = ''
230           Ensures that the specified users exist and have at least the ensured permissions.
231           The MySQL users will be identified using Unix socket authentication. This authenticates the Unix user with the
232           same name only, and that without the need for a password.
233           This option will never delete existing users or remove permissions, especially not when the value of this
234           option is changed. This means that users created and permissions assigned once through this option or
235           otherwise have to be removed manually.
236         '';
237         example = literalExpression ''
238           [
239             {
240               name = "nextcloud";
241               ensurePermissions = {
242                 "nextcloud.*" = "ALL PRIVILEGES";
243               };
244             }
245             {
246               name = "backup";
247               ensurePermissions = {
248                 "*.*" = "SELECT, LOCK TABLES";
249               };
250             }
251           ]
252         '';
253       };
255       replication = {
256         role = mkOption {
257           type = types.enum [ "master" "slave" "none" ];
258           default = "none";
259           description = "Role of the MySQL server instance.";
260         };
262         serverId = mkOption {
263           type = types.int;
264           default = 1;
265           description = "Id of the MySQL server instance. This number must be unique for each instance.";
266         };
268         masterHost = mkOption {
269           type = types.str;
270           description = "Hostname of the MySQL master server.";
271         };
273         slaveHost = mkOption {
274           type = types.str;
275           description = "Hostname of the MySQL slave server.";
276         };
278         masterUser = mkOption {
279           type = types.str;
280           description = "Username of the MySQL replication user.";
281         };
283         masterPassword = mkOption {
284           type = types.str;
285           description = "Password of the MySQL replication user.";
286         };
288         masterPort = mkOption {
289           type = types.port;
290           default = 3306;
291           description = "Port number on which the MySQL master server runs.";
292         };
293       };
294     };
296   };
299   ###### implementation
301   config = mkIf cfg.enable {
303     services.mysql.dataDir =
304       mkDefault (if versionAtLeast config.system.stateVersion "17.09" then "/var/lib/mysql"
305                  else "/var/mysql");
307     services.mysql.settings.mysqld = mkMerge [
308       {
309         datadir = cfg.dataDir;
310         port = mkDefault 3306;
311       }
312       (mkIf (cfg.replication.role == "master" || cfg.replication.role == "slave") {
313         log-bin = "mysql-bin-${toString cfg.replication.serverId}";
314         log-bin-index = "mysql-bin-${toString cfg.replication.serverId}.index";
315         relay-log = "mysql-relay-bin";
316         server-id = cfg.replication.serverId;
317         binlog-ignore-db = [ "information_schema" "performance_schema" "mysql" ];
318       })
319       (mkIf (!isMariaDB) {
320         plugin-load-add = "auth_socket.so";
321       })
322     ];
324     users.users = optionalAttrs (cfg.user == "mysql") {
325       mysql = {
326         description = "MySQL server user";
327         group = cfg.group;
328         uid = config.ids.uids.mysql;
329       };
330     };
332     users.groups = optionalAttrs (cfg.group == "mysql") {
333       mysql.gid = config.ids.gids.mysql;
334     };
336     environment.systemPackages = [ cfg.package ];
338     environment.etc."my.cnf".source = cfg.configFile;
340     systemd.services.mysql = {
341       description = "MySQL Server";
343       after = [ "network.target" ];
344       wantedBy = [ "multi-user.target" ];
345       restartTriggers = [ cfg.configFile ];
347       unitConfig.RequiresMountsFor = cfg.dataDir;
349       path = [
350         # Needed for the mysql_install_db command in the preStart script
351         # which calls the hostname command.
352         pkgs.nettools
353       ];
355       preStart = if isMariaDB then ''
356         if ! test -e ${cfg.dataDir}/mysql; then
357           ${cfg.package}/bin/mysql_install_db --defaults-file=/etc/my.cnf ${mysqldOptions}
358           touch ${cfg.dataDir}/mysql_init
359         fi
360       '' else ''
361         if ! test -e ${cfg.dataDir}/mysql; then
362           ${cfg.package}/bin/mysqld --defaults-file=/etc/my.cnf ${mysqldOptions} --initialize-insecure
363           touch ${cfg.dataDir}/mysql_init
364         fi
365       '';
367       script = ''
368         # https://mariadb.com/kb/en/getting-started-with-mariadb-galera-cluster/#systemd-and-galera-recovery
369         if test -n "''${_WSREP_START_POSITION}"; then
370           if test -e "${cfg.package}/bin/galera_recovery"; then
371             VAR=$(cd ${cfg.package}/bin/..; ${cfg.package}/bin/galera_recovery); [[ $? -eq 0 ]] && export _WSREP_START_POSITION=$VAR || exit 1
372           fi
373         fi
375         # The last two environment variables are used for starting Galera clusters
376         exec ${cfg.package}/bin/mysqld --defaults-file=/etc/my.cnf ${mysqldOptions} $_WSREP_NEW_CLUSTER $_WSREP_START_POSITION
377       '';
379       postStart = let
380         # The super user account to use on *first* run of MySQL server
381         superUser = if isMariaDB then cfg.user else "root";
382       in ''
383         ${optionalString (!hasNotify) ''
384           # Wait until the MySQL server is available for use
385           while [ ! -e /run/mysqld/mysqld.sock ]
386           do
387               echo "MySQL daemon not yet started. Waiting for 1 second..."
388               sleep 1
389           done
390         ''}
392         if [ -f ${cfg.dataDir}/mysql_init ]
393         then
394             # While MariaDB comes with a 'mysql' super user account since 10.4.x, MySQL does not
395             # Since we don't want to run this service as 'root' we need to ensure the account exists on first run
396             ( echo "CREATE USER IF NOT EXISTS '${cfg.user}'@'localhost' IDENTIFIED WITH ${if isMariaDB then "unix_socket" else "auth_socket"};"
397               echo "GRANT ALL PRIVILEGES ON *.* TO '${cfg.user}'@'localhost' WITH GRANT OPTION;"
398             ) | ${cfg.package}/bin/mysql -u ${superUser} -N
400             ${concatMapStrings (database: ''
401               # Create initial databases
402               if ! test -e "${cfg.dataDir}/${database.name}"; then
403                   echo "Creating initial database: ${database.name}"
404                   ( echo 'create database `${database.name}`;'
406                     ${optionalString (database.schema != null) ''
407                     echo 'use `${database.name}`;'
409                     # TODO: this silently falls through if database.schema does not exist,
410                     # we should catch this somehow and exit, but can't do it here because we're in a subshell.
411                     if [ -f "${database.schema}" ]
412                     then
413                         cat ${database.schema}
414                     elif [ -d "${database.schema}" ]
415                     then
416                         cat ${database.schema}/mysql-databases/*.sql
417                     fi
418                     ''}
419                   ) | ${cfg.package}/bin/mysql -u ${superUser} -N
420               fi
421             '') cfg.initialDatabases}
423             ${optionalString (cfg.replication.role == "master")
424               ''
425                 # Set up the replication master
427                 ( echo "use mysql;"
428                   echo "CREATE USER '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}' IDENTIFIED WITH mysql_native_password;"
429                   echo "SET PASSWORD FOR '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}' = PASSWORD('${cfg.replication.masterPassword}');"
430                   echo "GRANT REPLICATION SLAVE ON *.* TO '${cfg.replication.masterUser}'@'${cfg.replication.slaveHost}';"
431                 ) | ${cfg.package}/bin/mysql -u ${superUser} -N
432               ''}
434             ${optionalString (cfg.replication.role == "slave")
435               ''
436                 # Set up the replication slave
438                 ( echo "stop slave;"
439                   echo "change master to master_host='${cfg.replication.masterHost}', master_user='${cfg.replication.masterUser}', master_password='${cfg.replication.masterPassword}';"
440                   echo "start slave;"
441                 ) | ${cfg.package}/bin/mysql -u ${superUser} -N
442               ''}
444             ${optionalString (cfg.initialScript != null)
445               ''
446                 # Execute initial script
447                 # using toString to avoid copying the file to nix store if given as path instead of string,
448                 # as it might contain credentials
449                 cat ${toString cfg.initialScript} | ${cfg.package}/bin/mysql -u ${superUser} -N
450               ''}
452             rm ${cfg.dataDir}/mysql_init
453         fi
455         ${optionalString (cfg.ensureDatabases != []) ''
456           (
457           ${concatMapStrings (database: ''
458             echo "CREATE DATABASE IF NOT EXISTS \`${database}\`;"
459           '') cfg.ensureDatabases}
460           ) | ${cfg.package}/bin/mysql -N
461         ''}
463         ${concatMapStrings (user:
464           ''
465             ( echo "CREATE USER IF NOT EXISTS '${user.name}'@'localhost' IDENTIFIED WITH ${if isMariaDB then "unix_socket" else "auth_socket"};"
466               ${concatStringsSep "\n" (mapAttrsToList (database: permission: ''
467                 echo "GRANT ${permission} ON ${database} TO '${user.name}'@'localhost';"
468               '') user.ensurePermissions)}
469             ) | ${cfg.package}/bin/mysql -N
470           '') cfg.ensureUsers}
471       '';
473       serviceConfig = mkMerge [
474         {
475           Type = if hasNotify then "notify" else "simple";
476           Restart = "on-abort";
477           RestartSec = "5s";
479           # User and group
480           User = cfg.user;
481           Group = cfg.group;
482           # Runtime directory and mode
483           RuntimeDirectory = "mysqld";
484           RuntimeDirectoryMode = "0755";
485           # Access write directories
486           ReadWritePaths = [ cfg.dataDir ];
487           # Capabilities
488           CapabilityBoundingSet = "";
489           # Security
490           NoNewPrivileges = true;
491           # Sandboxing
492           ProtectSystem = "strict";
493           ProtectHome = true;
494           PrivateTmp = true;
495           PrivateDevices = true;
496           ProtectHostname = true;
497           ProtectKernelTunables = true;
498           ProtectKernelModules = true;
499           ProtectControlGroups = true;
500           RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
501           LockPersonality = true;
502           MemoryDenyWriteExecute = true;
503           RestrictRealtime = true;
504           RestrictSUIDSGID = true;
505           PrivateMounts = true;
506           # System Call Filtering
507           SystemCallArchitectures = "native";
508         }
509         (mkIf (cfg.dataDir == "/var/lib/mysql") {
510           StateDirectory = "mysql";
511           StateDirectoryMode = "0700";
512         })
513       ];
514     };
515   };