diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 33a52f5c..9fa277b0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -343,6 +343,11 @@ Makefile @thiagokokada /modules/services/betterlockscreen.nix @SebTM +/modules/programs/borgmatic.nix @DamienCassou +/modules/services/borgmatic.nix @DamienCassou +/tests/modules/programs/borgmatic @DamienCassou +/tests/modules/services/borgmatic @DamienCassou + /modules/services/caffeine.nix @uvNikita /modules/services/cbatticon.nix @pmiddend diff --git a/modules/misc/news.nix b/modules/misc/news.nix index 044ce393..0cc0fe54 100644 --- a/modules/misc/news.nix +++ b/modules/misc/news.nix @@ -748,6 +748,20 @@ in A new module is available: 'programs.discocss'. ''; } + + { + time = "2022-10-16T19:49:46+00:00"; + condition = hostPlatform.isLinux; + message = '' + Two new modules are available: + + - 'programs.borgmatic' and + - 'services.borgmatic'. + + use the first to configure the borgmatic tool and the second if you + want to automatically run scheduled backups. + ''; + } ]; }; } diff --git a/modules/modules.nix b/modules/modules.nix index b1bc0204..1bb875a4 100644 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -57,6 +57,7 @@ let ./programs/bashmount.nix ./programs/bat.nix ./programs/beets.nix + ./programs/borgmatic.nix ./programs/bottom.nix ./programs/broot.nix ./programs/browserpass.nix @@ -197,6 +198,7 @@ let ./services/barrier.nix ./services/betterlockscreen.nix ./services/blueman-applet.nix + ./services/borgmatic.nix ./services/caffeine.nix ./services/cbatticon.nix ./services/clipmenu.nix diff --git a/modules/programs/borgmatic.nix b/modules/programs/borgmatic.nix new file mode 100644 index 00000000..054ee232 --- /dev/null +++ b/modules/programs/borgmatic.nix @@ -0,0 +1,196 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.programs.borgmatic; + + mkNullableOption = args: + lib.mkOption (args // { + type = lib.types.nullOr args.type; + default = null; + }); + + mkRetentionOption = frequency: + mkNullableOption { + type = types.int; + description = + "Number of ${frequency} archives to keep. Use -1 for no limit."; + example = 3; + }; + + extraConfigOption = mkOption { + type = with types; attrsOf (oneOf [ str bool path int ]); + default = { }; + description = "Extra settings."; + }; + + consistencyCheckModule = types.submodule { + options = { + name = mkOption { + type = types.enum [ "repository" "archives" "data" "extract" ]; + description = "Name of consistency check to run."; + example = "repository"; + }; + + frequency = mkNullableOption { + type = types.strMatching "([[:digit:]]+ .*)|always"; + description = "Frequency of this type of check"; + example = "2 weeks"; + }; + }; + }; + + configModule = types.submodule { + options = { + location = { + sourceDirectories = mkOption { + type = types.listOf types.str; + description = "Directories to backup."; + example = literalExpression "[config.home.homeDirectory]"; + }; + + repositories = mkOption { + type = types.listOf types.str; + description = "Paths to repositories."; + example = + literalExpression ''["ssh://myuser@myrepo.myserver.com/./repo"]''; + }; + + extraConfig = extraConfigOption; + }; + + storage = { + encryptionPasscommand = mkNullableOption { + type = types.str; + description = "Command writing the passphrase to standard output."; + example = + literalExpression ''"''${pkgs.password-store}/bin/pass borg-repo"''; + }; + extraConfig = extraConfigOption; + }; + + retention = { + keepWithin = mkNullableOption { + type = types.strMatching "[[:digit:]]+[Hdwmy]"; + description = "Keep all archives within this time interval."; + example = "2d"; + }; + + keepSecondly = mkRetentionOption "secondly"; + keepMinutely = mkRetentionOption "minutely"; + keepHourly = mkRetentionOption "hourly"; + keepDaily = mkRetentionOption "daily"; + keepWeekly = mkRetentionOption "weekly"; + keepMonthly = mkRetentionOption "monthly"; + keepYearly = mkRetentionOption "yearly"; + + extraConfig = extraConfigOption; + }; + + consistency = { + checks = mkOption { + type = types.listOf consistencyCheckModule; + default = [ ]; + description = "Consistency checks to run"; + example = literalExpression '' + [ + { + name = "repository"; + frequency = "2 weeks"; + } + { + name = "archives"; + frequency = "4 weeks"; + } + { + name = "data"; + frequency = "6 weeks"; + } + { + name = "extract"; + frequency = "6 weeks"; + } + ]; + ''; + }; + + extraConfig = extraConfigOption; + }; + }; + }; + + removeNullValues = attrSet: filterAttrs (key: value: value != null) attrSet; + + writeConfig = config: + generators.toYAML { } { + location = removeNullValues { + source_directories = config.location.sourceDirectories; + repositories = config.location.repositories; + } // config.location.extraConfig; + storage = removeNullValues { + encryption_passcommand = config.storage.encryptionPasscommand; + } // config.storage.extraConfig; + retention = removeNullValues { + keep_within = config.retention.keepWithin; + keep_secondly = config.retention.keepSecondly; + keep_minutely = config.retention.keepMinutely; + keep_hourly = config.retention.keepHourly; + keep_daily = config.retention.keepDaily; + keep_weekly = config.retention.keepWeekly; + keep_monthly = config.retention.keepMonthly; + keep_yearly = config.retention.keepYearly; + } // config.retention.extraConfig; + consistency = removeNullValues { checks = config.consistency.checks; } + // config.consistency.extraConfig; + }; +in { + meta.maintainers = [ maintainers.DamienCassou ]; + + options = { + programs.borgmatic = { + enable = mkEnableOption "Borgmatic"; + + package = mkPackageOption pkgs "borgmatic" { }; + + backups = mkOption { + type = types.attrsOf configModule; + description = '' + Borgmatic allows for several named backup configurations, + each with its own source directories and repositories. + ''; + example = literalExpression '' + { + personal = { + location = { + sourceDirectories = [ "/home/me/personal" ]; + repositories = [ "ssh://myuser@myserver.com/./personal-repo" ]; + }; + }; + work = { + location = { + sourceDirectories = [ "/home/me/work" ]; + repositories = [ "ssh://myuser@myserver.com/./work-repo" ]; + }; + }; + }; + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + (lib.hm.assertions.assertPlatform "programs.borgmatic" pkgs + lib.platforms.linux) + ]; + + xdg.configFile = with lib.attrsets; + mapAttrs' (configName: config: + nameValuePair ("borgmatic.d/" + configName + ".yaml") { + text = writeConfig config; + }) cfg.backups; + + home.packages = [ cfg.package ]; + }; +} diff --git a/modules/services/borgmatic.nix b/modules/services/borgmatic.nix new file mode 100644 index 00000000..f91e84cd --- /dev/null +++ b/modules/services/borgmatic.nix @@ -0,0 +1,87 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + serviceConfig = config.services.borgmatic; + programConfig = config.programs.borgmatic; +in { + meta.maintainers = [ maintainers.DamienCassou ]; + + options = { + services.borgmatic = { + enable = mkEnableOption "Borgmatic service"; + + frequency = mkOption { + type = types.str; + default = "hourly"; + description = '' + How often to run borgmatic when + services.borgmatic.enable = true. + This value is passed to the systemd timer configuration as + the onCalendar option. See + + systemd.time + 7 + + for more information about the format. + ''; + }; + }; + }; + + config = mkIf serviceConfig.enable { + assertions = [ + (lib.hm.assertions.assertPlatform "services.borgmatic" pkgs + lib.platforms.linux) + ]; + + systemd.user = { + services.borgmatic = { + Unit = { + Description = "borgmatic backup"; + # Prevent borgmatic from running unless the machine is + # plugged into power: + ConditionACPower = true; + }; + Service = { + Type = "oneshot"; + + # Lower CPU and I/O priority: + Nice = 19; + CPUSchedulingPolicy = "batch"; + IOSchedulingClass = "best-effort"; + IOSchedulingPriority = 7; + IOWeight = 100; + + Restart = "no"; + LogRateLimitIntervalSec = 0; + + # Delay start to prevent backups running during boot: + ExecStartPre = "sleep 3m"; + + ExecStart = '' + ${pkgs.systemd}/bin/systemd-inhibit \ + --who="borgmatic" \ + --why="Prevent interrupting scheduled backup" \ + ${programConfig.package}/bin/borgmatic \ + --stats \ + --verbosity -1 \ + --list \ + --syslog-verbosity 1 + ''; + }; + }; + + timers.borgmatic = { + Unit.Description = "Run borgmatic backup"; + Timer = { + OnCalendar = serviceConfig.frequency; + Persistent = true; + RandomizedDelaySec = "10m"; + }; + Install.WantedBy = [ "timers.target" ]; + }; + }; + }; +} diff --git a/tests/default.nix b/tests/default.nix index 61959860..5621adb0 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -141,6 +141,7 @@ import nmt { ./modules/misc/xsession ./modules/programs/abook ./modules/programs/autorandr + ./modules/programs/borgmatic ./modules/programs/firefox ./modules/programs/foot ./modules/programs/getmail @@ -160,6 +161,7 @@ import nmt { ./modules/programs/xmobar ./modules/programs/yt-dlp ./modules/services/barrier + ./modules/services/borgmatic ./modules/services/devilspie2 ./modules/services/dropbox ./modules/services/emacs diff --git a/tests/modules/programs/borgmatic/basic-configuration.nix b/tests/modules/programs/borgmatic/basic-configuration.nix new file mode 100644 index 00000000..7a7b63d2 --- /dev/null +++ b/tests/modules/programs/borgmatic/basic-configuration.nix @@ -0,0 +1,110 @@ +{ config, pkgs, ... }: + +let + boolToString = bool: if bool then "true" else "false"; + backups = config.programs.borgmatic.backups; +in { + config = { + programs.borgmatic = { + enable = true; + backups = { + main = { + location = { + sourceDirectories = [ "/my-stuff-to-backup" ]; + repositories = [ "/mnt/disk1" "/mnt/disk2" ]; + extraConfig = { one_file_system = true; }; + }; + + storage = { + encryptionPasscommand = "fetch-the-password.sh"; + extraConfig = { checkpoint_interval = 200; }; + }; + + retention = { + keepWithin = "14d"; + keepSecondly = 12; + extraConfig = { prefix = "hostname"; }; + }; + + consistency = { + checks = [ + { + name = "repository"; + frequency = "2 weeks"; + } + { + name = "archives"; + frequency = "4 weeks"; + } + ]; + + extraConfig = { prefix = "hostname"; }; + }; + }; + }; + }; + + test.stubs.borgmatic = { }; + + nmt.script = '' + config_file=$TESTED/home-files/.config/borgmatic.d/main.yaml + assertFileExists $config_file + + declare -A expectations + + expectations[location.source_directories[0]]="${ + builtins.elemAt backups.main.location.sourceDirectories 0 + }" + expectations[location.repositories[0]]="${ + builtins.elemAt backups.main.location.repositories 0 + }" + expectations[location.repositories[1]]="${ + builtins.elemAt backups.main.location.repositories 1 + }" + expectations[location.one_file_system]="${ + boolToString backups.main.location.extraConfig.one_file_system + }" + + expectations[storage.encryption_passcommand]="${backups.main.storage.encryptionPasscommand}" + expectations[storage.checkpoint_interval]="${ + toString backups.main.storage.extraConfig.checkpoint_interval + }" + + expectations[retention.keep_within]="${backups.main.retention.keepWithin}" + expectations[retention.keep_secondly]="${ + toString backups.main.retention.keepSecondly + }" + expectations[retention.prefix]="${backups.main.retention.extraConfig.prefix}" + + expectations[consistency.checks[0].name]="${ + (builtins.elemAt backups.main.consistency.checks 0).name + }" + expectations[consistency.checks[0].frequency]="${ + (builtins.elemAt backups.main.consistency.checks 0).frequency + }" + expectations[consistency.checks[1].name]="${ + (builtins.elemAt backups.main.consistency.checks 1).name + }" + expectations[consistency.checks[1].frequency]="${ + (builtins.elemAt backups.main.consistency.checks 1).frequency + }" + expectations[consistency.prefix]="${backups.main.consistency.extraConfig.prefix}" + + yq=${pkgs.yq-go}/bin/yq + + for filter in "''${!expectations[@]}"; do + expected_value="''${expectations[$filter]}" + actual_value="$($yq ".$filter" $config_file)" + + if [[ "$actual_value" != "$expected_value" ]]; then + fail "Expected '$filter' to be '$expected_value' but was '$actual_value'" + fi + done + + one_file_system=$($yq ".location.one_file_system" $config_file) + if [[ $one_file_system != "true" ]]; then + fail "Expected one_file_system to be true but it was $one_file_system" + fi + ''; + }; +} diff --git a/tests/modules/programs/borgmatic/default.nix b/tests/modules/programs/borgmatic/default.nix new file mode 100644 index 00000000..721c4823 --- /dev/null +++ b/tests/modules/programs/borgmatic/default.nix @@ -0,0 +1 @@ +{ borgmatic-program-basic-configuration = ./basic-configuration.nix; } diff --git a/tests/modules/services/borgmatic/basic-configuration.nix b/tests/modules/services/borgmatic/basic-configuration.nix new file mode 100644 index 00000000..3763b98f --- /dev/null +++ b/tests/modules/services/borgmatic/basic-configuration.nix @@ -0,0 +1,22 @@ +{ config, pkgs, ... }: + +{ + config = { + services.borgmatic = { + enable = true; + frequency = "weekly"; + }; + + test.stubs.borgmatic = { }; + + nmt.script = '' + assertFileContent \ + $(normalizeStorePaths home-files/.config/systemd/user/borgmatic.service) \ + ${./basic-configuration.service} + + assertFileContent \ + home-files/.config/systemd/user/borgmatic.timer \ + ${./basic-configuration.timer} + ''; + }; +} diff --git a/tests/modules/services/borgmatic/basic-configuration.service b/tests/modules/services/borgmatic/basic-configuration.service new file mode 100644 index 00000000..6101b2b6 --- /dev/null +++ b/tests/modules/services/borgmatic/basic-configuration.service @@ -0,0 +1,23 @@ +[Service] +CPUSchedulingPolicy=batch +ExecStart=/nix/store/00000000000000000000000000000000-systemd/bin/systemd-inhibit \ + --who="borgmatic" \ + --why="Prevent interrupting scheduled backup" \ + @borgmatic@/bin/borgmatic \ + --stats \ + --verbosity -1 \ + --list \ + --syslog-verbosity 1 + +ExecStartPre=sleep 3m +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +IOWeight=100 +LogRateLimitIntervalSec=0 +Nice=19 +Restart=no +Type=oneshot + +[Unit] +ConditionACPower=true +Description=borgmatic backup diff --git a/tests/modules/services/borgmatic/basic-configuration.timer b/tests/modules/services/borgmatic/basic-configuration.timer new file mode 100644 index 00000000..1427925f --- /dev/null +++ b/tests/modules/services/borgmatic/basic-configuration.timer @@ -0,0 +1,10 @@ +[Install] +WantedBy=timers.target + +[Timer] +OnCalendar=weekly +Persistent=true +RandomizedDelaySec=10m + +[Unit] +Description=Run borgmatic backup diff --git a/tests/modules/services/borgmatic/default.nix b/tests/modules/services/borgmatic/default.nix new file mode 100644 index 00000000..802e7d0c --- /dev/null +++ b/tests/modules/services/borgmatic/default.nix @@ -0,0 +1 @@ +{ borgmatic-service-basic-configuration = ./basic-configuration.nix; }