247 lines
9.8 KiB
Nix
247 lines
9.8 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
namespace,
|
|
...
|
|
}:
|
|
with lib;
|
|
let
|
|
inherit (lib.${namespace})
|
|
mkOpt
|
|
mkBoolOpt
|
|
;
|
|
|
|
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
|
|
{
|
|
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-browser
|
|
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:
|
|
{
|
|
inherit (jobCfg)
|
|
initialize
|
|
createWrapper
|
|
inhibitsSleep
|
|
paths
|
|
timerConfig
|
|
pruneOpts
|
|
extraBackupArgs
|
|
;
|
|
exclude = jobCfg.exclude ++ cfg.defaultExcludes;
|
|
}
|
|
// 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
|
|
)
|
|
);
|
|
};
|
|
}
|