diff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix index 5461dbaf0bd0..220c571b927e 100644 --- a/nixos/modules/services/backup/borgbackup.nix +++ b/nixos/modules/services/backup/borgbackup.nix @@ -42,12 +42,16 @@ let ${cfg.postInit} fi '' + '' - borg create $extraArgs \ - --compression ${cfg.compression} \ - --exclude-from ${mkExcludeFile cfg} \ - $extraCreateArgs \ - "::$archiveName$archiveSuffix" \ - ${escapeShellArgs cfg.paths} + ( + set -o pipefail + ${optionalString (cfg.dumpCommand != null) ''${escapeShellArg cfg.dumpCommand} | \''} + borg create $extraArgs \ + --compression ${cfg.compression} \ + --exclude-from ${mkExcludeFile cfg} \ + $extraCreateArgs \ + "::$archiveName$archiveSuffix" \ + ${if cfg.paths == null then "-" else escapeShellArgs cfg.paths} + ) '' + optionalString cfg.appendFailedSuffix '' borg rename $extraArgs \ "::$archiveName$archiveSuffix" "$archiveName" @@ -182,6 +186,14 @@ let + " without at least one public key"; }; + mkSourceAssertions = name: cfg: { + assertion = count isNull [ cfg.dumpCommand cfg.paths ] == 1; + message = '' + Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand + must be set. + ''; + }; + mkRemovableDeviceAssertions = name: cfg: { assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice; message = '' @@ -240,11 +252,25 @@ in { options = { paths = mkOption { - type = with types; coercedTo str lib.singleton (listOf str); - description = "Path(s) to back up."; + type = with types; nullOr (coercedTo str lib.singleton (listOf str)); + default = null; + description = '' + Path(s) to back up. + Mutually exclusive with . + ''; example = "/home/user"; }; + dumpCommand = mkOption { + type = with types; nullOr path; + default = null; + description = '' + Backup the stdout of this program instead of filesystem paths. + Mutually exclusive with . + ''; + example = "/path/to/createZFSsend.sh"; + }; + repo = mkOption { type = types.str; description = "Remote or local repository to back up to."; @@ -657,6 +683,7 @@ in { assertions = mapAttrsToList mkPassAssertion jobs ++ mapAttrsToList mkKeysAssertion repos + ++ mapAttrsToList mkSourceAssertions jobs ++ mapAttrsToList mkRemovableDeviceAssertions jobs; system.activationScripts = mapAttrs' mkActivationScript jobs; diff --git a/nixos/tests/borgbackup.nix b/nixos/tests/borgbackup.nix index fae1d2d07138..cbb28689209b 100644 --- a/nixos/tests/borgbackup.nix +++ b/nixos/tests/borgbackup.nix @@ -81,6 +81,24 @@ in { environment.BORG_RSH = "ssh -oStrictHostKeyChecking=no -i /root/id_ed25519.appendOnly"; }; + commandSuccess = { + dumpCommand = pkgs.writeScript "commandSuccess" '' + echo -n test + ''; + repo = remoteRepo; + encryption.mode = "none"; + startAt = [ ]; + environment.BORG_RSH = "ssh -oStrictHostKeyChecking=no -i /root/id_ed25519"; + }; + + commandFail = { + dumpCommand = "${pkgs.coreutils}/bin/false"; + repo = remoteRepo; + encryption.mode = "none"; + startAt = [ ]; + environment.BORG_RSH = "ssh -oStrictHostKeyChecking=no -i /root/id_ed25519"; + }; + }; }; @@ -171,5 +189,20 @@ in { client.fail("{} list borg\@server:wrong".format(borg)) # TODO: Make sure that data is not actually deleted + + with subtest("commandSuccess"): + server.wait_for_unit("sshd.service") + client.wait_for_unit("network.target") + client.systemctl("start --wait borgbackup-job-commandSuccess") + client.fail("systemctl is-failed borgbackup-job-commandSuccess") + id = client.succeed("borg-job-commandSuccess list | tail -n1 | cut -d' ' -f1").strip() + client.succeed(f"borg-job-commandSuccess extract ::{id} stdin") + assert "test" == client.succeed("cat stdin") + + with subtest("commandFail"): + server.wait_for_unit("sshd.service") + client.wait_for_unit("network.target") + client.systemctl("start --wait borgbackup-job-commandFail") + client.succeed("systemctl is-failed borgbackup-job-commandFail") ''; })