at 23.05-pre 26 kB view raw
1{ config, lib, pkgs, ... }: 2 3with lib; 4 5let 6 7 isLocalPath = x: 8 builtins.substring 0 1 x == "/" # absolute path 9 || builtins.substring 0 1 x == "." # relative path 10 || builtins.match "[.*:.*]" == null; # not machine:path 11 12 mkExcludeFile = cfg: 13 # Write each exclude pattern to a new line 14 pkgs.writeText "excludefile" (concatStringsSep "\n" cfg.exclude); 15 16 mkKeepArgs = cfg: 17 # If cfg.prune.keep e.g. has a yearly attribute, 18 # its content is passed on as --keep-yearly 19 concatStringsSep " " 20 (mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep); 21 22 mkBackupScript = cfg: '' 23 on_exit() 24 { 25 exitStatus=$? 26 ${cfg.postHook} 27 exit $exitStatus 28 } 29 trap on_exit EXIT 30 31 archiveName="${if cfg.archiveBaseName == null then "" else cfg.archiveBaseName + "-"}$(date ${cfg.dateFormat})" 32 archiveSuffix="${optionalString cfg.appendFailedSuffix ".failed"}" 33 ${cfg.preHook} 34 '' + optionalString cfg.doInit '' 35 # Run borg init if the repo doesn't exist yet 36 if ! borg list $extraArgs > /dev/null; then 37 borg init $extraArgs \ 38 --encryption ${cfg.encryption.mode} \ 39 $extraInitArgs 40 ${cfg.postInit} 41 fi 42 '' + '' 43 ( 44 set -o pipefail 45 ${optionalString (cfg.dumpCommand != null) ''${escapeShellArg cfg.dumpCommand} | \''} 46 borg create $extraArgs \ 47 --compression ${cfg.compression} \ 48 --exclude-from ${mkExcludeFile cfg} \ 49 $extraCreateArgs \ 50 "::$archiveName$archiveSuffix" \ 51 ${if cfg.paths == null then "-" else escapeShellArgs cfg.paths} 52 ) 53 '' + optionalString cfg.appendFailedSuffix '' 54 borg rename $extraArgs \ 55 "::$archiveName$archiveSuffix" "$archiveName" 56 '' + '' 57 ${cfg.postCreate} 58 '' + optionalString (cfg.prune.keep != { }) '' 59 borg prune $extraArgs \ 60 ${mkKeepArgs cfg} \ 61 ${optionalString (cfg.prune.prefix != null) "--prefix ${escapeShellArg cfg.prune.prefix} \\"} 62 $extraPruneArgs 63 ${cfg.postPrune} 64 ''; 65 66 mkPassEnv = cfg: with cfg.encryption; 67 if passCommand != null then 68 { BORG_PASSCOMMAND = passCommand; } 69 else if passphrase != null then 70 { BORG_PASSPHRASE = passphrase; } 71 else { }; 72 73 mkBackupService = name: cfg: 74 let 75 userHome = config.users.users.${cfg.user}.home; 76 in nameValuePair "borgbackup-job-${name}" { 77 description = "BorgBackup job ${name}"; 78 path = with pkgs; [ 79 borgbackup openssh 80 ]; 81 script = mkBackupScript cfg; 82 serviceConfig = { 83 User = cfg.user; 84 Group = cfg.group; 85 # Only run when no other process is using CPU or disk 86 CPUSchedulingPolicy = "idle"; 87 IOSchedulingClass = "idle"; 88 ProtectSystem = "strict"; 89 ReadWritePaths = 90 [ "${userHome}/.config/borg" "${userHome}/.cache/borg" ] 91 ++ cfg.readWritePaths 92 # Borg needs write access to repo if it is not remote 93 ++ optional (isLocalPath cfg.repo) cfg.repo; 94 PrivateTmp = cfg.privateTmp; 95 }; 96 environment = { 97 BORG_REPO = cfg.repo; 98 inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs; 99 } // (mkPassEnv cfg) // cfg.environment; 100 }; 101 102 mkBackupTimers = name: cfg: 103 nameValuePair "borgbackup-job-${name}" { 104 description = "BorgBackup job ${name} timer"; 105 wantedBy = [ "timers.target" ]; 106 timerConfig = { 107 Persistent = cfg.persistentTimer; 108 OnCalendar = cfg.startAt; 109 }; 110 # if remote-backup wait for network 111 after = optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target"; 112 }; 113 114 # utility function around makeWrapper 115 mkWrapperDrv = { 116 original, name, set ? {} 117 }: 118 pkgs.runCommand "${name}-wrapper" { 119 nativeBuildInputs = [ pkgs.makeWrapper ]; 120 } (with lib; '' 121 makeWrapper "${original}" "$out/bin/${name}" \ 122 ${concatStringsSep " \\\n " (mapAttrsToList (name: value: ''--set ${name} "${value}"'') set)} 123 ''); 124 125 mkBorgWrapper = name: cfg: mkWrapperDrv { 126 original = "${pkgs.borgbackup}/bin/borg"; 127 name = "borg-job-${name}"; 128 set = { BORG_REPO = cfg.repo; } // (mkPassEnv cfg) // cfg.environment; 129 }; 130 131 # Paths listed in ReadWritePaths must exist before service is started 132 mkActivationScript = name: cfg: 133 let 134 install = "install -o ${cfg.user} -g ${cfg.group}"; 135 in 136 nameValuePair "borgbackup-job-${name}" (stringAfter [ "users" ] ('' 137 # Ensure that the home directory already exists 138 # We can't assert createHome == true because that's not the case for root 139 cd "${config.users.users.${cfg.user}.home}" 140 ${install} -d .config/borg 141 ${install} -d .cache/borg 142 '' + optionalString (isLocalPath cfg.repo && !cfg.removableDevice) '' 143 ${install} -d ${escapeShellArg cfg.repo} 144 '')); 145 146 mkPassAssertion = name: cfg: { 147 assertion = with cfg.encryption; 148 mode != "none" -> passCommand != null || passphrase != null; 149 message = 150 "passCommand or passphrase has to be specified because" 151 + '' borgbackup.jobs.${name}.encryption != "none"''; 152 }; 153 154 mkRepoService = name: cfg: 155 nameValuePair "borgbackup-repo-${name}" { 156 description = "Create BorgBackup repository ${name} directory"; 157 script = '' 158 mkdir -p ${escapeShellArg cfg.path} 159 chown ${cfg.user}:${cfg.group} ${escapeShellArg cfg.path} 160 ''; 161 serviceConfig = { 162 # The service's only task is to ensure that the specified path exists 163 Type = "oneshot"; 164 }; 165 wantedBy = [ "multi-user.target" ]; 166 }; 167 168 mkAuthorizedKey = cfg: appendOnly: key: 169 let 170 # Because of the following line, clients do not need to specify an absolute repo path 171 cdCommand = "cd ${escapeShellArg cfg.path}"; 172 restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} ."; 173 appendOnlyArg = optionalString appendOnly "--append-only"; 174 quotaArg = optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}"; 175 serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}"; 176 in 177 ''command="${cdCommand} && ${serveCommand}",restrict ${key}''; 178 179 mkUsersConfig = name: cfg: { 180 users.${cfg.user} = { 181 openssh.authorizedKeys.keys = 182 (map (mkAuthorizedKey cfg false) cfg.authorizedKeys 183 ++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly); 184 useDefaultShell = true; 185 group = cfg.group; 186 isSystemUser = true; 187 }; 188 groups.${cfg.group} = { }; 189 }; 190 191 mkKeysAssertion = name: cfg: { 192 assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ]; 193 message = 194 "borgbackup.repos.${name} does not make sense" 195 + " without at least one public key"; 196 }; 197 198 mkSourceAssertions = name: cfg: { 199 assertion = count isNull [ cfg.dumpCommand cfg.paths ] == 1; 200 message = '' 201 Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand 202 must be set. 203 ''; 204 }; 205 206 mkRemovableDeviceAssertions = name: cfg: { 207 assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice; 208 message = '' 209 borgbackup.repos.${name}: repo isn't a local path, thus it can't be a removable device! 210 ''; 211 }; 212 213in { 214 meta.maintainers = with maintainers; [ dotlambda ]; 215 meta.doc = ./borgbackup.xml; 216 217 ###### interface 218 219 options.services.borgbackup.jobs = mkOption { 220 description = lib.mdDoc '' 221 Deduplicating backups using BorgBackup. 222 Adding a job will cause a borg-job-NAME wrapper to be added 223 to your system path, so that you can perform maintenance easily. 224 See also the chapter about BorgBackup in the NixOS manual. 225 ''; 226 default = { }; 227 example = literalExpression '' 228 { # for a local backup 229 rootBackup = { 230 paths = "/"; 231 exclude = [ "/nix" ]; 232 repo = "/path/to/local/repo"; 233 encryption = { 234 mode = "repokey"; 235 passphrase = "secret"; 236 }; 237 compression = "auto,lzma"; 238 startAt = "weekly"; 239 }; 240 } 241 { # Root backing each day up to a remote backup server. We assume that you have 242 # * created a password less key: ssh-keygen -N "" -t ed25519 -f /path/to/ssh_key 243 # best practices are: use -t ed25519, /path/to = /run/keys 244 # * the passphrase is in the file /run/keys/borgbackup_passphrase 245 # * you have initialized the repository manually 246 paths = [ "/etc" "/home" ]; 247 exclude = [ "/nix" "'**/.cache'" ]; 248 doInit = false; 249 repo = "user3@arep.repo.borgbase.com:repo"; 250 encryption = { 251 mode = "repokey-blake2"; 252 passCommand = "cat /path/to/passphrase"; 253 }; 254 environment = { BORG_RSH = "ssh -i /path/to/ssh_key"; }; 255 compression = "auto,lzma"; 256 startAt = "daily"; 257 }; 258 ''; 259 type = types.attrsOf (types.submodule (let globalConfig = config; in 260 { name, config, ... }: { 261 options = { 262 263 paths = mkOption { 264 type = with types; nullOr (coercedTo str lib.singleton (listOf str)); 265 default = null; 266 description = lib.mdDoc '' 267 Path(s) to back up. 268 Mutually exclusive with {option}`dumpCommand`. 269 ''; 270 example = "/home/user"; 271 }; 272 273 dumpCommand = mkOption { 274 type = with types; nullOr path; 275 default = null; 276 description = lib.mdDoc '' 277 Backup the stdout of this program instead of filesystem paths. 278 Mutually exclusive with {option}`paths`. 279 ''; 280 example = "/path/to/createZFSsend.sh"; 281 }; 282 283 repo = mkOption { 284 type = types.str; 285 description = lib.mdDoc "Remote or local repository to back up to."; 286 example = "user@machine:/path/to/repo"; 287 }; 288 289 removableDevice = mkOption { 290 type = types.bool; 291 default = false; 292 description = lib.mdDoc "Whether the repo (which must be local) is a removable device."; 293 }; 294 295 archiveBaseName = mkOption { 296 type = types.nullOr (types.strMatching "[^/{}]+"); 297 default = "${globalConfig.networking.hostName}-${name}"; 298 defaultText = literalExpression ''"''${config.networking.hostName}-<name>"''; 299 description = lib.mdDoc '' 300 How to name the created archives. A timestamp, whose format is 301 determined by {option}`dateFormat`, will be appended. The full 302 name can be modified at runtime (`$archiveName`). 303 Placeholders like `{hostname}` must not be used. 304 Use `null` for no base name. 305 ''; 306 }; 307 308 dateFormat = mkOption { 309 type = types.str; 310 description = lib.mdDoc '' 311 Arguments passed to {command}`date` 312 to create a timestamp suffix for the archive name. 313 ''; 314 default = "+%Y-%m-%dT%H:%M:%S"; 315 example = "-u +%s"; 316 }; 317 318 startAt = mkOption { 319 type = with types; either str (listOf str); 320 default = "daily"; 321 description = lib.mdDoc '' 322 When or how often the backup should run. 323 Must be in the format described in 324 {manpage}`systemd.time(7)`. 325 If you do not want the backup to start 326 automatically, use `[ ]`. 327 It will generate a systemd service borgbackup-job-NAME. 328 You may trigger it manually via systemctl restart borgbackup-job-NAME. 329 ''; 330 }; 331 332 persistentTimer = mkOption { 333 default = false; 334 type = types.bool; 335 example = true; 336 description = lib.mdDoc '' 337 Set the `persistentTimer` option for the 338 {manpage}`systemd.timer(5)` 339 which triggers the backup immediately if the last trigger 340 was missed (e.g. if the system was powered down). 341 ''; 342 }; 343 344 user = mkOption { 345 type = types.str; 346 description = lib.mdDoc '' 347 The user {command}`borg` is run as. 348 User or group need read permission 349 for the specified {option}`paths`. 350 ''; 351 default = "root"; 352 }; 353 354 group = mkOption { 355 type = types.str; 356 description = lib.mdDoc '' 357 The group borg is run as. User or group needs read permission 358 for the specified {option}`paths`. 359 ''; 360 default = "root"; 361 }; 362 363 encryption.mode = mkOption { 364 type = types.enum [ 365 "repokey" "keyfile" 366 "repokey-blake2" "keyfile-blake2" 367 "authenticated" "authenticated-blake2" 368 "none" 369 ]; 370 description = lib.mdDoc '' 371 Encryption mode to use. Setting a mode 372 other than `"none"` requires 373 you to specify a {option}`passCommand` 374 or a {option}`passphrase`. 375 ''; 376 example = "repokey-blake2"; 377 }; 378 379 encryption.passCommand = mkOption { 380 type = with types; nullOr str; 381 description = lib.mdDoc '' 382 A command which prints the passphrase to stdout. 383 Mutually exclusive with {option}`passphrase`. 384 ''; 385 default = null; 386 example = "cat /path/to/passphrase_file"; 387 }; 388 389 encryption.passphrase = mkOption { 390 type = with types; nullOr str; 391 description = lib.mdDoc '' 392 The passphrase the backups are encrypted with. 393 Mutually exclusive with {option}`passCommand`. 394 If you do not want the passphrase to be stored in the 395 world-readable Nix store, use {option}`passCommand`. 396 ''; 397 default = null; 398 }; 399 400 compression = mkOption { 401 # "auto" is optional, 402 # compression mode must be given, 403 # compression level is optional 404 type = types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?"; 405 description = lib.mdDoc '' 406 Compression method to use. Refer to 407 {command}`borg help compression` 408 for all available options. 409 ''; 410 default = "lz4"; 411 example = "auto,lzma"; 412 }; 413 414 exclude = mkOption { 415 type = with types; listOf str; 416 description = lib.mdDoc '' 417 Exclude paths matching any of the given patterns. See 418 {command}`borg help patterns` for pattern syntax. 419 ''; 420 default = [ ]; 421 example = [ 422 "/home/*/.cache" 423 "/nix" 424 ]; 425 }; 426 427 readWritePaths = mkOption { 428 type = with types; listOf path; 429 description = lib.mdDoc '' 430 By default, borg cannot write anywhere on the system but 431 `$HOME/.config/borg` and `$HOME/.cache/borg`. 432 If, for example, your preHook script needs to dump files 433 somewhere, put those directories here. 434 ''; 435 default = [ ]; 436 example = [ 437 "/var/backup/mysqldump" 438 ]; 439 }; 440 441 privateTmp = mkOption { 442 type = types.bool; 443 description = lib.mdDoc '' 444 Set the `PrivateTmp` option for 445 the systemd-service. Set to false if you need sockets 446 or other files from global /tmp. 447 ''; 448 default = true; 449 }; 450 451 doInit = mkOption { 452 type = types.bool; 453 description = lib.mdDoc '' 454 Run {command}`borg init` if the 455 specified {option}`repo` does not exist. 456 You should set this to `false` 457 if the repository is located on an external drive 458 that might not always be mounted. 459 ''; 460 default = true; 461 }; 462 463 appendFailedSuffix = mkOption { 464 type = types.bool; 465 description = lib.mdDoc '' 466 Append a `.failed` suffix 467 to the archive name, which is only removed if 468 {command}`borg create` has a zero exit status. 469 ''; 470 default = true; 471 }; 472 473 prune.keep = mkOption { 474 # Specifying e.g. `prune.keep.yearly = -1` 475 # means there is no limit of yearly archives to keep 476 # The regex is for use with e.g. --keep-within 1y 477 type = with types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]")); 478 description = lib.mdDoc '' 479 Prune a repository by deleting all archives not matching any of the 480 specified retention options. See {command}`borg help prune` 481 for the available options. 482 ''; 483 default = { }; 484 example = literalExpression '' 485 { 486 within = "1d"; # Keep all archives from the last day 487 daily = 7; 488 weekly = 4; 489 monthly = -1; # Keep at least one archive for each month 490 } 491 ''; 492 }; 493 494 prune.prefix = mkOption { 495 type = types.nullOr (types.str); 496 description = lib.mdDoc '' 497 Only consider archive names starting with this prefix for pruning. 498 By default, only archives created by this job are considered. 499 Use `""` or `null` to consider all archives. 500 ''; 501 default = config.archiveBaseName; 502 defaultText = literalExpression "archiveBaseName"; 503 }; 504 505 environment = mkOption { 506 type = with types; attrsOf str; 507 description = lib.mdDoc '' 508 Environment variables passed to the backup script. 509 You can for example specify which SSH key to use. 510 ''; 511 default = { }; 512 example = { BORG_RSH = "ssh -i /path/to/key"; }; 513 }; 514 515 preHook = mkOption { 516 type = types.lines; 517 description = lib.mdDoc '' 518 Shell commands to run before the backup. 519 This can for example be used to mount file systems. 520 ''; 521 default = ""; 522 example = '' 523 # To add excluded paths at runtime 524 extraCreateArgs="$extraCreateArgs --exclude /some/path" 525 ''; 526 }; 527 528 postInit = mkOption { 529 type = types.lines; 530 description = lib.mdDoc '' 531 Shell commands to run after {command}`borg init`. 532 ''; 533 default = ""; 534 }; 535 536 postCreate = mkOption { 537 type = types.lines; 538 description = lib.mdDoc '' 539 Shell commands to run after {command}`borg create`. The name 540 of the created archive is stored in `$archiveName`. 541 ''; 542 default = ""; 543 }; 544 545 postPrune = mkOption { 546 type = types.lines; 547 description = lib.mdDoc '' 548 Shell commands to run after {command}`borg prune`. 549 ''; 550 default = ""; 551 }; 552 553 postHook = mkOption { 554 type = types.lines; 555 description = lib.mdDoc '' 556 Shell commands to run just before exit. They are executed 557 even if a previous command exits with a non-zero exit code. 558 The latter is available as `$exitStatus`. 559 ''; 560 default = ""; 561 }; 562 563 extraArgs = mkOption { 564 type = types.str; 565 description = lib.mdDoc '' 566 Additional arguments for all {command}`borg` calls the 567 service has. Handle with care. 568 ''; 569 default = ""; 570 example = "--remote-path=/path/to/borg"; 571 }; 572 573 extraInitArgs = mkOption { 574 type = types.str; 575 description = lib.mdDoc '' 576 Additional arguments for {command}`borg init`. 577 Can also be set at runtime using `$extraInitArgs`. 578 ''; 579 default = ""; 580 example = "--append-only"; 581 }; 582 583 extraCreateArgs = mkOption { 584 type = types.str; 585 description = lib.mdDoc '' 586 Additional arguments for {command}`borg create`. 587 Can also be set at runtime using `$extraCreateArgs`. 588 ''; 589 default = ""; 590 example = "--stats --checkpoint-interval 600"; 591 }; 592 593 extraPruneArgs = mkOption { 594 type = types.str; 595 description = lib.mdDoc '' 596 Additional arguments for {command}`borg prune`. 597 Can also be set at runtime using `$extraPruneArgs`. 598 ''; 599 default = ""; 600 example = "--save-space"; 601 }; 602 603 }; 604 } 605 )); 606 }; 607 608 options.services.borgbackup.repos = mkOption { 609 description = lib.mdDoc '' 610 Serve BorgBackup repositories to given public SSH keys, 611 restricting their access to the repository only. 612 See also the chapter about BorgBackup in the NixOS manual. 613 Also, clients do not need to specify the absolute path when accessing the repository, 614 i.e. `user@machine:.` is enough. (Note colon and dot.) 615 ''; 616 default = { }; 617 type = types.attrsOf (types.submodule ( 618 { ... }: { 619 options = { 620 path = mkOption { 621 type = types.path; 622 description = lib.mdDoc '' 623 Where to store the backups. Note that the directory 624 is created automatically, with correct permissions. 625 ''; 626 default = "/var/lib/borgbackup"; 627 }; 628 629 user = mkOption { 630 type = types.str; 631 description = lib.mdDoc '' 632 The user {command}`borg serve` is run as. 633 User or group needs write permission 634 for the specified {option}`path`. 635 ''; 636 default = "borg"; 637 }; 638 639 group = mkOption { 640 type = types.str; 641 description = lib.mdDoc '' 642 The group {command}`borg serve` is run as. 643 User or group needs write permission 644 for the specified {option}`path`. 645 ''; 646 default = "borg"; 647 }; 648 649 authorizedKeys = mkOption { 650 type = with types; listOf str; 651 description = lib.mdDoc '' 652 Public SSH keys that are given full write access to this repository. 653 You should use a different SSH key for each repository you write to, because 654 the specified keys are restricted to running {command}`borg serve` 655 and can only access this single repository. 656 ''; 657 default = [ ]; 658 }; 659 660 authorizedKeysAppendOnly = mkOption { 661 type = with types; listOf str; 662 description = lib.mdDoc '' 663 Public SSH keys that can only be used to append new data (archives) to the repository. 664 Note that archives can still be marked as deleted and are subsequently removed from disk 665 upon accessing the repo with full write access, e.g. when pruning. 666 ''; 667 default = [ ]; 668 }; 669 670 allowSubRepos = mkOption { 671 type = types.bool; 672 description = lib.mdDoc '' 673 Allow clients to create repositories in subdirectories of the 674 specified {option}`path`. These can be accessed using 675 `user@machine:path/to/subrepo`. Note that a 676 {option}`quota` applies to repositories independently. 677 Therefore, if this is enabled, clients can create multiple 678 repositories and upload an arbitrary amount of data. 679 ''; 680 default = false; 681 }; 682 683 quota = mkOption { 684 # See the definition of parse_file_size() in src/borg/helpers/parseformat.py 685 type = with types; nullOr (strMatching "[[:digit:].]+[KMGTP]?"); 686 description = lib.mdDoc '' 687 Storage quota for the repository. This quota is ensured for all 688 sub-repositories if {option}`allowSubRepos` is enabled 689 but not for the overall storage space used. 690 ''; 691 default = null; 692 example = "100G"; 693 }; 694 695 }; 696 } 697 )); 698 }; 699 700 ###### implementation 701 702 config = mkIf (with config.services.borgbackup; jobs != { } || repos != { }) 703 (with config.services.borgbackup; { 704 assertions = 705 mapAttrsToList mkPassAssertion jobs 706 ++ mapAttrsToList mkKeysAssertion repos 707 ++ mapAttrsToList mkSourceAssertions jobs 708 ++ mapAttrsToList mkRemovableDeviceAssertions jobs; 709 710 system.activationScripts = mapAttrs' mkActivationScript jobs; 711 712 systemd.services = 713 # A job named "foo" is mapped to systemd.services.borgbackup-job-foo 714 mapAttrs' mkBackupService jobs 715 # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo 716 // mapAttrs' mkRepoService repos; 717 718 # A job named "foo" is mapped to systemd.timers.borgbackup-job-foo 719 # only generate the timer if interval (startAt) is set 720 systemd.timers = mapAttrs' mkBackupTimers (filterAttrs (_: cfg: cfg.startAt != []) jobs); 721 722 users = mkMerge (mapAttrsToList mkUsersConfig repos); 723 724 environment.systemPackages = with pkgs; [ borgbackup ] ++ (mapAttrsToList mkBorgWrapper jobs); 725 }); 726}