From 0e837ecd5ffb5e8366a5e2878838ba691b31cd99 Mon Sep 17 00:00:00 2001 From: Julien Moutinho Date: Sat, 27 Nov 2021 02:50:39 +0100 Subject: [PATCH 2/2] nixos/syncoid: use DynamicUser= HistoryNote: the following changes were previously in separate commits but have been squashed and nixfmt-rfc-style on be able to rebase them onto a treewide change applying nixfmt-rfc-style on nixos/modules/services/backup/syncoid.nix which introduced multiple untrackable conflicts. Changes: - nixos/syncoid: add destroy to localTargetAllow's default syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining): syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists syncoid[3282897]: cannot receive: failed to read from stream syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied - nixos/syncoid: allow @timer syscalls - nixos/syncoid: set TZ envvar - nixos/syncoid: test sending to multiple targets - nixos/syncoid: don't delegate permissions to source's parent See https://github.com/NixOS/nixpkgs/pull/165204 and https://github.com/NixOS/nixpkgs/pull/180111 - nixos/syncoid: add support for LoadCredentialEncrypted= - nixos/syncoid: zfs-unallow unused dynamic users - nixos/syncoid: use NFTSet= Changes between the treewide nixfmt-rfc-style and those changes have been preserved: - 71967c47e54c56557c0ee7bec7fe554c018e37c4 nixos/syncoid: allow interval to be list of strings - f34483be5ee2418a563545a56743b7b59c549935 nixosTests: handleTest -> runTest, batch 1 --- nixos/modules/services/backup/syncoid.nix | 454 +++++++++++----------- nixos/tests/sanoid.nix | 78 ++-- 2 files changed, 269 insertions(+), 263 deletions(-) diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix index 8b4c59155f..7d344a1ad0 100644 --- a/nixos/modules/services/backup/syncoid.nix +++ b/nixos/modules/services/backup/syncoid.nix @@ -7,7 +7,7 @@ let cfg = config.services.syncoid; - # Extract local dasaset names (so no datasets containing "@") + # Extract local dataset names (so no datasets containing "@") localDatasetName = d: lib.optionals (d != null) ( @@ -20,81 +20,9 @@ let # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html escapeUnitName = name: - lib.concatMapStrings (s: if lib.isList s then "-" else s) ( + lib.concatMapStrings (s: if builtins.isList s then "-" else s) ( builtins.split "[^a-zA-Z0-9_.\\-]+" name ); - - # Function to build "zfs allow" commands for the filesystems we've delegated - # permissions to. It also checks if the target dataset exists before - # delegating permissions, if it doesn't exist we delegate it to the parent - # dataset (if it exists). This should solve the case of provisoning new - # datasets. - buildAllowCommand = - permissions: dataset: - ( - "-+${pkgs.writeShellScript "zfs-allow-${dataset}" '' - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS - - # Run a ZFS list on the dataset to check if it exists - if ${ - lib.escapeShellArgs [ - "/run/booted-system/sw/bin/zfs" - "list" - dataset - ] - } 2> /dev/null; then - ${lib.escapeShellArgs [ - "/run/booted-system/sw/bin/zfs" - "allow" - cfg.user - (lib.concatStringsSep "," permissions) - dataset - ]} - ${lib.optionalString ((builtins.dirOf dataset) != ".") '' - else - ${lib.escapeShellArgs [ - "/run/booted-system/sw/bin/zfs" - "allow" - cfg.user - (lib.concatStringsSep "," permissions) - # Remove the last part of the path - (builtins.dirOf dataset) - ]} - ''} - fi - ''}" - ); - - # Function to build "zfs unallow" commands for the filesystems we've - # delegated permissions to. Here we unallow both the target but also - # on the parent dataset because at this stage we have no way of - # knowing if the allow command did execute on the parent dataset or - # not in the pre-hook. We can't run the same if in the post hook - # since the dataset should have been created at this point. - buildUnallowCommand = - permissions: dataset: - ( - "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" '' - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS - ${lib.escapeShellArgs [ - "/run/booted-system/sw/bin/zfs" - "unallow" - cfg.user - (lib.concatStringsSep "," permissions) - dataset - ]} - ${lib.optionalString ((builtins.dirOf dataset) != ".") ( - lib.escapeShellArgs [ - "/run/booted-system/sw/bin/zfs" - "unallow" - cfg.user - (lib.concatStringsSep "," permissions) - # Remove the last part of the path - (builtins.dirOf dataset) - ] - )} - ''}" - ); in { @@ -120,38 +48,23 @@ in ''; }; - user = lib.mkOption { - type = lib.types.str; - default = "syncoid"; - example = "backup"; - description = '' - The user for the service. ZFS privilege delegation will be - automatically configured for any local pools used by syncoid if this - option is set to a user other than root. The user will be given the - "hold" and "send" privileges on any pool that has datasets being sent - and the "create", "mount", "receive", and "rollback" privileges on - any pool that has datasets being received. - ''; - }; - - group = lib.mkOption { - type = lib.types.str; - default = "syncoid"; - example = "backup"; - description = "The group for the service."; - }; - sshKey = lib.mkOption { type = with lib.types; nullOr (coercedTo path toString str); default = null; description = '' - SSH private key file to use to login to the remote system. Can be - overridden in individual commands. + SSH private key file to use to login to the remote system. + It can be overridden in individual commands. + It is loaded using `LoadCredentialEncrypted=` + when its path is prefixed by a credential name and colon, + otherwise `LoadCredential=` is used. + For more SSH tuning, you may use syncoid's `--sshoption` + in {option}`services.syncoid.commonArgs` + and/or in the `extraArgs` of a specific command. ''; }; localSourceAllow = lib.mkOption { - type = lib.types.listOf lib.types.str; + type = with lib.types; listOf str; # Permissions snapshot and destroy are in case --no-sync-snap is not used default = [ "bookmark" @@ -162,19 +75,22 @@ in "mount" ]; description = '' - Permissions granted for the {option}`services.syncoid.user` user - for local source datasets. See - + Permissions granted for the syncoid user for local source datasets. + See for available permissions. ''; }; localTargetAllow = lib.mkOption { - type = lib.types.listOf lib.types.str; + type = with lib.types; listOf str; + # Permission destroy is required to reset broken receive states (zfs receive -A), + # which syncoid does when it fails to resume a receive state, + # when the snapshot it refers to has been destroyed on the source. default = [ "change-key" "compression" "create" + "destroy" "mount" "mountpoint" "receive" @@ -187,9 +103,8 @@ in "rollback" ]; description = '' - Permissions granted for the {option}`services.syncoid.user` user - for local target datasets. See - + Permissions granted for the syncoid user for local target datasets. + See for available permissions. Make sure to include the `change-key` permission if you send raw encrypted datasets, the `compression` permission if you send raw compressed datasets, and so on. @@ -198,7 +113,7 @@ in }; commonArgs = lib.mkOption { - type = lib.types.listOf lib.types.str; + type = with lib.types; listOf str; default = [ ]; example = [ "--no-sync-snap" ]; description = '' @@ -296,13 +211,13 @@ in ''; }; - useCommonArgs = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether to add the configured common arguments to this command. - ''; - }; + useCommonArgs = + lib.mkEnableOption '' + configured common arguments to this command + '' + // { + default = true; + }; service = lib.mkOption { type = lib.types.attrs; @@ -341,139 +256,216 @@ in # Implementation config = lib.mkIf cfg.enable { - users = { - users = lib.mkIf (cfg.user == "syncoid") { - syncoid = { - group = cfg.group; - isSystemUser = true; - # For syncoid to be able to create /var/lib/syncoid/.ssh/ - # and to use custom ssh_config or known_hosts. - home = "/var/lib/syncoid"; - createHome = false; - }; - }; - groups = lib.mkIf (cfg.group == "syncoid") { - syncoid = { }; - }; - }; - systemd.services = lib.mapAttrs' ( name: c: + let + sshKeyCred = builtins.split ":" c.sshKey; + in lib.nameValuePair "syncoid-${escapeUnitName name}" ( lib.mkMerge [ { description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; after = [ "zfs.target" ]; startAt = cfg.interval; - # syncoid may need zpool to get feature@extensible_dataset - path = [ "/run/booted-system/sw/bin/" ]; - serviceConfig = { - ExecStartPre = - (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) - ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target)); - ExecStopPost = - (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source)) - ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target)); - ExecStart = lib.escapeShellArgs ( - [ "${cfg.package}/bin/syncoid" ] - ++ lib.optionals c.useCommonArgs cfg.commonArgs - ++ lib.optional c.recursive "-r" - ++ lib.optionals (c.sshKey != null) [ - "--sshkey" - c.sshKey - ] - ++ c.extraArgs - ++ [ - "--sendoptions" - c.sendOptions - "--recvoptions" - c.recvOptions - "--no-privilege-elevation" - c.source - c.target - ] - ); - User = cfg.user; - Group = cfg.group; - StateDirectory = [ "syncoid" ]; - StateDirectoryMode = "700"; - # Prevent SSH control sockets of different syncoid services from interfering - PrivateTmp = true; - # Permissive access to /proc because syncoid - # calls ps(1) to detect ongoing `zfs receive`. - ProcSubset = "all"; - ProtectProc = "default"; + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS. + # Moreover syncoid may need zpool to get feature@extensible_dataset. + path = [ "/run/booted-system/sw" ]; + # Prevents missing snapshots during DST changes + environment.TZ = "UTC"; + # A custom LD_LIBRARY_PATH is needed to access in `getent passwd` + # the systemd's entry about the DynamicUser=, + # so that ssh won't fail with: "No user exists for uid $UID". + environment.LD_LIBRARY_PATH = config.system.nssModules.path; + serviceConfig = + { + ExecStartPre = + # Recursively remove any residual permissions + # given on local+descendant datasets (source, target or target's parent) + # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519), + # which happens when a crash has occurred + # during any previous run of a syncoid-*.service (not only this one). + map + ( + dataset: + "+" + + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" '' + set -eu + zfs allow "$1" | + sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' | + { + declare -a uids + while read -r role id; do + if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then + case "$role" in + (user) uids+=("$id");; + esac + fi + done + zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1" + } + '' + + " " + + lib.escapeShellArg dataset + ) + ( + localDatasetName c.source + ++ localDatasetName c.target + ++ map builtins.dirOf (localDatasetName c.target) + ) + ++ + # For a local source, allow the localSourceAllow ZFS permissions. + map ( + dataset: + "+/run/booted-system/sw/bin/zfs allow $USER " + + lib.escapeShellArgs [ + (lib.concatStringsSep "," c.localSourceAllow) + dataset + ] + ) (localDatasetName c.source) + ++ + # For a local target, check if the dataset exists before delegating permissions, + # and if it doesn't exist, delegate it to the parent dataset. + # This should solve the case of provisioning new datasets. + map ( + dataset: + "+" + + pkgs.writeShellScript "zfs-allow-target" '' + dataset="$1" + # Run a ZFS list on the dataset to check if it exists + zfs list "$dataset" >/dev/null 2>/dev/null || + dataset="$(dirname "$dataset")" + zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset" + '' + + " " + + lib.escapeShellArg dataset + ) (localDatasetName c.target); + ExecStopPost = + let + zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset; + in + map zfsUnallow (localDatasetName c.source) + ++ + # For a local target, unallow both the dataset and its parent, + # because at this stage we have no way of knowing if the allow command + # did execute on the parent dataset or not in the ExecStartPre=. + # We can't run the same if-then-else in the post hook + # since the dataset should have been created at this point. + lib.concatMap (dataset: [ + (zfsUnallow dataset) + (zfsUnallow (builtins.dirOf dataset)) + ]) (localDatasetName c.target); + ExecStart = lib.escapeShellArgs ( + [ "${cfg.package}/bin/syncoid" ] + ++ lib.optionals c.useCommonArgs cfg.commonArgs + ++ lib.optional c.recursive "--recursive" + ++ lib.optionals (c.sshKey != null) [ + "--sshkey" + "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}" + ] + ++ c.extraArgs + ++ [ + "--sendoptions" + c.sendOptions + "--recvoptions" + c.recvOptions + "--no-privilege-elevation" + c.source + c.target + ] + ); + DynamicUser = true; + NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids"; + # Prevent SSH control sockets of different syncoid services from interfering + PrivateTmp = true; + # Permissive access to /proc because syncoid + # calls ps(1) to detect ongoing `zfs receive`. + ProcSubset = "all"; + ProtectProc = "default"; - # The following options are only for optimizing: - # systemd-analyze security | grep syncoid-'*' - AmbientCapabilities = ""; - CapabilityBoundingSet = ""; - DeviceAllow = [ "/dev/zfs" ]; - LockPersonality = true; - MemoryDenyWriteExecute = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateMounts = true; - PrivateNetwork = lib.mkDefault false; - PrivateUsers = false; # Enabling this breaks on zfs-2.2.0 - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectSystem = "strict"; - RemoveIPC = true; - RestrictAddressFamilies = [ - "AF_UNIX" - "AF_INET" - "AF_INET6" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - RootDirectory = "/run/syncoid/${escapeUnitName name}"; - RootDirectoryStartOnly = true; - BindPaths = [ "/dev/zfs" ]; - BindReadOnlyPaths = [ - builtins.storeDir - "/etc" - "/run" - "/bin/sh" - ]; - # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. - InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ]; - MountAPIVFS = true; - # Create RootDirectory= in the host's mount namespace. - RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; - RuntimeDirectoryMode = "700"; - SystemCallFilter = [ - "@system-service" - # Groups in @system-service which do not contain a syscall listed by: - # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … - # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log - # systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' ' - "~@aio" - "~@chown" - "~@keyring" - "~@memlock" - "~@privileged" - "~@resources" - "~@setuid" - "~@timer" - ]; - SystemCallArchitectures = "native"; - # This is for BindPaths= and BindReadOnlyPaths= - # to allow traversal of directories they create in RootDirectory=. - UMask = "0066"; - }; + # The following options are only for optimizing: + # systemd-analyze security | grep syncoid-'*' + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + DeviceAllow = [ "/dev/zfs" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = lib.mkDefault false; + PrivateUsers = false; # Enabling this breaks on zfs-2.2.0 + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RootDirectory = "/run/syncoid/${escapeUnitName name}"; + BindPaths = [ "/dev/zfs" ]; + BindReadOnlyPaths = [ + builtins.storeDir + "/etc" + "/run" + # Some programs hardcode /var/run + # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains + "/var/run" + "/bin/sh" + ]; + # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. + InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ]; + MountAPIVFS = true; + # Create RootDirectory= in the host's mount namespace. + RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; + RuntimeDirectoryMode = "700"; + SystemCallFilter = [ + "@system-service" + # Groups in @system-service which do not contain a syscall listed by: + # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … + # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log + # systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' ' + "~@aio" + "~@chown" + "~@keyring" + "~@memlock" + "~@privileged" + "~@resources" + "~@setuid" + ]; + SystemCallArchitectures = "native"; + # This is for BindPaths= and BindReadOnlyPaths= + # to allow traversal of directories they create in RootDirectory=. + UMask = "0066"; + } + // ( + if lib.length sshKeyCred > 1 then + { LoadCredentialEncrypted = [ c.sshKey ]; } + else + { LoadCredential = [ "sshKey:${c.sshKey}" ]; } + ); } cfg.service c.service ] ) ) cfg.commands; + + networking.nftables.ruleset = lib.mkBefore '' + table inet filter { + # A set containing the dynamic UIDs of the syncoid services currently active + set nixos_syncoid_uids { typeof meta skuid; } + } + ''; }; meta.maintainers = with lib.maintainers; [ diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix index e42fd54dfd..eabe88b6be 100644 --- a/nixos/tests/sanoid.nix +++ b/nixos/tests/sanoid.nix @@ -50,16 +50,26 @@ in enable = true; sshKey = "/var/lib/syncoid/id_ecdsa"; commands = { - # Sync snapshot taken by sanoid - "pool/sanoid" = { - target = "root@target:pool/sanoid"; + # Take snapshot and sync + "pool/syncoid".target = "root@target:pool/syncoid"; + + # Sync the same dataset to different targets + "pool/sanoid1" = { + source = "pool/sanoid"; + target = "root@target:pool/sanoid1"; + extraArgs = [ + "--no-sync-snap" + "--create-bookmark" + ]; + }; + "pool/sanoid2" = { + source = "pool/sanoid"; + target = "root@target:pool/sanoid2"; extraArgs = [ "--no-sync-snap" "--create-bookmark" ]; }; - # Take snapshot and sync - "pool/syncoid".target = "root@target:pool/syncoid"; # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111) "pool".target = "root@target:pool/full-pool"; @@ -94,6 +104,7 @@ in "zfs create pool/syncoid", "udevadm settle", ) + target.succeed( "mkdir /mnt", "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s", @@ -106,41 +117,44 @@ in "mkdir -m 700 -p /var/lib/syncoid", "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa", "chmod 600 /var/lib/syncoid/id_ecdsa", - "chown -R syncoid:syncoid /var/lib/syncoid/", ) - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting" - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting" - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting" + with subtest("Take snapshots with sanoid"): + source.succeed("touch /mnt/pool/sanoid/test.txt") + source.succeed("touch /mnt/pool/compat/test.txt") + source.systemctl("start --wait sanoid.service") - # Take snapshot with sanoid - source.succeed("touch /mnt/pool/sanoid/test.txt") - source.succeed("touch /mnt/pool/compat/test.txt") - source.systemctl("start --wait sanoid.service") + # Add some unused dynamic users to the stateful allow list of ZFS datasets, + # simulating a state where they remain after the system crashed, + # to check they'll be correctly removed by the syncoid services. + # Each syncoid service run from now may reuse at most one of them for itself. + source.succeed( + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool", + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid", + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid", + ) + + with subtest("sync snapshots"): + target.wait_for_open_port(22) + source.succeed("touch /mnt/pool/syncoid/test.txt") + + source.systemctl("start --wait syncoid-pool-syncoid.service") + target.succeed("cat /mnt/pool/syncoid/test.txt") + + source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service") + target.succeed("cat /mnt/pool/sanoid1/test.txt") + target.succeed("cat /mnt/pool/sanoid2/test.txt") + + source.systemctl("start --wait syncoid-pool.service") + target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]") + + source.systemctl("start --wait syncoid-pool-compat.service") + target.succeed("cat /mnt/pool/compat/test.txt") assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting" assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting" assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting" - - # Sync snapshots - target.wait_for_open_port(22) - source.succeed("touch /mnt/pool/syncoid/test.txt") - source.systemctl("start --wait syncoid-pool-sanoid.service") - target.succeed("cat /mnt/pool/sanoid/test.txt") - source.systemctl("start --wait syncoid-pool-syncoid.service") - source.systemctl("start --wait syncoid-pool-syncoid.service") - target.succeed("cat /mnt/pool/syncoid/test.txt") - assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot" - source.systemctl("start --wait syncoid-pool.service") - target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]") - - source.systemctl("start --wait syncoid-pool-compat.service") - target.succeed("cat /mnt/pool/compat/test.txt") - - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots" - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots" - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots" ''; } -- 2.47.2