selfhostblocks/modules/blocks/restic.nix
Pierre Penninckx 47dffd2a2f
switch backup contract to new contract style (#367)
Co-authored-by: ibizaman <ibizapeanut@gmail.com>
2024-11-26 19:59:07 +00:00

413 lines
16 KiB
Nix

{ config, pkgs, lib, utils, ... }:
let
cfg = config.shb.restic;
shblib = pkgs.callPackage ../../lib {};
contracts = pkgs.callPackage ../contracts {};
inherit (lib) concatStringsSep filterAttrs flatten literalExpression optionals listToAttrs mapAttrsToList mkEnableOption mkOption mkMerge;
inherit (lib) generators hasPrefix mkIf nameValuePair optionalAttrs removePrefix;
inherit (lib.types) attrsOf enum int ints oneOf nonEmptyStr nullOr str submodule;
commonOptions = { name, prefix, config, ... }: {
enable = mkEnableOption ''
this backup intance.
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 = contracts.secret.mkRequester {
mode = "0400";
owner = config.request.user;
ownerText = "[shb.restic.${prefix}.<name>.request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)";
restartUnits = [ (fullName name config.settings.repository) ];
restartUnitsText = "[ [shb.restic.${prefix}.<name>.settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]";
};
};
};
repository = mkOption {
description = "Repositories to back this instance to.";
type = submodule {
options = {
path = mkOption {
type = str;
description = "Repository location";
};
secrets = mkOption {
type = attrsOf shblib.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 = mkOption {
description = "For how long to keep backup files.";
type = attrsOf (oneOf [ int nonEmptyStr ]);
default = {
keep_within = "1d";
keep_hourly = 24;
keep_daily = 7;
keep_weekly = 4;
keep_monthly = 6;
};
};
limitUploadKiBs = mkOption {
type = nullOr int;
description = "Limit upload bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
limitDownloadKiBs = mkOption {
type = nullOr int;
description = "Limit download bandwidth to the given KiB/s amount.";
default = null;
example = 8000;
};
};
repoSlugName = name: builtins.replaceStrings ["/" ":"] ["_" "_"] (removePrefix "/" name);
fullName = name: repository: "restic-backups-${name}_${repoSlugName repository.path}";
in
{
options.shb.restic = {
instances = mkOption {
description = "Files to backup following the [backup contract](./contracts-backup.html).";
default = {};
type = attrsOf (submodule ({ name, config, ... }: {
options = contracts.backup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the Restic 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](./contracts-databasebackup.html).";
default = {};
type = attrsOf (submodule ({ name, config, ... }: {
options = {
request = mkOption {
description = ''
Request part of the backup contract.
Accepts values from a requester.
'';
type = contracts.databasebackup.request;
};
settings = mkOption {
description = ''
Settings specific to the Restic provider.
'';
type = submodule {
options = commonOptions { inherit name config; prefix = "databases"; };
};
};
result = mkOption {
description = ''
Result part of the backup contract.
Contains the output of the Restic provider.
'';
default = {
restoreScript = fullName name config.settings.repository;
backupService = "${fullName name config.settings.repository}.service";
};
defaultText = {
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
type = contracts.databasebackup.result {
restoreScript = fullName name config.settings.repository;
backupService = "${fullName name config.settings.repository}.service";
restoreScriptText = "${fullName "<name>" { path = "path/to/repository"; }}";
backupServiceText = "${fullName "<name>" { path = "path/to/repository"; }}.service";
};
};
};
}));
};
# Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23
performance = mkOption {
description = "Reduce performance impact of backup jobs.";
default = {};
type = submodule {
options = {
niceness = mkOption {
type = ints.between (-20) 19;
description = "nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process";
default = 15;
};
ioSchedulingClass = mkOption {
type = enum [ "idle" "best-effort" "realtime" ];
description = "ionice scheduling class, defaults to best-effort IO. Only used for `restic backup`, `restic forget` and `restic check` commands.";
default = "best-effort";
};
ioPriority = mkOption {
type = nullOr (ints.between 0 7);
description = "ionice priority, defaults to 7 for lowest priority IO. Only used for `restic backup`, `restic forget` and `restic check` commands.";
default = 7;
};
};
};
};
};
config = 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 mkMerge [
{
environment.systemPackages = optionals (enabledInstances != {} || enabledDatabases != {}) [ pkgs.restic ];
}
{
# Create repository if it is a local path.
systemd.tmpfiles.rules =
let
mkSettings = name: instance: optionals (hasPrefix "/" instance.settings.repository.path) [
"d '${instance.settings.repository.path}' 0750 ${instance.request.user} root - -"
];
in
flatten (mapAttrsToList mkSettings (cfg.instances // cfg.databases));
}
{
services.restic.backups =
let
mkSettings = name: instance: {
"${name}_${repoSlugName instance.settings.repository.path}" = {
inherit (instance.request) user;
repository = instance.settings.repository.path;
paths = instance.request.sourceDirectories;
passwordFile = toString instance.settings.passphrase.result.path;
initialize = true;
inherit (instance.settings.repository) timerConfig;
pruneOpts = mapAttrsToList (name: value:
"--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}"
) instance.settings.retention;
backupPrepareCommand = concatStringsSep "\n" instance.request.hooks.beforeBackup;
backupCleanupCommand = concatStringsSep "\n" instance.request.hooks.afterBackup;
extraBackupArgs =
(optionals (instance.settings.limitUploadKiBs != null) [
"--limit-upload=${toString instance.settings.limitUploadKiBs}"
])
++ (optionals (instance.settings.limitDownloadKiBs != null) [
"--limit-download=${toString instance.settings.limitDownloadKiBs}"
]);
} // optionalAttrs (builtins.length instance.request.excludePatterns > 0) {
exclude = instance.request.excludePatterns;
};
};
in
mkMerge (flatten (mapAttrsToList mkSettings enabledInstances));
}
{
services.restic.backups =
let
mkSettings = name: instance: {
"${name}_${repoSlugName instance.settings.repository.path}" = {
inherit (instance.request) user;
repository = instance.settings.repository.path;
dynamicFilesFrom = "echo";
passwordFile = toString instance.settings.passphrase.result.path;
initialize = true;
inherit (instance.settings.repository) timerConfig;
pruneOpts = mapAttrsToList (name: value:
"--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}"
) instance.settings.retention;
extraBackupArgs =
(optionals (instance.settings.limitUploadKiBs != null) [
"--limit-upload=${toString instance.settings.limitUploadKiBs}"
])
++ (optionals (instance.settings.limitDownloadKiBs != null) [
"--limit-download=${toString instance.settings.limitDownloadKiBs}"
])
++
(let
cmd = pkgs.writeShellScriptBin "dump.sh" instance.request.backupCmd;
in
[
"--stdin-filename ${instance.request.backupName} --stdin-from-command -- ${cmd}/bin/dump.sh"
]);
};
};
in
mkMerge (flatten (mapAttrsToList mkSettings enabledDatabases));
}
{
systemd.services =
let
mkSettings = name: instance:
let
serviceName = fullName name instance.settings.repository;
in
{
${serviceName} = mkMerge [
{
serviceConfig = {
Nice = cfg.performance.niceness;
IOSchedulingClass = cfg.performance.ioSchedulingClass;
IOSchedulingPriority = cfg.performance.ioPriority;
# BindReadOnlyPaths = instance.sourceDirectories;
};
}
(optionalAttrs (instance.settings.repository.secrets != {})
{
serviceConfig.EnvironmentFile = [
"/run/secrets_restic/${serviceName}"
];
after = [ "${serviceName}-pre.service" ];
requires = [ "${serviceName}-pre.service" ];
})
];
"${serviceName}-pre" = mkIf (instance.settings.repository.secrets != {})
(let
script = shblib.genConfigOutOfBandSystemd {
config = instance.settings.repository.secrets;
configLocation = "/run/secrets_restic/${serviceName}";
generator = name: v: pkgs.writeText "template" (generators.toINIWithGlobalSection {} { globalSection = v; });
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 = (shblib.replaceSecrets {
userConfig = instance.settings.repository.secrets // {
RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path;
RESTIC_REPOSITORY = instance.settings.repository.path;
};
resultPath = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
generator = name: v: pkgs.writeText (fullName name instance.settings.repository) (generators.toINIWithGlobalSection {} { globalSection = v; });
user = instance.request.user;
});
};
in
listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases)));
}
{
environment.systemPackages = let
mkResticBinary = name: instance:
pkgs.writeShellScriptBin (fullName name instance.settings.repository) ''
set -euo pipefail
export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \
| xargs -d '\n')
if ! [ "$1" = "restore" ]; then
sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@
else
shift
sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic restore $@ --target /"
fi
'';
in
flatten (mapAttrsToList mkResticBinary cfg.instances);
}
{
environment.systemPackages = let
mkResticBinary = name: instance:
pkgs.writeShellScriptBin (fullName name instance.settings.repository) ''
set -euo pipefail
export $(grep -v '^#' "/run/secrets_restic_env/${fullName name instance.settings.repository}" \
| xargs -d '\n')
if ! [ "$1" = "restore" ]; then
sudo --preserve-env -u ${instance.request.user} ${pkgs.restic}/bin/restic $@
else
shift
sudo --preserve-env -u ${instance.request.user} sh -c "${pkgs.restic}/bin/restic dump $@ ${instance.request.backupName} | ${instance.request.restoreCmd}"
fi
'';
in
flatten (mapAttrsToList mkResticBinary cfg.databases);
}
]);
}