1 From c911e43e341b4c0963012afa6671d508732bf165 Mon Sep 17 00:00:00 2001
2 From: Julien Moutinho <julm+nixpkgs@sourcephile.fr>
3 Date: Sat, 27 Nov 2021 02:50:39 +0100
4 Subject: [PATCH 2/3] nixos/syncoid: use DynamicUser=
6 The following changes were previously in separate commits
7 but have been squashed to be able to rebase them
8 onto a treewide change applying nixfmt-rfc-style
9 on nixos/modules/services/backup/syncoid.nix
10 which introduced multiple untrackable conflicts.
14 - nixos/syncoid: add destroy to localTargetAllow's default
16 syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining):
17 syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists
18 syncoid[3282897]: cannot receive: failed to read from stream
19 syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists
20 syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied
22 - nixos/syncoid: allow @timer syscalls
24 - nixos/syncoid: set TZ envvar
26 - nixos/syncoid: test sending to multiple targets
28 - nixos/syncoid: don't delegate permissions to source's parent
29 See https://github.com/NixOS/nixpkgs/pull/165204
30 and https://github.com/NixOS/nixpkgs/pull/180111
32 - nixos/syncoid: add support for LoadCredentialEncrypted=
34 - nixos/syncoid: zfs-unallow unused dynamic users
36 - nixos/syncoid: use NFTSet=
38 nixos/modules/services/backup/syncoid.nix | 463 +++++++++++-----------
39 nixos/tests/sanoid.nix | 78 ++--
40 2 files changed, 269 insertions(+), 272 deletions(-)
42 diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
43 index 8b4c59155f4d..6e92ffb707ab 100644
44 --- a/nixos/modules/services/backup/syncoid.nix
45 +++ b/nixos/modules/services/backup/syncoid.nix
48 cfg = config.services.syncoid;
50 - # Extract local dasaset names (so no datasets containing "@")
51 + # Extract local dataset names (so no datasets containing "@")
54 lib.optionals (d != null) (
55 @@ -20,81 +20,9 @@ let
56 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
59 - lib.concatMapStrings (s: if lib.isList s then "-" else s) (
60 + lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
61 builtins.split "[^a-zA-Z0-9_.\\-]+" name
64 - # Function to build "zfs allow" commands for the filesystems we've delegated
65 - # permissions to. It also checks if the target dataset exists before
66 - # delegating permissions, if it doesn't exist we delegate it to the parent
67 - # dataset (if it exists). This should solve the case of provisoning new
70 - permissions: dataset:
72 - "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
73 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
75 - # Run a ZFS list on the dataset to check if it exists
77 - lib.escapeShellArgs [
78 - "/run/booted-system/sw/bin/zfs"
82 - } 2> /dev/null; then
83 - ${lib.escapeShellArgs [
84 - "/run/booted-system/sw/bin/zfs"
87 - (lib.concatStringsSep "," permissions)
90 - ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
92 - ${lib.escapeShellArgs [
93 - "/run/booted-system/sw/bin/zfs"
96 - (lib.concatStringsSep "," permissions)
97 - # Remove the last part of the path
98 - (builtins.dirOf dataset)
105 - # Function to build "zfs unallow" commands for the filesystems we've
106 - # delegated permissions to. Here we unallow both the target but also
107 - # on the parent dataset because at this stage we have no way of
108 - # knowing if the allow command did execute on the parent dataset or
109 - # not in the pre-hook. We can't run the same if in the post hook
110 - # since the dataset should have been created at this point.
111 - buildUnallowCommand =
112 - permissions: dataset:
114 - "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
115 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
116 - ${lib.escapeShellArgs [
117 - "/run/booted-system/sw/bin/zfs"
120 - (lib.concatStringsSep "," permissions)
123 - ${lib.optionalString ((builtins.dirOf dataset) != ".") (
124 - lib.escapeShellArgs [
125 - "/run/booted-system/sw/bin/zfs"
128 - (lib.concatStringsSep "," permissions)
129 - # Remove the last part of the path
130 - (builtins.dirOf dataset)
138 @@ -106,52 +34,34 @@ in
139 package = lib.mkPackageOption pkgs "sanoid" { };
141 interval = lib.mkOption {
142 - type = with lib.types; either str (listOf str);
143 + type = lib.types.str;
145 example = "*-*-* *:15:00";
147 Run syncoid at this interval. The default is to run hourly.
149 - Must be in the format described in {manpage}`systemd.time(7)`. This is
150 - equivalent to adding a corresponding timer unit with
151 - {option}`OnCalendar` set to the value given here.
153 - Set to an empty list to avoid starting syncoid automatically.
154 + The format is described in
155 + {manpage}`systemd.time(7)`.
159 - user = lib.mkOption {
160 - type = lib.types.str;
161 - default = "syncoid";
162 - example = "backup";
164 - The user for the service. ZFS privilege delegation will be
165 - automatically configured for any local pools used by syncoid if this
166 - option is set to a user other than root. The user will be given the
167 - "hold" and "send" privileges on any pool that has datasets being sent
168 - and the "create", "mount", "receive", and "rollback" privileges on
169 - any pool that has datasets being received.
173 - group = lib.mkOption {
174 - type = lib.types.str;
175 - default = "syncoid";
176 - example = "backup";
177 - description = "The group for the service.";
180 sshKey = lib.mkOption {
181 type = with lib.types; nullOr (coercedTo path toString str);
184 - SSH private key file to use to login to the remote system. Can be
185 - overridden in individual commands.
186 + SSH private key file to use to login to the remote system.
187 + It can be overridden in individual commands.
188 + It is loaded using `LoadCredentialEncrypted=`
189 + when its path is prefixed by a credential name and colon,
190 + otherwise `LoadCredential=` is used.
191 + For more SSH tuning, you may use syncoid's `--sshoption`
192 + in {option}`services.syncoid.commonArgs`
193 + and/or in the `extraArgs` of a specific command.
197 localSourceAllow = lib.mkOption {
198 - type = lib.types.listOf lib.types.str;
199 + type = with lib.types; listOf str;
200 # Permissions snapshot and destroy are in case --no-sync-snap is not used
203 @@ -162,19 +72,22 @@ in
207 - Permissions granted for the {option}`services.syncoid.user` user
208 - for local source datasets. See
209 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
210 + Permissions granted for the syncoid user for local source datasets.
211 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
212 for available permissions.
216 localTargetAllow = lib.mkOption {
217 - type = lib.types.listOf lib.types.str;
218 + type = with lib.types; listOf str;
219 + # Permission destroy is required to reset broken receive states (zfs receive -A),
220 + # which syncoid does when it fails to resume a receive state,
221 + # when the snapshot it refers to has been destroyed on the source.
230 @@ -187,9 +100,8 @@ in
234 - Permissions granted for the {option}`services.syncoid.user` user
235 - for local target datasets. See
236 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
237 + Permissions granted for the syncoid user for local target datasets.
238 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
239 for available permissions.
240 Make sure to include the `change-key` permission if you send raw encrypted datasets,
241 the `compression` permission if you send raw compressed datasets, and so on.
242 @@ -198,7 +110,7 @@ in
245 commonArgs = lib.mkOption {
246 - type = lib.types.listOf lib.types.str;
247 + type = with lib.types; listOf str;
249 example = [ "--no-sync-snap" ];
251 @@ -296,13 +208,13 @@ in
255 - useCommonArgs = lib.mkOption {
256 - type = lib.types.bool;
259 - Whether to add the configured common arguments to this command.
263 + lib.mkEnableOption ''
264 + configured common arguments to this command
270 service = lib.mkOption {
271 type = lib.types.attrs;
272 @@ -341,139 +253,216 @@ in
275 config = lib.mkIf cfg.enable {
277 - users = lib.mkIf (cfg.user == "syncoid") {
280 - isSystemUser = true;
281 - # For syncoid to be able to create /var/lib/syncoid/.ssh/
282 - # and to use custom ssh_config or known_hosts.
283 - home = "/var/lib/syncoid";
284 - createHome = false;
287 - groups = lib.mkIf (cfg.group == "syncoid") {
292 systemd.services = lib.mapAttrs' (
295 + sshKeyCred = builtins.split ":" c.sshKey;
297 lib.nameValuePair "syncoid-${escapeUnitName name}" (
300 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
301 after = [ "zfs.target" ];
302 startAt = cfg.interval;
303 - # syncoid may need zpool to get feature@extensible_dataset
304 - path = [ "/run/booted-system/sw/bin/" ];
307 - (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
308 - ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
310 - (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
311 - ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
312 - ExecStart = lib.escapeShellArgs (
313 - [ "${cfg.package}/bin/syncoid" ]
314 - ++ lib.optionals c.useCommonArgs cfg.commonArgs
315 - ++ lib.optional c.recursive "-r"
316 - ++ lib.optionals (c.sshKey != null) [
326 - "--no-privilege-elevation"
333 - StateDirectory = [ "syncoid" ];
334 - StateDirectoryMode = "700";
335 - # Prevent SSH control sockets of different syncoid services from interfering
337 - # Permissive access to /proc because syncoid
338 - # calls ps(1) to detect ongoing `zfs receive`.
339 - ProcSubset = "all";
340 - ProtectProc = "default";
341 + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
342 + # Moreover syncoid may need zpool to get feature@extensible_dataset.
343 + path = [ "/run/booted-system/sw" ];
344 + # Prevents missing snapshots during DST changes
345 + environment.TZ = "UTC";
346 + # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
347 + # the systemd's entry about the DynamicUser=,
348 + # so that ssh won't fail with: "No user exists for uid $UID".
349 + environment.LD_LIBRARY_PATH = config.system.nssModules.path;
353 + # Recursively remove any residual permissions
354 + # given on local+descendant datasets (source, target or target's parent)
355 + # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
356 + # which happens when a crash has occurred
357 + # during any previous run of a syncoid-*.service (not only this one).
362 + + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
365 + sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
368 + while read -r role id; do
369 + if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
371 + (user) uids+=("$id");;
375 + zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
379 + + lib.escapeShellArg dataset
382 + localDatasetName c.source
383 + ++ localDatasetName c.target
384 + ++ map builtins.dirOf (localDatasetName c.target)
387 + # For a local source, allow the localSourceAllow ZFS permissions.
390 + "+/run/booted-system/sw/bin/zfs allow $USER "
391 + + lib.escapeShellArgs [
392 + (lib.concatStringsSep "," c.localSourceAllow)
395 + ) (localDatasetName c.source)
397 + # For a local target, check if the dataset exists before delegating permissions,
398 + # and if it doesn't exist, delegate it to the parent dataset.
399 + # This should solve the case of provisioning new datasets.
403 + + pkgs.writeShellScript "zfs-allow-target" ''
405 + # Run a ZFS list on the dataset to check if it exists
406 + zfs list "$dataset" >/dev/null 2>/dev/null ||
407 + dataset="$(dirname "$dataset")"
408 + zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
411 + + lib.escapeShellArg dataset
412 + ) (localDatasetName c.target);
415 + zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
417 + map zfsUnallow (localDatasetName c.source)
419 + # For a local target, unallow both the dataset and its parent,
420 + # because at this stage we have no way of knowing if the allow command
421 + # did execute on the parent dataset or not in the ExecStartPre=.
422 + # We can't run the same if-then-else in the post hook
423 + # since the dataset should have been created at this point.
424 + lib.concatMap (dataset: [
425 + (zfsUnallow dataset)
426 + (zfsUnallow (builtins.dirOf dataset))
427 + ]) (localDatasetName c.target);
428 + ExecStart = lib.escapeShellArgs (
429 + [ "${cfg.package}/bin/syncoid" ]
430 + ++ lib.optionals c.useCommonArgs cfg.commonArgs
431 + ++ lib.optional c.recursive "--recursive"
432 + ++ lib.optionals (c.sshKey != null) [
434 + "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
442 + "--no-privilege-elevation"
447 + DynamicUser = true;
448 + NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
449 + # Prevent SSH control sockets of different syncoid services from interfering
451 + # Permissive access to /proc because syncoid
452 + # calls ps(1) to detect ongoing `zfs receive`.
453 + ProcSubset = "all";
454 + ProtectProc = "default";
456 - # The following options are only for optimizing:
457 - # systemd-analyze security | grep syncoid-'*'
458 - AmbientCapabilities = "";
459 - CapabilityBoundingSet = "";
460 - DeviceAllow = [ "/dev/zfs" ];
461 - LockPersonality = true;
462 - MemoryDenyWriteExecute = true;
463 - NoNewPrivileges = true;
464 - PrivateDevices = true;
465 - PrivateMounts = true;
466 - PrivateNetwork = lib.mkDefault false;
467 - PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
468 - ProtectClock = true;
469 - ProtectControlGroups = true;
470 - ProtectHome = true;
471 - ProtectHostname = true;
472 - ProtectKernelLogs = true;
473 - ProtectKernelModules = true;
474 - ProtectKernelTunables = true;
475 - ProtectSystem = "strict";
477 - RestrictAddressFamilies = [
482 - RestrictNamespaces = true;
483 - RestrictRealtime = true;
484 - RestrictSUIDSGID = true;
485 - RootDirectory = "/run/syncoid/${escapeUnitName name}";
486 - RootDirectoryStartOnly = true;
487 - BindPaths = [ "/dev/zfs" ];
488 - BindReadOnlyPaths = [
494 - # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
495 - InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
496 - MountAPIVFS = true;
497 - # Create RootDirectory= in the host's mount namespace.
498 - RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
499 - RuntimeDirectoryMode = "700";
500 - SystemCallFilter = [
502 - # Groups in @system-service which do not contain a syscall listed by:
503 - # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
504 - # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
505 - # 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 ' '
515 - SystemCallArchitectures = "native";
516 - # This is for BindPaths= and BindReadOnlyPaths=
517 - # to allow traversal of directories they create in RootDirectory=.
520 + # The following options are only for optimizing:
521 + # systemd-analyze security | grep syncoid-'*'
522 + AmbientCapabilities = "";
523 + CapabilityBoundingSet = "";
524 + DeviceAllow = [ "/dev/zfs" ];
525 + LockPersonality = true;
526 + MemoryDenyWriteExecute = true;
527 + NoNewPrivileges = true;
528 + PrivateDevices = true;
529 + PrivateMounts = true;
530 + PrivateNetwork = lib.mkDefault false;
531 + PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
532 + ProtectClock = true;
533 + ProtectControlGroups = true;
534 + ProtectHome = true;
535 + ProtectHostname = true;
536 + ProtectKernelLogs = true;
537 + ProtectKernelModules = true;
538 + ProtectKernelTunables = true;
539 + ProtectSystem = "strict";
541 + RestrictAddressFamilies = [
546 + RestrictNamespaces = true;
547 + RestrictRealtime = true;
548 + RestrictSUIDSGID = true;
549 + RootDirectory = "/run/syncoid/${escapeUnitName name}";
550 + BindPaths = [ "/dev/zfs" ];
551 + BindReadOnlyPaths = [
555 + # Some programs hardcode /var/run
556 + # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
560 + # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
561 + InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
562 + MountAPIVFS = true;
563 + # Create RootDirectory= in the host's mount namespace.
564 + RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
565 + RuntimeDirectoryMode = "700";
566 + SystemCallFilter = [
568 + # Groups in @system-service which do not contain a syscall listed by:
569 + # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
570 + # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
571 + # 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 ' '
580 + SystemCallArchitectures = "native";
581 + # This is for BindPaths= and BindReadOnlyPaths=
582 + # to allow traversal of directories they create in RootDirectory=.
586 + if lib.length sshKeyCred > 1 then
587 + { LoadCredentialEncrypted = [ c.sshKey ]; }
589 + { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
598 + networking.nftables.ruleset = lib.mkBefore ''
599 + table inet filter {
600 + # A set containing the dynamic UIDs of the syncoid services currently active
601 + set nixos_syncoid_uids { typeof meta skuid; }
606 meta.maintainers = with lib.maintainers; [
607 diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
608 index 227a95e9471d..9f5d52c2c527 100644
609 --- a/nixos/tests/sanoid.nix
610 +++ b/nixos/tests/sanoid.nix
611 @@ -51,17 +51,21 @@ import ./make-test-python.nix (
613 sshKey = "/var/lib/syncoid/id_ecdsa";
615 - # Sync snapshot taken by sanoid
617 - target = "root@target:pool/sanoid";
620 - "--create-bookmark"
623 # Take snapshot and sync
624 "pool/syncoid".target = "root@target:pool/syncoid";
626 + # Sync the same dataset to different targets
628 + source = "pool/sanoid";
629 + target = "root@target:pool/sanoid1";
630 + extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
633 + source = "pool/sanoid";
634 + target = "root@target:pool/sanoid2";
635 + extraArgs = [ "--no-sync-snap" "--create-bookmark" ];
638 # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111)
639 "pool".target = "root@target:pool/full-pool";
641 @@ -95,6 +99,7 @@ import ./make-test-python.nix (
642 "zfs create pool/syncoid",
648 "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
649 @@ -107,42 +112,45 @@ import ./make-test-python.nix (
650 "mkdir -m 700 -p /var/lib/syncoid",
651 "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
652 "chmod 600 /var/lib/syncoid/id_ecdsa",
653 - "chown -R syncoid:syncoid /var/lib/syncoid/",
656 - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
657 - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting"
658 - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting"
659 + with subtest("Take snapshots with sanoid"):
660 + source.succeed("touch /mnt/pool/sanoid/test.txt")
661 + source.succeed("touch /mnt/pool/compat/test.txt")
662 + source.systemctl("start --wait sanoid.service")
664 - # Take snapshot with sanoid
665 - source.succeed("touch /mnt/pool/sanoid/test.txt")
666 - source.succeed("touch /mnt/pool/compat/test.txt")
667 - source.systemctl("start --wait sanoid.service")
668 + # Add some unused dynamic users to the stateful allow list of ZFS datasets,
669 + # simulating a state where they remain after the system crashed,
670 + # to check they'll be correctly removed by the syncoid services.
671 + # Each syncoid service run from now may reuse at most one of them for itself.
673 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool",
674 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid",
675 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid",
678 + with subtest("sync snapshots"):
679 + target.wait_for_open_port(22)
680 + source.succeed("touch /mnt/pool/syncoid/test.txt")
682 + source.systemctl("start --wait syncoid-pool-syncoid.service")
683 + target.succeed("cat /mnt/pool/syncoid/test.txt")
685 + source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
686 + target.succeed("cat /mnt/pool/sanoid1/test.txt")
687 + target.succeed("cat /mnt/pool/sanoid2/test.txt")
689 + source.systemctl("start --wait syncoid-pool.service")
690 + target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
692 + source.systemctl("start --wait syncoid-pool-compat.service")
693 + target.succeed("cat /mnt/pool/compat/test.txt")
695 assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting"
696 assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting"
697 assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting"
700 - target.wait_for_open_port(22)
701 - source.succeed("touch /mnt/pool/syncoid/test.txt")
702 - source.systemctl("start --wait syncoid-pool-sanoid.service")
703 - target.succeed("cat /mnt/pool/sanoid/test.txt")
704 - source.systemctl("start --wait syncoid-pool-syncoid.service")
705 - source.systemctl("start --wait syncoid-pool-syncoid.service")
706 - target.succeed("cat /mnt/pool/syncoid/test.txt")
708 assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot"
710 - source.systemctl("start --wait syncoid-pool.service")
711 - target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
713 - source.systemctl("start --wait syncoid-pool-compat.service")
714 - target.succeed("cat /mnt/pool/compat/test.txt")
716 - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots"
717 - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots"
718 - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots"