at 23.11-beta 15 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 = lib.mdDoc '' 12 Periodic backups to create with Restic. 13 ''; 14 type = types.attrsOf (types.submodule ({ config, name, ... }: { 15 options = { 16 passwordFile = mkOption { 17 type = types.str; 18 description = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 checkOpts = mkOption { 210 type = types.listOf types.str; 211 default = [ ]; 212 description = lib.mdDoc '' 213 A list of options for 'restic check', which is run after 214 pruning. 215 ''; 216 example = [ 217 "--with-cache" 218 ]; 219 }; 220 221 dynamicFilesFrom = mkOption { 222 type = with types; nullOr str; 223 default = null; 224 description = lib.mdDoc '' 225 A script that produces a list of files to back up. The 226 results of this command are given to the '--files-from' 227 option. The result is merged with paths specified via `paths`. 228 ''; 229 example = "find /home/matt/git -type d -name .git"; 230 }; 231 232 backupPrepareCommand = mkOption { 233 type = with types; nullOr str; 234 default = null; 235 description = lib.mdDoc '' 236 A script that must run before starting the backup process. 237 ''; 238 }; 239 240 backupCleanupCommand = mkOption { 241 type = with types; nullOr str; 242 default = null; 243 description = lib.mdDoc '' 244 A script that must run after finishing the backup process. 245 ''; 246 }; 247 248 package = mkOption { 249 type = types.package; 250 default = pkgs.restic; 251 defaultText = literalExpression "pkgs.restic"; 252 description = lib.mdDoc '' 253 Restic package to use. 254 ''; 255 }; 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 (resticCmd + " check " + (concatStringsSep " " backup.checkOpts)) 309 ]; 310 # Helper functions for rclone remotes 311 rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1; 312 rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v); 313 rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v); 314 toRcloneVal = v: if lib.isBool v then lib.boolToString v else v; 315 in 316 nameValuePair "restic-backups-${name}" ({ 317 environment = { 318 # not %C, because that wouldn't work in the wrapper script 319 RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}"; 320 RESTIC_PASSWORD_FILE = backup.passwordFile; 321 RESTIC_REPOSITORY = backup.repository; 322 RESTIC_REPOSITORY_FILE = backup.repositoryFile; 323 } // optionalAttrs (backup.rcloneOptions != null) (mapAttrs' 324 (name: value: 325 nameValuePair (rcloneAttrToOpt name) (toRcloneVal value) 326 ) 327 backup.rcloneOptions) // optionalAttrs (backup.rcloneConfigFile != null) { 328 RCLONE_CONFIG = backup.rcloneConfigFile; 329 } // optionalAttrs (backup.rcloneConfig != null) (mapAttrs' 330 (name: value: 331 nameValuePair (rcloneAttrToConf name) (toRcloneVal value) 332 ) 333 backup.rcloneConfig); 334 path = [ config.programs.ssh.package ]; 335 restartIfChanged = false; 336 wants = [ "network-online.target" ]; 337 after = [ "network-online.target" ]; 338 serviceConfig = { 339 Type = "oneshot"; 340 ExecStart = (optionals doBackup [ "${resticCmd} backup ${concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)} --files-from=${filesFromTmpFile}" ]) 341 ++ pruneCmd; 342 User = backup.user; 343 RuntimeDirectory = "restic-backups-${name}"; 344 CacheDirectory = "restic-backups-${name}"; 345 CacheDirectoryMode = "0700"; 346 PrivateTmp = true; 347 } // optionalAttrs (backup.environmentFile != null) { 348 EnvironmentFile = backup.environmentFile; 349 }; 350 } // optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) { 351 preStart = '' 352 ${optionalString (backup.backupPrepareCommand != null) '' 353 ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand} 354 ''} 355 ${optionalString (backup.initialize) '' 356 ${resticCmd} snapshots || ${resticCmd} init 357 ''} 358 ${optionalString (backup.paths != null && backup.paths != []) '' 359 cat ${pkgs.writeText "staticPaths" (concatStringsSep "\n" backup.paths)} >> ${filesFromTmpFile} 360 ''} 361 ${optionalString (backup.dynamicFilesFrom != null) '' 362 ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile} 363 ''} 364 ''; 365 } // optionalAttrs (doBackup || backup.backupCleanupCommand != null) { 366 postStop = '' 367 ${optionalString (backup.backupCleanupCommand != null) '' 368 ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand} 369 ''} 370 ${optionalString doBackup '' 371 rm ${filesFromTmpFile} 372 ''} 373 ''; 374 }) 375 ) 376 config.services.restic.backups; 377 systemd.timers = 378 mapAttrs' 379 (name: backup: nameValuePair "restic-backups-${name}" { 380 wantedBy = [ "timers.target" ]; 381 timerConfig = backup.timerConfig; 382 }) 383 (filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups); 384 385 # generate wrapper scripts, as described in the createWrapper option 386 environment.systemPackages = lib.mapAttrsToList (name: backup: let 387 extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; 388 resticCmd = "${backup.package}/bin/restic${extraOptions}"; 389 in pkgs.writeShellScriptBin "restic-${name}" '' 390 set -a # automatically export variables 391 ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"} 392 # set same environment variables as the systemd service 393 ${lib.pipe config.systemd.services."restic-backups-${name}".environment [ 394 (lib.filterAttrs (_: v: v != null)) 395 (lib.mapAttrsToList (n: v: "${n}=${v}")) 396 (lib.concatStringsSep "\n") 397 ]} 398 399 exec ${resticCmd} $@ 400 '') (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups); 401 }; 402}