summary refs log tree commit diff
diff options
context:
space:
mode:
authorBen Wolsieffer <benwolsieffer@gmail.com>2019-10-26 23:37:30 -0400
committerSilvan Mosberger <contact@infinisil.com>2020-02-10 01:12:39 +0100
commit7684537e333660e14f1a81add303853f1cb9e87e (patch)
treea3dbd66be589728ae052788770ca83f6af8d2380
parentdcd96eebd851709717521d0ede97858138da6783 (diff)
downloadnixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar.gz
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar.bz2
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar.lz
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar.xz
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.tar.zst
nixpkgs-7684537e333660e14f1a81add303853f1cb9e87e.zip
nixos/sanoid, nixos/syncoid: init module and test
-rw-r--r--nixos/modules/module-list.nix2
-rw-r--r--nixos/modules/services/backup/sanoid.nix213
-rw-r--r--nixos/modules/services/backup/syncoid.nix168
-rw-r--r--nixos/tests/all-tests.nix1
-rw-r--r--nixos/tests/sanoid.nix90
-rw-r--r--pkgs/tools/backup/sanoid/default.nix10
6 files changed, 484 insertions, 0 deletions
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index eadf1d2d89b..489f36b7101 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -226,6 +226,8 @@
   ./services/backup/restic.nix
   ./services/backup/restic-rest-server.nix
   ./services/backup/rsnapshot.nix
+  ./services/backup/sanoid.nix
+  ./services/backup/syncoid.nix
   ./services/backup/tarsnap.nix
   ./services/backup/tsm.nix
   ./services/backup/zfs-replication.nix
