selfhostblocks/modules/blocks/restic.nix
ibizaman 2e8b3fb166 remove group and retention from backup contract
Group is not strictly needed.
Retention is an implementation detail of the service backing up, not part of the contract.
2024-08-23 22:46:58 +02:00

289 lines
10 KiB
Nix

{ config, pkgs, lib, utils, ... }:
let
cfg = config.shb.restic;
shblib = pkgs.callPackage ../../lib {};
instanceOptions = {
enable = lib.mkEnableOption "shb restic";
passphraseFile = lib.mkOption {
description = "Encryption key for the backups.";
type = lib.types.path;
};
user = lib.mkOption {
description = ''
Unix user doing the backups.
For Restic, the same user must be used for all instances.
'';
type = lib.types.str;
default = cfg.user;
};
sourceDirectories = lib.mkOption {
description = "Source directories.";
type = lib.types.nonEmptyListOf lib.types.str;
};
excludePatterns = lib.mkOption {
description = "Exclude patterns.";
type = lib.types.listOf lib.types.str;
default = [];
};
repositories = lib.mkOption {
description = "Repositories to back this instance to.";
type = lib.types.nonEmptyListOf (lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.str;
description = "Repository location";
};
secrets = lib.mkOption {
type = lib.types.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 = lib.literalExpression ''
{
AWS_ACCESS_KEY_ID = <path/to/secret>;
AWS_SECRET_ACCESS_KEY = <path/to/secret>;
}
'';
};
timerConfig = lib.mkOption {
type = lib.types.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 = "For how long to keep backup files.";
type = lib.types.attrsOf (lib.types.oneOf [ lib.types.int lib.types.nonEmptyStr ]);
default = {
keep_within = "1d";
keep_hourly = 24;
keep_daily = 7;
keep_weekly = 4;
keep_monthly = 6;
};
};
hooks = lib.mkOption {
description = "Hooks to run before or after the backup.";
default = {};
type = lib.types.submodule {
options = {
before_backup = lib.mkOption {
description = "Hooks to run before backup";
type = lib.types.listOf lib.types.str;
default = [];
};
after_backup = lib.mkOption {
description = "Hooks to run after backup";
type = lib.types.listOf lib.types.str;
default = [];
};
};
};
};
};
repoSlugName = name: builtins.replaceStrings ["/" ":"] ["_" "_"] (lib.strings.removePrefix "/" name);
in
{
options.shb.restic = {
user = lib.mkOption {
description = "Unix user doing the backups.";
type = lib.types.str;
default = "backup";
};
instances = lib.mkOption {
description = "Each instance is a backup setting";
default = {};
type = lib.types.attrsOf (lib.types.submodule {
options = instanceOptions;
});
};
# 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. Only used for `restic backup`, `restic forget` and `restic check` commands.";
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. Only used for `restic backup`, `restic forget` and `restic check` commands.";
default = 7;
};
};
};
};
};
config = lib.mkIf (cfg.instances != {}) (
let
enabledInstances = lib.attrsets.filterAttrs (k: i: i.enable) cfg.instances;
in lib.mkMerge [
{
assertions = [
{
assertion = lib.all (x: x.user == cfg.user) (lib.mapAttrsToList (n: v: v)cfg.instances);
message = "All Restic instances must have the same user as 'shb.restic.user'.";
}
{
assertion = lib.all (x: x.group == cfg.group) (lib.mapAttrsToList (n: v: v) cfg.instances);
message = "All Restic instances must have the same group as 'shb.restic.group'.";
}
];
users.users = {
${cfg.user} = {
name = cfg.user;
group = cfg.group;
home = lib.mkForce "/var/lib/${cfg.user}";
createHome = true;
isSystemUser = true;
extraGroups = [ "keys" ];
};
};
users.groups = {
${cfg.group} = {
name = cfg.group;
};
};
}
{
environment.systemPackages = lib.optionals (enabledInstances != {}) [ pkgs.restic ];
systemd.tmpfiles.rules =
let
mkRepositorySettings = name: instance: repository: lib.optionals (lib.hasPrefix "/" repository.path) [
"d '${repository.path}' 0750 ${instance.user} root - -"
];
mkSettings = name: instance: builtins.map (mkRepositorySettings name instance) instance.repositories;
in
lib.flatten (lib.attrsets.mapAttrsToList mkSettings enabledInstances);
services.restic.backups =
let
mkRepositorySettings = name: instance: repository: {
"${name}_${repoSlugName repository.path}" = {
inherit (cfg) user;
repository = repository.path;
paths = instance.sourceDirectories;
passwordFile = toString instance.passphraseFile;
initialize = true;
inherit (repository) timerConfig;
pruneOpts = lib.mapAttrsToList (name: value:
"--${builtins.replaceStrings ["_"] ["-"] name} ${builtins.toString value}"
) instance.retention;
backupPrepareCommand = lib.strings.concatStringsSep "\n" instance.hooks.before_backup;
backupCleanupCommand = lib.strings.concatStringsSep "\n" instance.hooks.after_backup;
} // lib.attrsets.optionalAttrs (builtins.length instance.excludePatterns > 0) {
exclude = instance.excludePatterns;
};
};
mkSettings = name: instance: builtins.map (mkRepositorySettings name instance) instance.repositories;
in
lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSettings enabledInstances));
systemd.services =
let
mkRepositorySettings = name: instance: repository:
let
serviceName = "restic-backups-${name}_${repoSlugName repository.path}";
in
{
${serviceName} = lib.mkMerge [
{
serviceConfig = {
Nice = cfg.performance.niceness;
IOSchedulingClass = cfg.performance.ioSchedulingClass;
IOSchedulingPriority = cfg.performance.ioPriority;
};
}
(lib.attrsets.optionalAttrs (repository.secrets != {})
{
serviceConfig.EnvironmentFile = [
"/run/secrets/restic/${serviceName}"
];
after = [ "${serviceName}-pre.service" ];
requires = [ "${serviceName}-pre.service" ];
})
];
"${serviceName}-pre" = lib.mkIf (repository.secrets != {})
(let
script = shblib.genConfigOutOfBandSystemd {
config = repository.secrets;
configLocation = "/run/secrets/restic/${serviceName}";
generator = name: v: pkgs.writeText "template" (lib.generators.toINIWithGlobalSection {} { globalSection = v; });
};
in
{
script = script.preStart;
serviceConfig.Type = "oneshot";
serviceConfig.LoadCredential = script.loadCredentials;
});
};
mkSettings = name: instance: builtins.map (mkRepositorySettings name instance) instance.repositories;
in
lib.mkMerge (lib.flatten (lib.attrsets.mapAttrsToList mkSettings enabledInstances));
}
{
environment.systemPackages = let
mkResticBinary = name: instance: repository: pkgs.writeShellScriptBin "restic-${name}_${repoSlugName repository.path}" ''
export RESTIC_PASSWORD_FILE=${instance.passphraseFile}
export RESTIC_REPOSITORY=${repository.path}
${pkgs.restic}/bin/restic $@
'';
mkSettings = name: instance: builtins.map (mkResticBinary name instance) instance.repositories;
in
lib.flatten (lib.attrsets.mapAttrsToList mkSettings enabledInstances);
}
]);
}