1 From 0e837ecd5ffb5e8366a5e2878838ba691b31cd99 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/2] nixos/syncoid: use DynamicUser=
6 HistoryNote: the following changes
7 were previously in separate commits
8 but have been squashed and nixfmt-rfc-style
9 on be able to rebase them
10 onto a treewide change applying nixfmt-rfc-style
11 on nixos/modules/services/backup/syncoid.nix
12 which introduced multiple untrackable conflicts.
16 - nixos/syncoid: add destroy to localTargetAllow's default
18 syncoid[3282027]: Resuming interrupted zfs send/receive from rpool/var/mail to losurdo/backup/mermet/var/mail (~ UNKNOWN remaining):
19 syncoid[3282885]: cannot resume send: 'rpool/var/mail@autosnap_2021-10-17_15:00:19_hourly' used in the initial send no longer exists
20 syncoid[3282897]: cannot receive: failed to read from stream
21 syncoid[3282027]: WARN: resetting partially receive state because the snapshot source no longer exists
22 syncoid[3283326]: cannot destroy 'losurdo/backup/mermet/var/mail/%recv': permission denied
24 - nixos/syncoid: allow @timer syscalls
26 - nixos/syncoid: set TZ envvar
28 - nixos/syncoid: test sending to multiple targets
30 - nixos/syncoid: don't delegate permissions to source's parent
32 See https://github.com/NixOS/nixpkgs/pull/165204
33 and https://github.com/NixOS/nixpkgs/pull/180111
35 - nixos/syncoid: add support for LoadCredentialEncrypted=
37 - nixos/syncoid: zfs-unallow unused dynamic users
39 - nixos/syncoid: use NFTSet=
41 Changes between the treewide nixfmt-rfc-style
42 and those changes have been preserved:
44 - 71967c47e54c56557c0ee7bec7fe554c018e37c4 nixos/syncoid: allow interval to be list of strings
45 - f34483be5ee2418a563545a56743b7b59c549935 nixosTests: handleTest -> runTest, batch 1
47 nixos/modules/services/backup/syncoid.nix | 454 +++++++++++-----------
48 nixos/tests/sanoid.nix | 78 ++--
49 2 files changed, 269 insertions(+), 263 deletions(-)
51 diff --git a/nixos/modules/services/backup/syncoid.nix b/nixos/modules/services/backup/syncoid.nix
52 index 8b4c59155f..7d344a1ad0 100644
53 --- a/nixos/modules/services/backup/syncoid.nix
54 +++ b/nixos/modules/services/backup/syncoid.nix
57 cfg = config.services.syncoid;
59 - # Extract local dasaset names (so no datasets containing "@")
60 + # Extract local dataset names (so no datasets containing "@")
63 lib.optionals (d != null) (
64 @@ -20,81 +20,9 @@ let
65 # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
68 - lib.concatMapStrings (s: if lib.isList s then "-" else s) (
69 + lib.concatMapStrings (s: if builtins.isList s then "-" else s) (
70 builtins.split "[^a-zA-Z0-9_.\\-]+" name
73 - # Function to build "zfs allow" commands for the filesystems we've delegated
74 - # permissions to. It also checks if the target dataset exists before
75 - # delegating permissions, if it doesn't exist we delegate it to the parent
76 - # dataset (if it exists). This should solve the case of provisoning new
79 - permissions: dataset:
81 - "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
82 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
84 - # Run a ZFS list on the dataset to check if it exists
86 - lib.escapeShellArgs [
87 - "/run/booted-system/sw/bin/zfs"
91 - } 2> /dev/null; then
92 - ${lib.escapeShellArgs [
93 - "/run/booted-system/sw/bin/zfs"
96 - (lib.concatStringsSep "," permissions)
99 - ${lib.optionalString ((builtins.dirOf dataset) != ".") ''
101 - ${lib.escapeShellArgs [
102 - "/run/booted-system/sw/bin/zfs"
105 - (lib.concatStringsSep "," permissions)
106 - # Remove the last part of the path
107 - (builtins.dirOf dataset)
114 - # Function to build "zfs unallow" commands for the filesystems we've
115 - # delegated permissions to. Here we unallow both the target but also
116 - # on the parent dataset because at this stage we have no way of
117 - # knowing if the allow command did execute on the parent dataset or
118 - # not in the pre-hook. We can't run the same if in the post hook
119 - # since the dataset should have been created at this point.
120 - buildUnallowCommand =
121 - permissions: dataset:
123 - "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
124 - # Here we explicitly use the booted system to guarantee the stable API needed by ZFS
125 - ${lib.escapeShellArgs [
126 - "/run/booted-system/sw/bin/zfs"
129 - (lib.concatStringsSep "," permissions)
132 - ${lib.optionalString ((builtins.dirOf dataset) != ".") (
133 - lib.escapeShellArgs [
134 - "/run/booted-system/sw/bin/zfs"
137 - (lib.concatStringsSep "," permissions)
138 - # Remove the last part of the path
139 - (builtins.dirOf dataset)
147 @@ -120,38 +48,23 @@ in
151 - user = lib.mkOption {
152 - type = lib.types.str;
153 - default = "syncoid";
154 - example = "backup";
156 - The user for the service. ZFS privilege delegation will be
157 - automatically configured for any local pools used by syncoid if this
158 - option is set to a user other than root. The user will be given the
159 - "hold" and "send" privileges on any pool that has datasets being sent
160 - and the "create", "mount", "receive", and "rollback" privileges on
161 - any pool that has datasets being received.
165 - group = lib.mkOption {
166 - type = lib.types.str;
167 - default = "syncoid";
168 - example = "backup";
169 - description = "The group for the service.";
172 sshKey = lib.mkOption {
173 type = with lib.types; nullOr (coercedTo path toString str);
176 - SSH private key file to use to login to the remote system. Can be
177 - overridden in individual commands.
178 + SSH private key file to use to login to the remote system.
179 + It can be overridden in individual commands.
180 + It is loaded using `LoadCredentialEncrypted=`
181 + when its path is prefixed by a credential name and colon,
182 + otherwise `LoadCredential=` is used.
183 + For more SSH tuning, you may use syncoid's `--sshoption`
184 + in {option}`services.syncoid.commonArgs`
185 + and/or in the `extraArgs` of a specific command.
189 localSourceAllow = lib.mkOption {
190 - type = lib.types.listOf lib.types.str;
191 + type = with lib.types; listOf str;
192 # Permissions snapshot and destroy are in case --no-sync-snap is not used
195 @@ -162,19 +75,22 @@ in
199 - Permissions granted for the {option}`services.syncoid.user` user
200 - for local source datasets. See
201 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
202 + Permissions granted for the syncoid user for local source datasets.
203 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
204 for available permissions.
208 localTargetAllow = lib.mkOption {
209 - type = lib.types.listOf lib.types.str;
210 + type = with lib.types; listOf str;
211 + # Permission destroy is required to reset broken receive states (zfs receive -A),
212 + # which syncoid does when it fails to resume a receive state,
213 + # when the snapshot it refers to has been destroyed on the source.
222 @@ -187,9 +103,8 @@ in
226 - Permissions granted for the {option}`services.syncoid.user` user
227 - for local target datasets. See
228 - <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
229 + Permissions granted for the syncoid user for local target datasets.
230 + See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
231 for available permissions.
232 Make sure to include the `change-key` permission if you send raw encrypted datasets,
233 the `compression` permission if you send raw compressed datasets, and so on.
234 @@ -198,7 +113,7 @@ in
237 commonArgs = lib.mkOption {
238 - type = lib.types.listOf lib.types.str;
239 + type = with lib.types; listOf str;
241 example = [ "--no-sync-snap" ];
243 @@ -296,13 +211,13 @@ in
247 - useCommonArgs = lib.mkOption {
248 - type = lib.types.bool;
251 - Whether to add the configured common arguments to this command.
255 + lib.mkEnableOption ''
256 + configured common arguments to this command
262 service = lib.mkOption {
263 type = lib.types.attrs;
264 @@ -341,139 +256,216 @@ in
267 config = lib.mkIf cfg.enable {
269 - users = lib.mkIf (cfg.user == "syncoid") {
272 - isSystemUser = true;
273 - # For syncoid to be able to create /var/lib/syncoid/.ssh/
274 - # and to use custom ssh_config or known_hosts.
275 - home = "/var/lib/syncoid";
276 - createHome = false;
279 - groups = lib.mkIf (cfg.group == "syncoid") {
284 systemd.services = lib.mapAttrs' (
287 + sshKeyCred = builtins.split ":" c.sshKey;
289 lib.nameValuePair "syncoid-${escapeUnitName name}" (
292 description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}";
293 after = [ "zfs.target" ];
294 startAt = cfg.interval;
295 - # syncoid may need zpool to get feature@extensible_dataset
296 - path = [ "/run/booted-system/sw/bin/" ];
299 - (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source))
300 - ++ (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target));
302 - (map (buildUnallowCommand c.localSourceAllow) (localDatasetName c.source))
303 - ++ (map (buildUnallowCommand c.localTargetAllow) (localDatasetName c.target));
304 - ExecStart = lib.escapeShellArgs (
305 - [ "${cfg.package}/bin/syncoid" ]
306 - ++ lib.optionals c.useCommonArgs cfg.commonArgs
307 - ++ lib.optional c.recursive "-r"
308 - ++ lib.optionals (c.sshKey != null) [
318 - "--no-privilege-elevation"
325 - StateDirectory = [ "syncoid" ];
326 - StateDirectoryMode = "700";
327 - # Prevent SSH control sockets of different syncoid services from interfering
329 - # Permissive access to /proc because syncoid
330 - # calls ps(1) to detect ongoing `zfs receive`.
331 - ProcSubset = "all";
332 - ProtectProc = "default";
333 + # Here we explicitly use the booted system to guarantee the stable API needed by ZFS.
334 + # Moreover syncoid may need zpool to get feature@extensible_dataset.
335 + path = [ "/run/booted-system/sw" ];
336 + # Prevents missing snapshots during DST changes
337 + environment.TZ = "UTC";
338 + # A custom LD_LIBRARY_PATH is needed to access in `getent passwd`
339 + # the systemd's entry about the DynamicUser=,
340 + # so that ssh won't fail with: "No user exists for uid $UID".
341 + environment.LD_LIBRARY_PATH = config.system.nssModules.path;
345 + # Recursively remove any residual permissions
346 + # given on local+descendant datasets (source, target or target's parent)
347 + # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519),
348 + # which happens when a crash has occurred
349 + # during any previous run of a syncoid-*.service (not only this one).
354 + + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" ''
357 + sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' |
360 + while read -r role id; do
361 + if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then
363 + (user) uids+=("$id");;
367 + zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1"
371 + + lib.escapeShellArg dataset
374 + localDatasetName c.source
375 + ++ localDatasetName c.target
376 + ++ map builtins.dirOf (localDatasetName c.target)
379 + # For a local source, allow the localSourceAllow ZFS permissions.
382 + "+/run/booted-system/sw/bin/zfs allow $USER "
383 + + lib.escapeShellArgs [
384 + (lib.concatStringsSep "," c.localSourceAllow)
387 + ) (localDatasetName c.source)
389 + # For a local target, check if the dataset exists before delegating permissions,
390 + # and if it doesn't exist, delegate it to the parent dataset.
391 + # This should solve the case of provisioning new datasets.
395 + + pkgs.writeShellScript "zfs-allow-target" ''
397 + # Run a ZFS list on the dataset to check if it exists
398 + zfs list "$dataset" >/dev/null 2>/dev/null ||
399 + dataset="$(dirname "$dataset")"
400 + zfs allow "$USER" ${lib.escapeShellArg (lib.concatStringsSep "," c.localTargetAllow)} "$dataset"
403 + + lib.escapeShellArg dataset
404 + ) (localDatasetName c.target);
407 + zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + lib.escapeShellArg dataset;
409 + map zfsUnallow (localDatasetName c.source)
411 + # For a local target, unallow both the dataset and its parent,
412 + # because at this stage we have no way of knowing if the allow command
413 + # did execute on the parent dataset or not in the ExecStartPre=.
414 + # We can't run the same if-then-else in the post hook
415 + # since the dataset should have been created at this point.
416 + lib.concatMap (dataset: [
417 + (zfsUnallow dataset)
418 + (zfsUnallow (builtins.dirOf dataset))
419 + ]) (localDatasetName c.target);
420 + ExecStart = lib.escapeShellArgs (
421 + [ "${cfg.package}/bin/syncoid" ]
422 + ++ lib.optionals c.useCommonArgs cfg.commonArgs
423 + ++ lib.optional c.recursive "--recursive"
424 + ++ lib.optionals (c.sshKey != null) [
426 + "\${CREDENTIALS_DIRECTORY}/${if lib.length sshKeyCred > 1 then lib.head sshKeyCred else "sshKey"}"
434 + "--no-privilege-elevation"
439 + DynamicUser = true;
440 + NFTSet = lib.optional config.networking.nftables.enable "user:inet:filter:nixos_syncoid_uids";
441 + # Prevent SSH control sockets of different syncoid services from interfering
443 + # Permissive access to /proc because syncoid
444 + # calls ps(1) to detect ongoing `zfs receive`.
445 + ProcSubset = "all";
446 + ProtectProc = "default";
448 - # The following options are only for optimizing:
449 - # systemd-analyze security | grep syncoid-'*'
450 - AmbientCapabilities = "";
451 - CapabilityBoundingSet = "";
452 - DeviceAllow = [ "/dev/zfs" ];
453 - LockPersonality = true;
454 - MemoryDenyWriteExecute = true;
455 - NoNewPrivileges = true;
456 - PrivateDevices = true;
457 - PrivateMounts = true;
458 - PrivateNetwork = lib.mkDefault false;
459 - PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
460 - ProtectClock = true;
461 - ProtectControlGroups = true;
462 - ProtectHome = true;
463 - ProtectHostname = true;
464 - ProtectKernelLogs = true;
465 - ProtectKernelModules = true;
466 - ProtectKernelTunables = true;
467 - ProtectSystem = "strict";
469 - RestrictAddressFamilies = [
474 - RestrictNamespaces = true;
475 - RestrictRealtime = true;
476 - RestrictSUIDSGID = true;
477 - RootDirectory = "/run/syncoid/${escapeUnitName name}";
478 - RootDirectoryStartOnly = true;
479 - BindPaths = [ "/dev/zfs" ];
480 - BindReadOnlyPaths = [
486 - # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
487 - InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
488 - MountAPIVFS = true;
489 - # Create RootDirectory= in the host's mount namespace.
490 - RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
491 - RuntimeDirectoryMode = "700";
492 - SystemCallFilter = [
494 - # Groups in @system-service which do not contain a syscall listed by:
495 - # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
496 - # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
497 - # 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 ' '
507 - SystemCallArchitectures = "native";
508 - # This is for BindPaths= and BindReadOnlyPaths=
509 - # to allow traversal of directories they create in RootDirectory=.
512 + # The following options are only for optimizing:
513 + # systemd-analyze security | grep syncoid-'*'
514 + AmbientCapabilities = "";
515 + CapabilityBoundingSet = "";
516 + DeviceAllow = [ "/dev/zfs" ];
517 + LockPersonality = true;
518 + MemoryDenyWriteExecute = true;
519 + NoNewPrivileges = true;
520 + PrivateDevices = true;
521 + PrivateMounts = true;
522 + PrivateNetwork = lib.mkDefault false;
523 + PrivateUsers = false; # Enabling this breaks on zfs-2.2.0
524 + ProtectClock = true;
525 + ProtectControlGroups = true;
526 + ProtectHome = true;
527 + ProtectHostname = true;
528 + ProtectKernelLogs = true;
529 + ProtectKernelModules = true;
530 + ProtectKernelTunables = true;
531 + ProtectSystem = "strict";
533 + RestrictAddressFamilies = [
538 + RestrictNamespaces = true;
539 + RestrictRealtime = true;
540 + RestrictSUIDSGID = true;
541 + RootDirectory = "/run/syncoid/${escapeUnitName name}";
542 + BindPaths = [ "/dev/zfs" ];
543 + BindReadOnlyPaths = [
547 + # Some programs hardcode /var/run
548 + # eg. /var/run/avahi-daemon/socket to resolve *.local mDNS domains
552 + # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
553 + InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ];
554 + MountAPIVFS = true;
555 + # Create RootDirectory= in the host's mount namespace.
556 + RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ];
557 + RuntimeDirectoryMode = "700";
558 + SystemCallFilter = [
560 + # Groups in @system-service which do not contain a syscall listed by:
561 + # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid …
562 + # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log
563 + # 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 ' '
572 + SystemCallArchitectures = "native";
573 + # This is for BindPaths= and BindReadOnlyPaths=
574 + # to allow traversal of directories they create in RootDirectory=.
578 + if lib.length sshKeyCred > 1 then
579 + { LoadCredentialEncrypted = [ c.sshKey ]; }
581 + { LoadCredential = [ "sshKey:${c.sshKey}" ]; }
590 + networking.nftables.ruleset = lib.mkBefore ''
591 + table inet filter {
592 + # A set containing the dynamic UIDs of the syncoid services currently active
593 + set nixos_syncoid_uids { typeof meta skuid; }
598 meta.maintainers = with lib.maintainers; [
599 diff --git a/nixos/tests/sanoid.nix b/nixos/tests/sanoid.nix
600 index e42fd54dfd..eabe88b6be 100644
601 --- a/nixos/tests/sanoid.nix
602 +++ b/nixos/tests/sanoid.nix
603 @@ -50,16 +50,26 @@ in
605 sshKey = "/var/lib/syncoid/id_ecdsa";
607 - # Sync snapshot taken by sanoid
609 - target = "root@target:pool/sanoid";
610 + # Take snapshot and sync
611 + "pool/syncoid".target = "root@target:pool/syncoid";
613 + # Sync the same dataset to different targets
615 + source = "pool/sanoid";
616 + target = "root@target:pool/sanoid1";
619 + "--create-bookmark"
623 + source = "pool/sanoid";
624 + target = "root@target:pool/sanoid2";
630 - # Take snapshot and sync
631 - "pool/syncoid".target = "root@target:pool/syncoid";
633 # Test pool without parent (regression test for https://github.com/NixOS/nixpkgs/pull/180111)
634 "pool".target = "root@target:pool/full-pool";
635 @@ -94,6 +104,7 @@ in
636 "zfs create pool/syncoid",
642 "parted --script /dev/vdb -- mklabel msdos mkpart primary 1024M -1s",
643 @@ -106,41 +117,44 @@ in
644 "mkdir -m 700 -p /var/lib/syncoid",
645 "cat '${snakeOilPrivateKey}' > /var/lib/syncoid/id_ecdsa",
646 "chmod 600 /var/lib/syncoid/id_ecdsa",
647 - "chown -R syncoid:syncoid /var/lib/syncoid/",
650 - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set before snapshotting"
651 - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set before snapshotting"
652 - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set before snapshotting"
653 + with subtest("Take snapshots with sanoid"):
654 + source.succeed("touch /mnt/pool/sanoid/test.txt")
655 + source.succeed("touch /mnt/pool/compat/test.txt")
656 + source.systemctl("start --wait sanoid.service")
658 - # Take snapshot with sanoid
659 - source.succeed("touch /mnt/pool/sanoid/test.txt")
660 - source.succeed("touch /mnt/pool/compat/test.txt")
661 - source.systemctl("start --wait sanoid.service")
662 + # Add some unused dynamic users to the stateful allow list of ZFS datasets,
663 + # simulating a state where they remain after the system crashed,
664 + # to check they'll be correctly removed by the syncoid services.
665 + # Each syncoid service run from now may reuse at most one of them for itself.
667 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool",
668 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/sanoid",
669 + "zfs allow -u $(printf %s, {61184..61200})65519 dedup pool/syncoid",
672 + with subtest("sync snapshots"):
673 + target.wait_for_open_port(22)
674 + source.succeed("touch /mnt/pool/syncoid/test.txt")
676 + source.systemctl("start --wait syncoid-pool-syncoid.service")
677 + target.succeed("cat /mnt/pool/syncoid/test.txt")
679 + source.systemctl("start --wait syncoid-pool-sanoid{1,2}.service")
680 + target.succeed("cat /mnt/pool/sanoid1/test.txt")
681 + target.succeed("cat /mnt/pool/sanoid2/test.txt")
683 + source.systemctl("start --wait syncoid-pool.service")
684 + target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
686 + source.systemctl("start --wait syncoid-pool-compat.service")
687 + target.succeed("cat /mnt/pool/compat/test.txt")
689 assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after snapshotting"
690 assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after snapshotting"
691 assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after snapshotting"
694 - target.wait_for_open_port(22)
695 - source.succeed("touch /mnt/pool/syncoid/test.txt")
696 - source.systemctl("start --wait syncoid-pool-sanoid.service")
697 - target.succeed("cat /mnt/pool/sanoid/test.txt")
698 - source.systemctl("start --wait syncoid-pool-syncoid.service")
699 - source.systemctl("start --wait syncoid-pool-syncoid.service")
700 - target.succeed("cat /mnt/pool/syncoid/test.txt")
702 assert(len(source.succeed("zfs list -H -t snapshot pool/syncoid").splitlines()) == 1), "Syncoid should only retain one sync snapshot"
704 - source.systemctl("start --wait syncoid-pool.service")
705 - target.succeed("[[ -d /mnt/pool/full-pool/syncoid ]]")
707 - source.systemctl("start --wait syncoid-pool-compat.service")
708 - target.succeed("cat /mnt/pool/compat/test.txt")
710 - assert len(source.succeed("zfs allow pool")) == 0, "Pool shouldn't have delegated permissions set after syncing snapshots"
711 - assert len(source.succeed("zfs allow pool/sanoid")) == 0, "Sanoid dataset shouldn't have delegated permissions set after syncing snapshots"
712 - assert len(source.succeed("zfs allow pool/syncoid")) == 0, "Syncoid dataset shouldn't have delegated permissions set after syncing snapshots"