diff --git a/nixos/modules/services/backup/sanoid.nix b/nixos/modules/services/backup/sanoid.nix
new file mode 100644
index 00000000000..0472fb4ba1e
--- /dev/null
+++ b/nixos/modules/services/backup/sanoid.nix
@@ -0,0 +1,213 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.sanoid;
+
+  datasetSettingsType = with types;
+    (attrsOf (nullOr (oneOf [ str int bool (listOf str) ]))) // {
+      description = "dataset/template options";
+    };
+
+  # Default values from https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf
+
+  commonOptions = {
+    hourly = mkOption {
+      description = "Number of hourly snapshots.";
+      type = types.ints.unsigned;
+      default = 48;
+    };
+
+    daily = mkOption {
+      description = "Number of daily snapshots.";
+      type = types.ints.unsigned;
+      default = 90;
+    };
+
+    monthly = mkOption {
+      description = "Number of monthly snapshots.";
+      type = types.ints.unsigned;
+      default = 6;
+    };
+
+    yearly = mkOption {
+      description = "Number of yearly snapshots.";
+      type = types.ints.unsigned;
+      default = 0;
+    };
+
+    autoprune = mkOption {
+      description = "Whether to automatically prune old snapshots.";
+      type = types.bool;
+      default = true;
+    };
+
+    autosnap = mkOption {
+      description = "Whether to automatically take snapshots.";
+      type = types.bool;
+      default = true;
+    };
+
+    settings = mkOption {
+      description = ''
+        Free-form settings for this template/dataset. See
+        <link xlink:href="https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf"/>
+        for allowed values.
+      '';
+      type = datasetSettingsType;
+    };
+  };
+
+  commonConfig = config: {
+    settings = {
+      hourly = mkDefault config.hourly;
+      daily = mkDefault config.daily;
+      monthly = mkDefault config.monthly;
+      yearly = mkDefault config.yearly;
+      autoprune = mkDefault config.autoprune;
+      autosnap = mkDefault config.autosnap;
+    };
+  };
+
+  datasetOptions = {
+    useTemplate = mkOption {
+      description = "Names of the templates to use for this dataset.";
+      type = (types.listOf (types.enum (attrNames cfg.templates))) // {
+        description = "list of template names";
+      };
+      default = [];
+    };
+
+    recursive = mkOption {
+      description = "Whether to recursively snapshot dataset children.";
+      type = types.bool;
+      default = false;
+    };
+
+    processChildrenOnly = mkOption {
+      description = "Whether to only snapshot child datasets if recursing.";
+      type = types.bool;
+      default = false;
+    };
+  };
+
+  datasetConfig = config: {
+    settings = {
+      use_template = mkDefault config.useTemplate;
+      recursive = mkDefault config.recursive;
+      process_children_only = mkDefault config.processChildrenOnly;
+    };
+  };
+
+  # Extract pool names from configured datasets
+  pools = unique (map (d: head (builtins.match "([^/]+).*" d)) (attrNames cfg.datasets));
+
+  configFile = let
+    mkValueString = v:
+      if builtins.isList v then concatStringsSep "," v
+      else generators.mkValueStringDefault {} v;
+
+    mkKeyValue = k: v: if v == null then ""
+      else generators.mkKeyValueDefault { inherit mkValueString; } "=" k v;
+  in generators.toINI { inherit mkKeyValue; } cfg.settings;
+
+  configDir = pkgs.writeTextDir "sanoid.conf" configFile;
+
+in {
+
+    # Interface
+
+    options.services.sanoid = {
+      enable = mkEnableOption "Sanoid ZFS snapshotting service";
+
+      interval = mkOption {
+        type = types.str;
+        default = "hourly";
+        example = "daily";
+        description = ''
+          Run sanoid at this interval. The default is to run hourly.
+
+          The format is described in
+          <citerefentry><refentrytitle>systemd.time</refentrytitle>
+          <manvolnum>7</manvolnum></citerefentry>.
+        '';
+      };
+
+      datasets = mkOption {
+        type = types.attrsOf (types.submodule ({ config, ... }: {
+          options = commonOptions // datasetOptions;
+          config = mkMerge [ (commonConfig config) (datasetConfig config) ];
+        }));
+        default = {};
+        description = "Datasets to snapshot.";
+      };
+
+      templates = mkOption {
+        type = types.attrsOf (types.submodule ({ config, ... }: {
+          options = commonOptions;
+          config = commonConfig config;
+        }));
+        default = {};
+        description = "Templates for datasets.";
+      };
+
+      settings = mkOption {
+        type = types.attrsOf datasetSettingsType;
+        description = ''
+          Free-form settings written directly to the config file. See
+          <link xlink:href="https://github.com/jimsalterjrs/sanoid/blob/master/sanoid.defaults.conf"/>
+          for allowed values.
+        '';
+      };
+
+      extraArgs = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "--verbose" "--readonly" "--debug" ];
+        description = ''
+          Extra arguments to pass to sanoid. See
+          <link xlink:href="https://github.com/jimsalterjrs/sanoid/#sanoid-command-line-options"/>
+          for allowed options.
+        '';
+      };
+    };
+
+    # Implementation
+
+    config = mkIf cfg.enable {
+      services.sanoid.settings = mkMerge [
+        (mapAttrs' (d: v: nameValuePair ("template_" + d) v.settings) cfg.templates)
+        (mapAttrs (d: v: v.settings) cfg.datasets)
+      ];
+
+      systemd.services.sanoid = {
+        description = "Sanoid snapshot service";
+        serviceConfig = {
+          ExecStartPre = map (pool: lib.escapeShellArgs [
+            "+/run/booted-system/sw/bin/zfs" "allow"
+            "sanoid" "snapshot,mount,destroy" pool
+          ]) pools;
+          ExecStart = lib.escapeShellArgs ([
+            "${pkgs.sanoid}/bin/sanoid"
+            "--cron"
+            "--configdir" configDir
+          ] ++ cfg.extraArgs);
+          ExecStopPost = map (pool: lib.escapeShellArgs [
+            "+/run/booted-system/sw/bin/zfs" "unallow" "sanoid" pool
+          ]) pools;
+          User = "sanoid";
+          Group = "sanoid";
+          DynamicUser = true;
+          RuntimeDirectory = "sanoid";
+          CacheDirectory = "sanoid";
+        };
+        # Prevents missing snapshots during DST changes
+        environment.TZ = "UTC";
+        after = [ "zfs.target" ];
+        startAt = cfg.interval;
+      };
+    };
+
+    meta.maintainers = with maintainers; [ lopsided98 ];
+  }
diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
new file mode 100644
index 00000000000..53787a0182a
--- /dev/null
+++ b/nixos/modules/services/backup/syncoid.nix
@@ -0,0 +1,168 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.syncoid;
+in {
+
+    # Interface
+
+    options.services.syncoid = {
+      enable = mkEnableOption "Syncoid ZFS synchronization service";
+
+      interval = mkOption {
+        type = types.str;
+        default = "hourly";
+        example = "*-*-* *:15:00";
+        description = ''
+          Run syncoid at this interval. The default is to run hourly.
+
+          The format is described in
+          <citerefentry><refentrytitle>systemd.time</refentrytitle>
+          <manvolnum>7</manvolnum></citerefentry>.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "root";
+        example = "backup";
+        description = ''
+          The user for the service. Sudo or ZFS privilege delegation must be
+          configured to use a user other than root.
+        '';
+      };
+
+      sshKey = mkOption {
+        type = types.nullOr types.path;
+        # Prevent key from being copied to store
+        apply = mapNullable toString;
+        default = null;
+        description = ''
+          SSH private key file to use to login to the remote system. Can be
+          overridden in individual commands.
+        '';
+      };
+
+      commonArgs = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "--no-sync-snap" ];
+        description = ''
+          Arguments to add to every syncoid command, unless disabled for that
+          command. See
+          <link xlink:href="https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options"/>
+          for available options.
+        '';
+      };
+
+      commands = mkOption {
+        type = types.attrsOf (types.submodule ({ name, ... }: {
+          options = {
+            source = mkOption {
+              type = types.str;
+              example = "pool/dataset";
+              description = ''
+                Source ZFS dataset. Can be either local or remote. Defaults to
+                the attribute name.
+              '';
+            };
+
+            target = mkOption {
+              type = types.str;
+              example = "user@server:pool/dataset";
+              description = ''
+                Target ZFS dataset. Can be either local
+                (<replaceable>pool/dataset</replaceable>) or remote
+                (<replaceable>user@server:pool/dataset</replaceable>).
+              '';
+            };
+
+            recursive = mkOption {
+              type = types.bool;
+              default = false;
+              description = ''
+                Whether to also transfer child datasets.
+              '';
+            };
+
+            sshKey = mkOption {
+              type = types.nullOr types.path;
+              # Prevent key from being copied to store
+              apply = mapNullable toString;
+              description = ''
+                SSH private key file to use to login to the remote system.
+                Defaults to <option>services.syncoid.sshKey</option> option.
+              '';
+            };
+
+            sendOptions = mkOption {
+              type = types.separatedString " ";
+              default = "";
+              example = "Lc e";
+              description = ''
+                Advanced options to pass to zfs send. Options are specified
+                without their leading dashes and separated by spaces.
+              '';
+            };
+
+            recvOptions = mkOption {
+              type = types.separatedString " ";
+              default = "";
+              example = "ux recordsize o compression=lz4";
+              description = ''
+                Advanced options to pass to zfs recv. Options are specified
+                without their leading dashes and separated by spaces.
+              '';
+            };
+
+            useCommonArgs = mkOption {
+              type = types.bool;
+              default = true;
+              description = ''
+                Whether to add the configured common arguments to this command.
+              '';
+            };
+
+            extraArgs = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              example = [ "--sshport 2222" ];
+              description = "Extra syncoid arguments for this command.";
+            };
+          };
+          config = {
+            source = mkDefault name;
+            sshKey = mkDefault cfg.sshKey;
+          };
+        }));
+        default = {};
+        example."pool/test".target = "root@target:pool/test";
+        description = "Syncoid commands to run.";
+      };
+    };
+
+    # Implementation
+
+    config = mkIf cfg.enable {
+      systemd.services.syncoid = {
+        description = "Syncoid ZFS synchronization service";
+        script = concatMapStringsSep "\n" (c: lib.escapeShellArgs
+          ([ "${pkgs.sanoid}/bin/syncoid" ]
+            ++ (optionals c.useCommonArgs cfg.commonArgs)
+            ++ (optional c.recursive "-r")
+            ++ (optionals (c.sshKey != null) [ "--sshkey" c.sshKey ])
+            ++ c.extraArgs
+            ++ [ "--sendoptions" c.sendOptions
+                 "--recvoptions" c.recvOptions
+                 c.source c.target
+               ])) (attrValues cfg.commands);
+        after = [ "zfs.target" ];
+        serviceConfig.User = cfg.user;
+        startAt = cfg.interval;
+      };
+    };
+
+    meta.maintainers = with maintainers; [ lopsided98 ];
+  }
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index eb69457fb7e..874c338905d 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -255,6 +255,7 @@ in
   runInMachine = handleTest ./run-in-machine.nix {};
   rxe = handleTest ./rxe.nix {};
   samba = handleTest ./samba.nix {};
