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