at master 20 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 utils, 6 ... 7}: 8let 9 # Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers" 10 inherit (utils.systemdUtils.unitOptions) unitOption; 11in 12{ 13 options.services.restic.backups = lib.mkOption { 14 description = '' 15 Periodic backups to create with Restic. 16 ''; 17 type = lib.types.attrsOf ( 18 lib.types.submodule ( 19 { name, ... }: 20 { 21 options = { 22 passwordFile = lib.mkOption { 23 type = with lib.types; nullOr str; 24 default = null; 25 description = '' 26 Read the repository password from a file. 27 ''; 28 example = "/etc/nixos/restic-password"; 29 }; 30 31 environmentFile = lib.mkOption { 32 type = with lib.types; nullOr str; 33 default = null; 34 description = '' 35 file containing the credentials to access the repository, in the 36 format of an EnvironmentFile as described by {manpage}`systemd.exec(5)` 37 ''; 38 }; 39 40 rcloneOptions = lib.mkOption { 41 type = 42 with lib.types; 43 nullOr ( 44 attrsOf (oneOf [ 45 str 46 bool 47 ]) 48 ); 49 default = null; 50 description = '' 51 Options to pass to rclone to control its behavior. 52 See <https://rclone.org/docs/#options> for 53 available options. When specifying option names, strip the 54 leading `--`. To set a flag such as 55 `--drive-use-trash`, which does not take a value, 56 set the value to the Boolean `true`. 57 ''; 58 example = { 59 bwlimit = "10M"; 60 drive-use-trash = "true"; 61 }; 62 }; 63 64 rcloneConfig = lib.mkOption { 65 type = 66 with lib.types; 67 nullOr ( 68 attrsOf (oneOf [ 69 str 70 bool 71 ]) 72 ); 73 default = null; 74 description = '' 75 Configuration for the rclone remote being used for backup. 76 See the remote's specific options under rclone's docs at 77 <https://rclone.org/docs/>. When specifying 78 option names, use the "config" name specified in the docs. 79 For example, to set `--b2-hard-delete` for a B2 80 remote, use `hard_delete = true` in the 81 attribute set. 82 Warning: Secrets set in here will be world-readable in the Nix 83 store! Consider using the `rcloneConfigFile` 84 option instead to specify secret values separately. Note that 85 options set here will override those set in the config file. 86 ''; 87 example = { 88 type = "b2"; 89 account = "xxx"; 90 key = "xxx"; 91 hard_delete = true; 92 }; 93 }; 94 95 rcloneConfigFile = lib.mkOption { 96 type = with lib.types; nullOr path; 97 default = null; 98 description = '' 99 Path to the file containing rclone configuration. This file 100 must contain configuration for the remote specified in this backup 101 set and also must be readable by root. Options set in 102 `rcloneConfig` will override those set in this 103 file. 104 ''; 105 }; 106 107 inhibitsSleep = lib.mkOption { 108 default = false; 109 type = lib.types.bool; 110 example = true; 111 description = '' 112 Prevents the system from sleeping while backing up. 113 ''; 114 }; 115 116 repository = lib.mkOption { 117 type = with lib.types; nullOr str; 118 default = null; 119 description = '' 120 repository to backup to. 121 ''; 122 example = "sftp:backup@192.168.1.100:/backups/${name}"; 123 }; 124 125 repositoryFile = lib.mkOption { 126 type = with lib.types; nullOr path; 127 default = null; 128 description = '' 129 Path to the file containing the repository location to backup to. 130 ''; 131 }; 132 133 paths = lib.mkOption { 134 # This is nullable for legacy reasons only. We should consider making it a pure listOf 135 # after some time has passed since this comment was added. 136 type = lib.types.nullOr (lib.types.listOf lib.types.str); 137 default = [ ]; 138 description = '' 139 Which paths to backup, in addition to ones specified via 140 `dynamicFilesFrom`. If null or an empty array and 141 `dynamicFilesFrom` is also null, no backup command will be run. 142 This can be used to create a prune-only job. 143 ''; 144 example = [ 145 "/var/lib/postgresql" 146 "/home/user/backup" 147 ]; 148 }; 149 150 command = lib.mkOption { 151 type = lib.types.listOf lib.types.str; 152 default = [ ]; 153 description = '' 154 Command to pass to --stdin-from-command. If null or an empty array, and `paths`/`dynamicFilesFrom` 155 are also null, no backup command will be run. 156 ''; 157 example = [ 158 "sudo" 159 "-u" 160 "postgres" 161 "pg_dumpall" 162 ]; 163 }; 164 165 exclude = lib.mkOption { 166 type = lib.types.listOf lib.types.str; 167 default = [ ]; 168 description = '' 169 Patterns to exclude when backing up. See 170 https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for 171 details on syntax. 172 ''; 173 example = [ 174 "/var/cache" 175 "/home/*/.cache" 176 ".git" 177 ]; 178 }; 179 180 timerConfig = lib.mkOption { 181 type = lib.types.nullOr (lib.types.attrsOf unitOption); 182 default = { 183 OnCalendar = "daily"; 184 Persistent = true; 185 }; 186 description = '' 187 When to run the backup. See {manpage}`systemd.timer(5)` for 188 details. If null no timer is created and the backup will only 189 run when explicitly started. 190 ''; 191 example = { 192 OnCalendar = "00:05"; 193 RandomizedDelaySec = "5h"; 194 Persistent = true; 195 }; 196 }; 197 198 user = lib.mkOption { 199 type = lib.types.str; 200 default = "root"; 201 description = '' 202 As which user the backup should run. 203 ''; 204 example = "postgresql"; 205 }; 206 207 extraBackupArgs = lib.mkOption { 208 type = lib.types.listOf lib.types.str; 209 default = [ ]; 210 description = '' 211 Extra arguments passed to restic backup. 212 ''; 213 example = [ 214 "--cleanup-cache" 215 "--exclude-file=/etc/nixos/restic-ignore" 216 ]; 217 }; 218 219 extraOptions = lib.mkOption { 220 type = lib.types.listOf lib.types.str; 221 default = [ ]; 222 description = '' 223 Extra extended options to be passed to the restic --option flag. 224 ''; 225 example = [ 226 "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'" 227 ]; 228 }; 229 230 initialize = lib.mkOption { 231 type = lib.types.bool; 232 default = false; 233 description = '' 234 Create the repository if it doesn't exist. 235 ''; 236 }; 237 238 pruneOpts = lib.mkOption { 239 type = lib.types.listOf lib.types.str; 240 default = [ ]; 241 description = '' 242 A list of options (--keep-\* et al.) for 'restic forget 243 --prune', to automatically prune old snapshots. The 244 'forget' command is run *after* the 'backup' command, so 245 keep that in mind when constructing the --keep-\* options. 246 ''; 247 example = [ 248 "--keep-daily 7" 249 "--keep-weekly 5" 250 "--keep-monthly 12" 251 "--keep-yearly 75" 252 ]; 253 }; 254 255 runCheck = lib.mkOption { 256 type = lib.types.bool; 257 default = builtins.length config.services.restic.backups.${name}.checkOpts > 0; 258 defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0''; 259 description = "Whether to run the `check` command with the provided `checkOpts` options."; 260 example = true; 261 }; 262 263 checkOpts = lib.mkOption { 264 type = lib.types.listOf lib.types.str; 265 default = [ ]; 266 description = '' 267 A list of options for 'restic check'. 268 ''; 269 example = [ 270 "--with-cache" 271 ]; 272 }; 273 274 dynamicFilesFrom = lib.mkOption { 275 type = with lib.types; nullOr str; 276 default = null; 277 description = '' 278 A script that produces a list of files to back up. The 279 results of this command are given to the '--files-from' 280 option. The result is merged with paths specified via `paths`. 281 ''; 282 example = "find /home/matt/git -type d -name .git"; 283 }; 284 285 backupPrepareCommand = lib.mkOption { 286 type = with lib.types; nullOr str; 287 default = null; 288 description = '' 289 A script that must run before starting the backup process. 290 ''; 291 }; 292 293 backupCleanupCommand = lib.mkOption { 294 type = with lib.types; nullOr str; 295 default = null; 296 description = '' 297 A script that must run after finishing the backup process. 298 ''; 299 }; 300 301 package = lib.mkPackageOption pkgs "restic" { }; 302 303 createWrapper = lib.mkOption { 304 type = lib.types.bool; 305 default = true; 306 description = '' 307 Whether to generate and add a script to the system path, that has the same environment variables set 308 as the systemd service. This can be used to e.g. mount snapshots or perform other opterations, without 309 having to manually specify most options. 310 ''; 311 }; 312 313 progressFps = lib.mkOption { 314 type = with lib.types; nullOr numbers.nonnegative; 315 default = null; 316 description = '' 317 Controls the frequency of progress reporting. 318 ''; 319 example = 0.1; 320 }; 321 }; 322 } 323 ) 324 ); 325 default = { }; 326 example = { 327 localbackup = { 328 paths = [ "/home" ]; 329 exclude = [ "/home/*/.cache" ]; 330 repository = "/mnt/backup-hdd"; 331 passwordFile = "/etc/nixos/secrets/restic-password"; 332 initialize = true; 333 }; 334 remotebackup = { 335 paths = [ "/home" ]; 336 repository = "sftp:backup@host:/backups/home"; 337 passwordFile = "/etc/nixos/secrets/restic-password"; 338 extraOptions = [ 339 "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'" 340 ]; 341 timerConfig = { 342 OnCalendar = "00:05"; 343 RandomizedDelaySec = "5h"; 344 }; 345 }; 346 commandbackup = { 347 command = [ 348 "\${lib.getExe pkgs.sudo}" 349 "-u postgres" 350 "\${pkgs.postgresql}/bin/pg_dumpall" 351 ]; 352 extraBackupArgs = [ "--tag database" ]; 353 repository = "s3:example.com/mybucket"; 354 passwordFile = "/etc/nixos/secrets/restic-password"; 355 environmentFile = "/etc/nixos/secrets/restic-environment"; 356 pruneOpts = [ 357 "--keep-daily 14" 358 "--keep-weekly 4" 359 "--keep-monthly 2" 360 "--group-by tags" 361 ]; 362 }; 363 }; 364 }; 365 366 config = { 367 assertions = lib.flatten ( 368 lib.mapAttrsToList (name: backup: [ 369 { 370 assertion = 371 ((backup.repository == null) != (backup.repositoryFile == null)) 372 || (backup.environmentFile != null); 373 message = "services.restic.backups.${name}: exactly one of repository, repositoryFile or environmentFile should be set"; 374 } 375 { 376 assertion = 377 let 378 fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null; 379 commandBackup = backup.command != [ ]; 380 in 381 !(fileBackup && commandBackup); 382 message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time."; 383 } 384 { 385 assertion = (backup.passwordFile != null) || (backup.environmentFile != null); 386 message = "services.restic.backups.${name}: passwordFile or environmentFile must be set"; 387 } 388 ]) config.services.restic.backups 389 ); 390 systemd.services = lib.mapAttrs' ( 391 name: backup: 392 let 393 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; 394 inhibitCmd = lib.concatStringsSep " " [ 395 "${pkgs.systemd}/bin/systemd-inhibit" 396 "--mode='block'" 397 "--who='restic'" 398 "--what='sleep'" 399 "--why=${lib.escapeShellArg "Scheduled backup ${name}"} " 400 ]; 401 resticCmd = "${lib.optionalString backup.inhibitsSleep inhibitCmd}${lib.getExe backup.package}${extraOptions}"; 402 excludeFlags = lib.optional ( 403 backup.exclude != [ ] 404 ) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}"; 405 filesFromTmpFile = "/run/restic-backups-${name}/includes"; 406 fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]); 407 commandBackup = backup.command != [ ]; 408 doBackup = fileBackup || commandBackup; 409 pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [ 410 (resticCmd + " unlock") 411 (resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts)) 412 ]; 413 checkCmd = lib.optionals backup.runCheck [ 414 (resticCmd + " check " + (lib.concatStringsSep " " backup.checkOpts)) 415 ]; 416 # Helper functions for rclone remotes 417 rcloneRemoteName = builtins.elemAt (lib.splitString ":" backup.repository) 1; 418 rcloneAttrToOpt = v: "RCLONE_" + lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v); 419 rcloneAttrToConf = v: "RCLONE_CONFIG_" + lib.toUpper (rcloneRemoteName + "_" + v); 420 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v; 421 in 422 lib.nameValuePair "restic-backups-${name}" ( 423 { 424 environment = { 425 # not %C, because that wouldn't work in the wrapper script 426 RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}"; 427 RESTIC_PASSWORD_FILE = backup.passwordFile; 428 RESTIC_REPOSITORY = backup.repository; 429 RESTIC_REPOSITORY_FILE = backup.repositoryFile; 430 } 431 // lib.optionalAttrs (backup.rcloneOptions != null) ( 432 lib.mapAttrs' ( 433 name: value: lib.nameValuePair (rcloneAttrToOpt name) (toRcloneVal value) 434 ) backup.rcloneOptions 435 ) 436 // lib.optionalAttrs (backup.rcloneConfigFile != null) { 437 RCLONE_CONFIG = backup.rcloneConfigFile; 438 } 439 // lib.optionalAttrs (backup.rcloneConfig != null) ( 440 lib.mapAttrs' ( 441 name: value: lib.nameValuePair (rcloneAttrToConf name) (toRcloneVal value) 442 ) backup.rcloneConfig 443 ) 444 // lib.optionalAttrs (backup.progressFps != null) { 445 RESTIC_PROGRESS_FPS = toString backup.progressFps; 446 }; 447 path = [ config.programs.ssh.package ]; 448 restartIfChanged = false; 449 wants = [ "network-online.target" ]; 450 after = [ "network-online.target" ]; 451 serviceConfig = { 452 Type = "oneshot"; 453 ExecStart = 454 lib.optionals doBackup [ 455 "${resticCmd} backup ${ 456 lib.concatStringsSep " " ( 457 backup.extraBackupArgs 458 ++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ]) 459 ++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command) 460 ) 461 }" 462 ] 463 ++ pruneCmd 464 ++ checkCmd; 465 User = backup.user; 466 RuntimeDirectory = "restic-backups-${name}"; 467 CacheDirectory = "restic-backups-${name}"; 468 CacheDirectoryMode = "0700"; 469 PrivateTmp = true; 470 } 471 // lib.optionalAttrs (backup.environmentFile != null) { 472 EnvironmentFile = backup.environmentFile; 473 }; 474 } 475 // lib.optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) { 476 preStart = '' 477 ${lib.optionalString (backup.backupPrepareCommand != null) '' 478 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand} 479 ''} 480 ${lib.optionalString backup.initialize '' 481 ${resticCmd} cat config > /dev/null || ${resticCmd} init 482 ''} 483 ${lib.optionalString (backup.paths != null && backup.paths != [ ]) '' 484 cat ${pkgs.writeText "staticPaths" (lib.concatLines backup.paths)} >> ${filesFromTmpFile} 485 ''} 486 ${lib.optionalString (backup.dynamicFilesFrom != null) '' 487 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile} 488 ''} 489 ''; 490 } 491 // lib.optionalAttrs (doBackup || backup.backupCleanupCommand != null) { 492 postStop = '' 493 ${lib.optionalString (backup.backupCleanupCommand != null) '' 494 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand} 495 ''} 496 ${lib.optionalString fileBackup '' 497 rm ${filesFromTmpFile} 498 ''} 499 ''; 500 } 501 ) 502 ) config.services.restic.backups; 503 systemd.timers = lib.mapAttrs' ( 504 name: backup: 505 lib.nameValuePair "restic-backups-${name}" { 506 wantedBy = [ "timers.target" ]; 507 inherit (backup) timerConfig; 508 } 509 ) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups); 510 511 # generate wrapper scripts, as described in the createWrapper option 512 environment.systemPackages = lib.mapAttrsToList ( 513 name: backup: 514 let 515 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; 516 resticCmd = "${lib.getExe backup.package}${extraOptions}"; 517 in 518 pkgs.writeShellScriptBin "restic-${name}" '' 519 set -a # automatically export variables 520 ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"} 521 # set same environment variables as the systemd service 522 ${lib.pipe config.systemd.services."restic-backups-${name}".environment [ 523 (lib.filterAttrs (n: v: v != null && n != "PATH")) 524 (lib.mapAttrs (_: v: "${v}")) 525 lib.toShellVars 526 ]} 527 PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH 528 529 exec ${resticCmd} "$@" 530 '' 531 ) (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups); 532 }; 533}