+  sanoid = handleTest ./sanoid.nix {};
   sddm = handleTest ./sddm.nix {};
   shiori = handleTest ./shiori.nix {};
   signal-desktop = handleTest ./signal-desktop.nix {};
diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
new file mode 100644
index 00000000000..284b38932cc
--- /dev/null
+++ b/nixos/tests/sanoid.nix
@@ -0,0 +1,90 @@
+import ./make-test-python.nix ({ pkgs, ... }: let
+  inherit (import ./ssh-keys.nix pkgs)
+    snakeOilPrivateKey snakeOilPublicKey;
+
+  commonConfig = { pkgs, ... }: {
+    virtualisation.emptyDiskImages = [ 2048 ];
+    boot.supportedFilesystems = [ "zfs" ];
+    environment.systemPackages = [ pkgs.parted ];
+  };
+in {
+  name = "sanoid";
+  meta = with pkgs.stdenv.lib.maintainers; {
+    maintainers = [ lopsided98 ];
+  };
+
+  nodes = {
+    source = { ... }: {
+      imports = [ commonConfig ];
+      networking.hostId = "daa82e91";
+
+      programs.ssh.extraConfig = ''
+        UserKnownHostsFile=/dev/null
+        StrictHostKeyChecking=no
+      '';
+
+      services.sanoid = {
+        enable = true;
+        templates.test = {
+          hourly = 12;
+          daily = 1;
+          monthly = 1;
+          yearly = 1;
+
+          autosnap = true;
+        };
+        datasets."pool/test".useTemplate = [ "test" ];
+      };
+
+      services.syncoid = {
+        enable = true;
+        sshKey = "/root/.ssh/id_ecdsa";
+        commonArgs = [ "--no-sync-snap" ];
+        commands."pool/test".target = "root@target:pool/test";
+      };
+    };
+    target = { ... }: {
+      imports = [ commonConfig ];
+      networking.hostId = "dcf39d36";
+
+      services.openssh.enable = true;
+      users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ];
+    };
+  };
+
+  testScript = ''
+    source.succeed(
+        "mkdir /tmp/mnt",
+        "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
+        "udevadm settle",
+        "zpool create pool /dev/vdb1",
+        "zfs create -o mountpoint=legacy pool/test",
+        "mount -t zfs pool/test /tmp/mnt",
+        "udevadm settle",
+    )
+    target.succeed(
+        "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
+        "udevadm settle",
+        "zpool create pool /dev/vdb1",
+        "udevadm settle",
+    )
+
+    source.succeed("mkdir -m 700 /root/.ssh")
+    source.succeed(
+        "cat '${snakeOilPrivateKey}' > /root/.ssh/id_ecdsa"
+    )
+    source.succeed("chmod 600 /root/.ssh/id_ecdsa")
+
+    source.succeed("touch /tmp/mnt/test.txt")
+    source.systemctl("start --wait sanoid.service")
+
+    target.wait_for_open_port(22)
+    source.systemctl("start --wait syncoid.service")
+    target.succeed(
+        "mkdir /tmp/mnt",
+        "zfs set mountpoint=legacy pool/test",
+        "mount -t zfs pool/test /tmp/mnt",
+    )
+    target.succeed("cat /tmp/mnt/test.txt")
+  '';
+})
diff --git a/pkgs/tools/backup/sanoid/default.nix b/pkgs/tools/backup/sanoid/default.nix
index d67916c1e05..569a07a459b 100644
--- a/pkgs/tools/backup/sanoid/default.nix
+++ b/pkgs/tools/backup/sanoid/default.nix
@@ -25,6 +25,16 @@ stdenv.mkDerivation rec {
       url = "https://github.com/jimsalterjrs/sanoid/commit/44bcd21f269e17765acd1ad0d45161902a205c7b.patch";
       sha256 = "0zqyl8q5sfscqcc07acw68ysnlnh3nb57cigjfwbccsm0zwlwham";
     })
+    # Add --cache-dir option
+    (fetchpatch {
+      url = "https://github.com/jimsalterjrs/sanoid/commit/a1f5e4c0c006e16a5047a16fc65c9b3663adb81e.patch";
+      sha256 = "1bb4g2zxrbvf7fvcgzzxsr1cvxzrxg5dzh89sx3h7qlrd6grqhdy";
+    })
+    # Add --run-dir option
+    (fetchpatch {
+      url = "https://github.com/jimsalterjrs/sanoid/commit/59a07f92b4920952cc9137b03c1533656f48b121.patch";
+      sha256 = "11v4jhc36v839gppzvhvzp5jd22904k8xqdhhpx6ghl75yyh4f4s";
+    })
   ];
 
   nativeBuildInputs = [ makeWrapper ];