restic
This commit is contained in:
@@ -1,32 +1,243 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
namespace,
|
||||
...
|
||||
}:
|
||||
with lib;
|
||||
let
|
||||
name = "restic";
|
||||
cfg = config.${namespace}.services.${name};
|
||||
inherit (lib.${namespace})
|
||||
mkOpt
|
||||
mkBoolOpt
|
||||
;
|
||||
|
||||
resticConfig = lib.${namespace}.mkModule {
|
||||
inherit config name;
|
||||
serviceName = "${name}-rest-server";
|
||||
description = "restic";
|
||||
options = { };
|
||||
moduleConfig = {
|
||||
# Configure the standard NixOS restic server service
|
||||
services.restic.server = {
|
||||
enable = true;
|
||||
dataDir = "${cfg.dataDir}/backup/restic";
|
||||
prometheus = true;
|
||||
listenAddress = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
htpasswd-file = "${cfg.dataDir}/backup/restic/.htpasswd";
|
||||
extraFlags = [ "--no-auth" ];
|
||||
cfg = config.${namespace}.services.restic;
|
||||
|
||||
# Only process jobs that have enable = true.
|
||||
enabledBackups = filterAttrs (_name: jobCfg: jobCfg.enable) cfg.backups;
|
||||
enabledJobNames = attrNames enabledBackups;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ntfy notify script
|
||||
# Reads NTFY_USER and NTFY_PASSWORD from the environment (injected via the
|
||||
# restic-ntfy.env SOPS template at runtime).
|
||||
#
|
||||
# On success, queries `restic snapshots --json` to retrieve stats from the
|
||||
# most recent snapshot and builds a rich summary message.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkNotifyScript =
|
||||
jobName:
|
||||
pkgs.writeShellScript "restic-notify-${jobName}" ''
|
||||
STATUS="$1" # "success" or "failure"
|
||||
HOST="$(${pkgs.hostname}/bin/hostname)"
|
||||
TOPIC="${cfg.ntfy.topic}"
|
||||
SERVER="${cfg.ntfy.server}"
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
TITLE="Backup succeeded: ${jobName} on $HOST"
|
||||
PRIORITY="low"
|
||||
TAGS="white_check_mark,floppy_disk"
|
||||
|
||||
# Query the latest snapshot for metadata. RESTIC_* env vars are
|
||||
# already set by the restic systemd service so we can call restic
|
||||
# directly. Fall back gracefully if the query fails.
|
||||
SNAP_JSON="$(${pkgs.restic}/bin/restic snapshots --json --last 2>/dev/null || echo '[]')"
|
||||
|
||||
SNAP_ID="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .short_id // "unknown"')"
|
||||
SNAP_TIME="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .time[:19] // "unknown"' | ${pkgs.gnused}/bin/sed 's/T/ /')"
|
||||
SNAP_HOST="$( echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .hostname // "unknown"')"
|
||||
SNAP_PATHS="$(echo "$SNAP_JSON" | ${pkgs.jq}/bin/jq -r 'last | .paths[] // "unknown"' | ${pkgs.coreutils}/bin/tr '\n' ' ')"
|
||||
|
||||
MESSAGE="$(printf 'Job: %s\nHost: %s\nPaths: %s\nSnapshot: %s\nTime: %s' \
|
||||
'${jobName}' "$SNAP_HOST" "$SNAP_PATHS" "$SNAP_ID" "$SNAP_TIME")"
|
||||
else
|
||||
TITLE="Backup FAILED: ${jobName} on $HOST"
|
||||
PRIORITY="high"
|
||||
TAGS="rotating_light,floppy_disk"
|
||||
MESSAGE="Restic backup '${jobName}' failed. Check: journalctl -u restic-backups-${jobName}.service"
|
||||
fi
|
||||
|
||||
${pkgs.curl}/bin/curl -sf \
|
||||
--user "$NTFY_USER:$NTFY_PASSWORD" \
|
||||
-H "Title: $TITLE" \
|
||||
-H "Priority: $PRIORITY" \
|
||||
-H "Tags: $TAGS" \
|
||||
-d "$MESSAGE" \
|
||||
"$SERVER/$TOPIC" || true
|
||||
'';
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure-notify companion unit (triggered via OnFailure=)
|
||||
# ---------------------------------------------------------------------------
|
||||
mkFailureNotifyUnit =
|
||||
jobName:
|
||||
let
|
||||
notifyScript = mkNotifyScript jobName;
|
||||
in
|
||||
{
|
||||
description = "Notify ntfy on restic backup failure: ${jobName}";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${notifyScript} failure";
|
||||
EnvironmentFile = [ config.sops.templates."restic-ntfy.env".path ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
imports = [ resticConfig ];
|
||||
options.${namespace}.services.restic = {
|
||||
enable = mkBoolOpt false "Enable restic backup jobs";
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# ntfy notification options
|
||||
# -----------------------------------------------------------------------
|
||||
ntfy = {
|
||||
enable = mkBoolOpt false "Send ntfy notifications on backup success/failure";
|
||||
|
||||
server = mkOpt types.str "https://ntfy.mjallen.dev" "ntfy server base URL";
|
||||
|
||||
topic = mkOpt types.str "backups" "ntfy topic to publish notifications to";
|
||||
|
||||
userSecret =
|
||||
mkOpt types.str ""
|
||||
"SOPS secret key path for the ntfy username (must be declared in sops.secrets by the caller)";
|
||||
|
||||
passwordSecret =
|
||||
mkOpt types.str ""
|
||||
"SOPS secret key path for the ntfy password (must be declared in sops.secrets by the caller)";
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Default excludes applied to every job (merged with per-job excludes)
|
||||
# -----------------------------------------------------------------------
|
||||
defaultExcludes = mkOpt (types.listOf types.str) [ ] "Exclude patterns applied to every backup job";
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Backup job definitions
|
||||
# -----------------------------------------------------------------------
|
||||
backups = mkOpt (types.attrsOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
enable = mkBoolOpt true "Enable this backup job";
|
||||
|
||||
paths = mkOpt (types.listOf types.str) [ ] "Paths to back up";
|
||||
|
||||
exclude = mkOpt (types.listOf types.str) [ ] "Additional exclude patterns for this job";
|
||||
|
||||
# Repository — exactly one of repository / repositoryFile must be set.
|
||||
repository = mkOpt (types.nullOr types.str) null "Restic repository URL (plain string)";
|
||||
|
||||
repositoryFile =
|
||||
mkOpt (types.nullOr types.str) null
|
||||
"Path to a file containing the repository URL (e.g. from SOPS)";
|
||||
|
||||
passwordFile = mkOpt (types.nullOr types.str) null "Path to the restic repository password file";
|
||||
|
||||
environmentFile =
|
||||
mkOpt (types.nullOr types.str) null
|
||||
"Path to an environment file (e.g. REST credentials from a SOPS template)";
|
||||
|
||||
rcloneConfigFile =
|
||||
mkOpt (types.nullOr types.str) null
|
||||
"Path to an rclone config file (for rclone: repositories)";
|
||||
|
||||
# Timer
|
||||
timerConfig = mkOpt (types.attrsOf types.anything) {
|
||||
OnCalendar = "02:00";
|
||||
RandomizedDelaySec = "1h";
|
||||
Persistent = true;
|
||||
} "systemd timer configuration for this job";
|
||||
|
||||
# Pruning / retention
|
||||
pruneOpts = mkOpt (types.listOf types.str) [
|
||||
"--keep-daily 7"
|
||||
"--keep-weekly 4"
|
||||
"--keep-monthly 12"
|
||||
"--keep-yearly 2"
|
||||
] "restic forget flags that control snapshot retention";
|
||||
|
||||
# Misc
|
||||
initialize = mkBoolOpt true "Initialise the repository if it does not exist";
|
||||
createWrapper = mkBoolOpt true "Create a restic wrapper script for this job";
|
||||
inhibitsSleep = mkBoolOpt true "Inhibit system sleep while this backup is running";
|
||||
|
||||
extraBackupArgs = mkOpt (types.listOf types.str) [ ] "Extra arguments passed to restic backup";
|
||||
};
|
||||
}
|
||||
)) { } "Restic backup job definitions";
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
# -----------------------------------------------------------------------
|
||||
# CLI tools
|
||||
# -----------------------------------------------------------------------
|
||||
environment.systemPackages = with pkgs; [
|
||||
restic
|
||||
restic-integrity
|
||||
];
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# SOPS: ntfy credentials env-file template
|
||||
#
|
||||
# The secrets themselves (desktop/ntfy/user, desktop/ntfy/password) are
|
||||
# declared in the per-system sops.nix so they can carry the correct
|
||||
# sopsFile path. This module only renders the template that combines them.
|
||||
# -----------------------------------------------------------------------
|
||||
sops.templates."restic-ntfy.env" =
|
||||
mkIf (cfg.ntfy.enable && cfg.ntfy.userSecret != "" && cfg.ntfy.passwordSecret != "")
|
||||
{
|
||||
mode = "0600";
|
||||
content = ''
|
||||
NTFY_USER=${config.sops.placeholder.${cfg.ntfy.userSecret}}
|
||||
NTFY_PASSWORD=${config.sops.placeholder.${cfg.ntfy.passwordSecret}}
|
||||
'';
|
||||
restartUnits = map (name: "restic-backups-${name}.service") enabledJobNames;
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Restic backup jobs
|
||||
# -----------------------------------------------------------------------
|
||||
services.restic.backups = mapAttrs (
|
||||
_name: jobCfg:
|
||||
{
|
||||
initialize = jobCfg.initialize;
|
||||
createWrapper = jobCfg.createWrapper;
|
||||
inhibitsSleep = jobCfg.inhibitsSleep;
|
||||
paths = jobCfg.paths;
|
||||
exclude = jobCfg.exclude ++ cfg.defaultExcludes;
|
||||
timerConfig = jobCfg.timerConfig;
|
||||
pruneOpts = jobCfg.pruneOpts;
|
||||
extraBackupArgs = jobCfg.extraBackupArgs;
|
||||
}
|
||||
// optionalAttrs (jobCfg.passwordFile != null) { inherit (jobCfg) passwordFile; }
|
||||
// optionalAttrs (jobCfg.repository != null) { inherit (jobCfg) repository; }
|
||||
// optionalAttrs (jobCfg.repositoryFile != null) { inherit (jobCfg) repositoryFile; }
|
||||
// optionalAttrs (jobCfg.environmentFile != null) { inherit (jobCfg) environmentFile; }
|
||||
// optionalAttrs (jobCfg.rcloneConfigFile != null) { inherit (jobCfg) rcloneConfigFile; }
|
||||
) enabledBackups;
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Systemd service overrides: ntfy env-file injection + notifications
|
||||
# -----------------------------------------------------------------------
|
||||
systemd.services = mkIf cfg.ntfy.enable (
|
||||
mkMerge (
|
||||
map (jobName: {
|
||||
# Inject the ntfy credentials env-file into the NixOS-generated
|
||||
# restic service and wire up the failure-notify companion unit.
|
||||
"restic-backups-${jobName}" = {
|
||||
serviceConfig = {
|
||||
EnvironmentFile = [ config.sops.templates."restic-ntfy.env".path ];
|
||||
# Send a success notification after the backup (and prune) completes.
|
||||
ExecStartPost = "${mkNotifyScript jobName} success";
|
||||
};
|
||||
unitConfig.OnFailure = "restic-notify-failure-${jobName}.service";
|
||||
};
|
||||
|
||||
# One-shot unit that fires on failure via OnFailure=.
|
||||
"restic-notify-failure-${jobName}" = mkFailureNotifyUnit jobName;
|
||||
}) enabledJobNames
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user