I find these `mkProvider` and `mkRequester` functions really nice. Also fixes doc generation related to https://github.com/NixOS/nixpkgs/pull/353513
437 lines
17 KiB
Nix
437 lines
17 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 = {
|
|
request = mkOption {
|
|
description = ''
|
|
Request part of the backup contract.
|
|
|
|
Accepts values from a requester.
|
|
'';
|
|
|
|
type = contracts.backup.request;
|
|
};
|
|
|
|
settings = mkOption {
|
|
description = ''
|
|
Settings specific to the Restic provider.
|
|
'';
|
|
|
|
type = submodule {
|
|
options = commonOptions { inherit name config; prefix = "instances"; };
|
|
};
|
|
};
|
|
|
|
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.backup.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";
|
|
};
|
|
};
|
|
};
|
|
}));
|
|
};
|
|
|
|
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.before_backup;
|
|
|
|
backupCleanupCommand = concatStringsSep "\n" instance.request.hooks.after_backup;
|
|
|
|
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);
|
|
}
|
|
]);
|
|
}
|