nixos/restic: add command option (#432329)

Changed files
+88 -15
nixos
doc
manual
release-notes
modules
services
backup
tests
+2
nixos/doc/manual/release-notes/rl-2511.section.md
···
- Immich now has support for [VectorChord](https://github.com/tensorchord/VectorChord) when using the PostgreSQL configuration provided by `services.immich.database.enable`, which replaces `pgvecto-rs`. VectorChord support can be toggled with the option `services.immich.database.enableVectorChord`. Additionally, `pgvecto-rs` support is now disabled from NixOS 25.11 onwards using the option `services.immich.database.enableVectors`. This option will be removed fully in the future once Immich drops support for `pgvecto-rs` fully. See [Immich migration instructions](#module-services-immich-vectorchord-migration)
+
- `services.restic.backups` now includes a `command` option for passing a command to the [--stdin-from-command](https://github.com/restic/restic/pull/4410) flag.
+
- `services.postsrsd` now automatically integrates with the local Postfix instance, when enabled. This behavior can disabled using the [services.postsrsd.configurePostfix](#opt-services.postsrsd.configurePostfix) option.
- `services.pfix-srsd` now automatically integrates with the local Postfix instance, when enabled. This behavior can disabled using the [services.pfix-srsd.configurePostfix](#opt-services.pfix-srsd.configurePostfix) option.
+65 -14
nixos/modules/services/backup/restic.nix
···
];
};
+
command = lib.mkOption {
+
type = lib.types.listOf lib.types.str;
+
default = [ ];
+
description = ''
+
Command to pass to --stdin-from-command. If null or an empty array, and `paths`/`dynamicFilesFrom`
+
are also null, no backup command will be run.
+
'';
+
example = [
+
"sudo"
+
"-u"
+
"postgres"
+
"pg_dumpall"
+
];
+
};
+
exclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
···
runCheck = lib.mkOption {
type = lib.types.bool;
-
default = (builtins.length config.services.restic.backups.${name}.checkOpts > 0);
+
default = builtins.length config.services.restic.backups.${name}.checkOpts > 0;
defaultText = lib.literalExpression ''builtins.length config.services.backups.${name}.checkOpts > 0'';
description = "Whether to run the `check` command with the provided `checkOpts` options.";
example = true;
···
RandomizedDelaySec = "5h";
};
};
+
commandbackup = {
+
command = [
+
"\${lib.getExe pkgs.sudo}"
+
"-u postgres"
+
"\${pkgs.postgresql}/bin/pg_dumpall"
+
];
+
extraBackupArgs = [ "--tag database" ];
+
repository = "s3:example.com/mybucket";
+
passwordFile = "/etc/nixos/secrets/restic-password";
+
environmentFile = "/etc/nixos/secrets/restic-environment";
+
pruneOpts = [
+
"--keep-daily 14"
+
"--keep-weekly 4"
+
"--keep-monthly 2"
+
"--group-by tags"
+
];
+
};
};
};
config = {
-
assertions = lib.mapAttrsToList (n: v: {
-
assertion = (v.repository == null) != (v.repositoryFile == null);
-
message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set";
-
}) config.services.restic.backups;
+
assertions = lib.flatten (
+
lib.mapAttrsToList (name: backup: [
+
{
+
assertion = (backup.repository == null) != (backup.repositoryFile == null);
+
message = "services.restic.backups.${name}: exactly one of repository or repositoryFile should be set";
+
}
+
{
+
assertion =
+
let
+
fileBackup = (backup.paths != null && backup.paths != [ ]) || backup.dynamicFilesFrom != null;
+
commandBackup = backup.command != [ ];
+
in
+
!(fileBackup && commandBackup);
+
message = "services.restic.backups.${name}: cannot do both a command backup and a file backup at the same time.";
+
}
+
]) config.services.restic.backups
+
);
systemd.services = lib.mapAttrs' (
name: backup:
let
···
backup.exclude != [ ]
) "--exclude-file=${pkgs.writeText "exclude-patterns" (lib.concatStringsSep "\n" backup.exclude)}";
filesFromTmpFile = "/run/restic-backups-${name}/includes";
-
doBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]);
+
fileBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != [ ]);
+
commandBackup = backup.command != [ ];
+
doBackup = fileBackup || commandBackup;
pruneCmd = lib.optionals (builtins.length backup.pruneOpts > 0) [
(resticCmd + " unlock")
(resticCmd + " forget --prune " + (lib.concatStringsSep " " backup.pruneOpts))
···
serviceConfig = {
Type = "oneshot";
ExecStart =
-
(lib.optionals doBackup [
+
lib.optionals doBackup [
"${resticCmd} backup ${
-
lib.concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)
-
} --files-from=${filesFromTmpFile}"
-
])
+
lib.concatStringsSep " " (
+
backup.extraBackupArgs
+
++ lib.optionals fileBackup (excludeFlags ++ [ "--files-from=${filesFromTmpFile}" ])
+
++ lib.optionals commandBackup ([ "--stdin-from-command=true --" ] ++ backup.command)
+
)
+
}"
+
]
++ pruneCmd
++ checkCmd;
User = backup.user;
···
${lib.optionalString (backup.backupPrepareCommand != null) ''
${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand}
''}
-
${lib.optionalString (backup.initialize) ''
+
${lib.optionalString backup.initialize ''
${resticCmd} cat config > /dev/null || ${resticCmd} init
''}
${lib.optionalString (backup.paths != null && backup.paths != [ ]) ''
···
${lib.optionalString (backup.backupCleanupCommand != null) ''
${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand}
''}
-
${lib.optionalString doBackup ''
+
${lib.optionalString fileBackup ''
rm ${filesFromTmpFile}
''}
'';
···
name: backup:
lib.nameValuePair "restic-backups-${name}" {
wantedBy = [ "timers.target" ];
-
timerConfig = backup.timerConfig;
+
inherit (backup) timerConfig;
}
) (lib.filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups);
···
${lib.pipe config.systemd.services."restic-backups-${name}".environment [
(lib.filterAttrs (n: v: v != null && n != "PATH"))
(lib.mapAttrs (_: v: "${v}"))
-
(lib.toShellVars)
+
lib.toShellVars
]}
PATH=${config.systemd.services."restic-backups-${name}".environment.PATH}:$PATH
+21 -1
nixos/tests/restic.nix
···
{ pkgs, ... }:
-
let
inherit (import ./ssh-keys.nix pkgs)
snakeOilEd25519PrivateKey
···
remoteRepository = "/root/restic-backup";
remoteFromFileRepository = "/root/restic-backup-from-file";
+
remoteFromCommandRepository = "/root/restic-backup-from-command";
remoteInhibitTestRepository = "/root/restic-backup-inhibit-test";
remoteNoInitRepository = "/root/restic-backup-no-init";
rcloneRepository = "rclone:local:/root/restic-rclone-backup";
···
"--keep-weekly 1"
"--keep-monthly 1"
"--keep-yearly 99"
+
];
+
commandString = "testing";
+
command = [
+
"echo"
+
"-n"
+
commandString
];
in
{
···
find /opt -mindepth 1 -maxdepth 1 ! -name a_dir # all files in /opt except for a_dir
'';
};
+
remote-from-command-backup = {
+
inherit
+
passwordFile
+
pruneOpts
+
command
+
;
+
initialize = true;
+
repository = remoteFromCommandRepository;
+
};
inhibit-test = {
inherit
passwordFile
···
"mkdir /tmp/restore-3",
"${pkgs.restic}/bin/restic -r ${remoteRepository} -p ${passwordFile} restore latest -t /tmp/restore-3",
"diff -ru ${testDir} /tmp/restore-3/opt",
+
+
# test that remote-from-command-backup produces a snapshot, with the expected contents
+
"systemctl start restic-backups-remote-from-command-backup.service",
+
'restic-remote-from-command-backup snapshots --json | ${pkgs.jq}/bin/jq "length | . == 1"',
+
'[[ $(restic-remote-from-command-backup dump --path /stdin latest stdin) == ${commandString} ]]',
# test that rclonebackup produces a snapshot
"systemctl start restic-backups-rclonebackup.service",