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