selfhostblocks/modules/blocks/borgbackup.nix
2026-02-02 08:14:21 +01:00

541 lines
18 KiB
Nix

{
config,
pkgs,
lib,
utils,
shb,
...
}:
let
cfg = config.shb.borgbackup;
inherit (lib)
concatStringsSep
filterAttrs
flatten
literalExpression
optionals
listToAttrs
mapAttrsToList
mkOption
mkMerge
;
inherit (lib)
mkIf
nameValuePair
optionalAttrs
removePrefix
;
inherit (lib.types)
attrsOf
int
oneOf
nonEmptyStr
nullOr
str
submodule
;
commonOptions =
{
name,
prefix,
config,
...
}:
{
enable = lib.mkEnableOption ''
SelfHostBlocks' BorgBackup block;
A disabled instance will not backup data anymore
but still provides the helper tool to restore snapshots
'';
passphrase = lib.mkOption {
description = "Encryption key for the backup repository.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.request.user;
ownerText = "[shb.borgbackup.${prefix}.<name>.request.user](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.request.user)";
restartUnits = [ (fullName name config.settings.repository) ];
restartUnitsText = "[ [shb.borgbackup.${prefix}.<name>.settings.repository](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.settings.repository) ]";
};
};
};
repository = lib.mkOption {
description = "Repository to send the backups to.";
type = submodule {
options = {
path = mkOption {
type = str;
description = "Repository location";
};
secrets = mkOption {
type = attrsOf shb.secretFileType;
default = { };
description = ''
Secrets needed to access the repository where the backups will be stored.
See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example
and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets.
'';
example = literalExpression ''
{
AWS_ACCESS_KEY_ID.source = <path/to/secret>;
AWS_SECRET_ACCESS_KEY.source = <path/to/secret>;
}
'';
};
timerConfig = mkOption {
type = attrsOf utils.systemdUtils.unitOptions.unitOption;
default = {
OnCalendar = "daily";
Persistent = true;
};
description = "When to run the backup. See {manpage}`systemd.timer(5)` for details.";
example = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
Persistent = true;
};
};
};
};
};
retention = lib.mkOption {
description = "Retention options. See {command}`borg help prune` for the available options.";
type = attrsOf (oneOf [
int
nonEmptyStr
]);
default = {
within = "1d";
hourly = 24;
daily = 7;
weekly = 4;
monthly = 6;
};
};
consistency = lib.mkOption {
description = "Consistency frequency options.";
type = lib.types.attrsOf lib.types.nonEmptyStr;
default = { };
example = {
repository = "2 weeks";
archives = "1 month";
};
};
limitUploadKiBs = mkOption {
type = nullOr int;
description = "Limit upload bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
stateDir = mkOption {
type = nullOr lib.types.str;
description = ''
Override the directory in which {command}`borg` stores its
configuration and cache. By default it uses the user's
home directory but is some cases this can cause conflicts.
'';
default = null;
};
};
repoSlugName = name: builtins.replaceStrings [ "/" ":" ] [ "_" "_" ] (removePrefix "/" name);
fullName = name: repository: "borgbackup-job-${name}_${repoSlugName repository.path}";
in
{
imports = [
../../lib/module.nix
../blocks/monitoring.nix
];
options.shb.borgbackup = {
enableDashboard = lib.mkEnableOption "the Backups SHB dashboard" // {
default = true;
};
instances = mkOption {
description = "Files to backup following the [backup contract](./shb.contracts-backup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = shb.contracts.backup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the BorgBackup provider.
'';
type = submodule {
options = commonOptions {
inherit name config;
prefix = "instances";
};
};
};
resultCfg = {
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
};
}
)
);
};
databases = mkOption {
description = "Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = shb.contracts.databasebackup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the BorgBackup provider.
'';
type = submodule {
options = commonOptions {
inherit name config;
prefix = "databases";
};
};
};
resultCfg = {
restoreScript = fullName name config.settings.repository;
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupService = "${fullName name config.settings.repository}.service";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
};
}
)
);
};
borgServer = lib.mkOption {
description = "Add borgbackup package to `environment.systemPackages` so external backups can use this server as a remote.";
default = false;
example = true;
type = lib.types.bool;
};
# Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23
performance = lib.mkOption {
description = "Reduce performance impact of backup jobs.";
default = { };
type = lib.types.submodule {
options = {
niceness = lib.mkOption {
type = lib.types.ints.between (-20) 19;
description = "nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process";
default = 15;
};
ioSchedulingClass = lib.mkOption {
type = lib.types.enum [
"idle"
"best-effort"
"realtime"
];
description = "ionice scheduling class, defaults to best-effort IO.";
default = "best-effort";
};
ioPriority = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 0 7);
description = "ionice priority, defaults to 7 for lowest priority IO.";
default = 7;
};
};
};
};
};
config = lib.mkIf (cfg.instances != { } || cfg.databases != { }) (
let
enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances;
enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases;
in
lib.mkMerge [
{
environment.systemPackages =
optionals (cfg.borgServer || enabledInstances != { } || enabledDatabases != { })
[
pkgs.borgbackup
];
}
{
services.borgbackup.jobs =
let
mkJob = name: instance: {
"${name}_${repoSlugName instance.settings.repository.path}" = {
inherit (instance.request) user;
repo = instance.settings.repository.path;
paths = instance.request.sourceDirectories;
encryption.mode = "repokey-blake2";
# We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down.
encryption.passCommand = "cat ${instance.settings.passphrase.result.path}";
doInit = true;
failOnWarnings = true;
stateDir = instance.settings.stateDir;
persistentTimer = instance.settings.repository.timerConfig.Persistent or false;
startAt = ""; # Some non-empty string value tricks the upstream module in creating the systemd timer.
prune.keep = instance.settings.retention;
preHook = concatStringsSep "\n" instance.request.hooks.beforeBackup;
postHook = concatStringsSep "\n" instance.request.hooks.afterBackup;
extraArgs = (
optionals (instance.settings.limitUploadKiBs != null) [
"--upload-ratelimit=${toString instance.settings.limitUploadKiBs}"
]
);
exclude = instance.request.excludePatterns;
};
};
in
mkMerge (mapAttrsToList mkJob enabledInstances);
}
{
services.borgbackup.jobs =
let
mkJob = name: instance: {
"${name}_${repoSlugName instance.settings.repository.path}" = {
inherit (instance.request) user;
repo = instance.settings.repository.path;
dumpCommand = lib.getExe (
pkgs.writeShellApplication {
name = "dump-command";
text = instance.request.backupCmd;
}
);
encryption.mode = "repokey-blake2";
# We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down.
encryption.passCommand = "cat ${instance.settings.passphrase.result.path}";
doInit = true;
failOnWarnings = true;
stateDir = instance.settings.stateDir;
persistentTimer = instance.settings.repository.timerConfig.Persistent or false;
startAt = ""; # Some non-empty list value that tricks upstream in creating the systemd timer.
prune.keep = instance.settings.retention;
extraArgs = (
optionals (instance.settings.limitUploadKiBs != null) [
"--upload-ratelimit=${toString instance.settings.limitUploadKiBs}"
]
);
};
};
in
mkMerge (mapAttrsToList mkJob enabledDatabases);
}
{
systemd.timers =
let
mkTimer = name: instance: {
${fullName name instance.settings.repository} = {
timerConfig = lib.mkForce instance.settings.repository.timerConfig;
};
};
in
mkMerge (mapAttrsToList mkTimer (enabledInstances // enabledDatabases));
}
{
systemd.services =
let
mkSettings =
name: instance:
let
serviceName = fullName name instance.settings.repository;
in
{
${serviceName} = mkMerge [
{
serviceConfig = {
# Makes the systemd service wait for the backup to be done before changing state to inactive.
Type = "oneshot";
Nice = lib.mkForce cfg.performance.niceness;
IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass;
IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority;
# BindReadOnlyPaths = instance.sourceDirectories;
};
}
(optionalAttrs (instance.settings.repository.secrets != { }) {
serviceConfig.EnvironmentFile = [
"/run/secrets_borgbackup/${serviceName}"
];
after = [ "${serviceName}-pre.service" ];
requires = [ "${serviceName}-pre.service" ];
})
];
"${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) (
let
script = shb.genConfigOutOfBandSystemd {
config = instance.settings.repository.secrets;
configLocation = "/run/secrets_borgbackup/${serviceName}";
generator = shb.toEnvVar;
user = instance.request.user;
};
in
{
script = script.preStart;
serviceConfig.Type = "oneshot";
serviceConfig.LoadCredential = script.loadCredentials;
}
);
};
in
mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases)));
}
{
systemd.services =
let
mkEnv =
name: instance:
nameValuePair "${fullName name instance.settings.repository}_restore_gen" {
enable = true;
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = (
shb.replaceSecrets {
userConfig = instance.settings.repository.secrets // {
BORG_PASSCOMMAND = ''"cat ${instance.settings.passphrase.result.path}"'';
BORG_REPO = instance.settings.repository.path;
};
resultPath = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
generator = shb.toEnvVar;
user = instance.request.user;
}
);
};
in
listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases)));
}
{
environment.systemPackages =
let
mkBorgBackupBinary =
name: instance:
pkgs.writeShellApplication {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
(cd / && sudocmd ${pkgs.borgbackup}/bin/borg extract "$BORG_REPO"::"$archive")
'';
};
in
flatten (mapAttrsToList mkBorgBackupBinary cfg.instances);
}
{
environment.systemPackages =
let
mkBorgBackupBinary =
name: instance:
pkgs.writeShellApplication {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
sudocmd sh -c "${pkgs.borgbackup}/bin/borg extract $BORG_REPO::$archive --stdout | ${instance.request.restoreCmd}"
'';
};
in
flatten (mapAttrsToList mkBorgBackupBinary cfg.databases);
}
(lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) {
shb.monitoring.dashboards = [
./backup/dashboard/Backups.json
];
})
]
);
}