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; }