From 941f2257d0f8ce92d006aae2af172a71ac64c3f6 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 26 May 2026 10:51:25 +0200 Subject: [PATCH] zfs: allow snapshot before activation --- CHANGELOG.md | 5 +++ docs/redirects.json | 15 +++++++++ modules/blocks/zfs.nix | 49 ++++++++++++++++++++++++++++++ modules/blocks/zfs/docs/default.md | 36 ++++++++++++++++++++++ test/blocks/zfs.nix | 43 ++++++++++++++++++++++++++ 5 files changed, 148 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb35fdb..c8958c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ Template: to include the ldap UID instead of the email but I kept the email address. This means there is no migration to do. +## New Features + +- Add `shb.zfs.snapshotBeforeActivation` option to take a ZFS snapshot of given datasets before activation. + See [the manual](https://shb.skarabox.com/blocks-zfs.html) for more details. + # v0.8.0 ## Breaking Changes diff --git a/docs/redirects.json b/docs/redirects.json index 554a819..bd8fbfb 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -1928,6 +1928,18 @@ "blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.path": [ "blocks-zfs.html#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.path" ], + "blocks-zfs-options-shb.zfs.snapshotBeforeActivation.datasets": [ + "blocks-zfs.html#blocks-zfs-options-shb.zfs.snapshotBeforeActivation.datasets" + ], + "blocks-zfs-options-shb.zfs.snapshotBeforeActivation.enable": [ + "blocks-zfs.html#blocks-zfs-options-shb.zfs.snapshotBeforeActivation.enable" + ], + "blocks-zfs-options-shb.zfs.snapshotBeforeActivation.recursive": [ + "blocks-zfs.html#blocks-zfs-options-shb.zfs.snapshotBeforeActivation.recursive" + ], + "blocks-zfs-options-shb.zfs.snapshotBeforeActivation.template": [ + "blocks-zfs.html#blocks-zfs-options-shb.zfs.snapshotBeforeActivation.template" + ], "blocks-zfs-usage": [ "blocks-zfs.html#blocks-zfs-usage" ], @@ -1937,6 +1949,9 @@ "blocks-zfs-usage-backup-files": [ "blocks-zfs.html#blocks-zfs-usage-backup-files" ], + "blocks-zfs-usage-snapshot-before-activation": [ + "blocks-zfs.html#blocks-zfs-usage-snapshot-before-activation" + ], "build-time-validation": [ "service-implementation-guide.html#build-time-validation" ], diff --git a/modules/blocks/zfs.nix b/modules/blocks/zfs.nix index 898ec6f..4e7f5d9 100644 --- a/modules/blocks/zfs.nix +++ b/modules/blocks/zfs.nix @@ -9,6 +9,12 @@ let cfg = config.shb.zfs; + + datasets = + if cfg.snapshotBeforeActivation.datasets != null then + cfg.snapshotBeforeActivation.datasets + else + config.boot.zfs.extraPools; in { imports = [ @@ -145,6 +151,37 @@ in ); }; + + snapshotBeforeActivation = { + enable = lib.mkOption { + type = lib.types.bool; + description = "Take a snapshot of all datasets before activation"; + default = false; + }; + + template = lib.mkOption { + type = lib.types.str; + description = "Bash command to generate the snapshot name. $1 is the path to the new generation."; + default = ''pre-$(date --utc '+%y%m%dT%H%M%S')-$(basename "$1")''; + }; + + datasets = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + description = '' + Defines all datasets to take a snapshot of. + + If set to `null`, the default, take the list of datasets from `config.boot.zfs.extraPools`. + + The snapshots are not recursive unless specificed in the `recursive` option. + ''; + }; + + recursive = lib.mkOption { + type = lib.types.bool; + description = "If true, take snapshots recurisevly on the given datasets."; + default = false; + }; + }; }; # The implementation is greatly inspired by https://discourse.nixos.org/t/configure-zfs-filesystems-after-install/48633/3 @@ -203,5 +240,17 @@ in mergeAttrs = lib.foldl lib.mergeAttrs { }; in mergeAttrs (lib.mapAttrsToList mkPool cfg.pools); + + system.preSwitchChecks."shb-zfs-snapshot" = lib.mkIf cfg.snapshotBeforeActivation.enable ( + '' + name="${cfg.snapshotBeforeActivation.template}" + '' + + ( + let + recursiveFlag = lib.optionalString cfg.snapshotBeforeActivation.recursive "-r"; + in + lib.concatMapStringsSep "\n" (ds: "zfs snapshot ${recursiveFlag} ${ds}@\"$name\"") datasets + ) + ); }; } diff --git a/modules/blocks/zfs/docs/default.md b/modules/blocks/zfs/docs/default.md index 7e0bc9f..93b3123 100644 --- a/modules/blocks/zfs/docs/default.md +++ b/modules/blocks/zfs/docs/default.md @@ -15,6 +15,7 @@ Defined in [`/modules/blocks/zfs.nix`](@REPO@/modules/blocks/zfs.nix): ``` This block creates ZFS datasets, optionally mounts them and sets permissions on the mount point. +It also enables taking snapshots on activation. ## Features {#blocks-zfs-features} @@ -22,9 +23,11 @@ This block creates ZFS datasets, optionally mounts them and sets permissions on - Sets permissions, [owner](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.owner), [group](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.group) and [ACL](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.defaultACLs) on the mount point. - Backup of the files in the dataset [`shb.zfs..backup`][backup] through the [backup contract](./contracts-backup.html). - Backup of the dataset itself [`shb.zfs..datasetbackup`][datasetbackup] through the [dataset backup contract](./contracts-datasetbackup.html). +- Take a snapshot [before activating or deploying][activationsnapshot] allowing a safe rollback. [backup]: #blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.backup [datasetbackup]: #blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.datasetbackup +[activationsnapshot]: #blocks-zfs-options-shb.zfs.snapshotBeforeActivation.enable ## Usage {#blocks-zfs-usage} @@ -93,6 +96,39 @@ For example, with the restic module as the file backup contract provider: See the [Restic block](blocks-restic.html) for more examples. +### Snapshot before activation {#blocks-zfs-usage-snapshot-before-activation} + +To take a snapshot of all datasets before activating a new configuration: + +```nix +{ + shb.zfs.snapshotBeforeActivation.enable = true; +} +``` + +This does not take a recursive snapshot by default, to do it recursively, do: + +```nix +{ + shb.zfs.snapshotBeforeActivation = { + enable = true; + recursive = true; + }; +} +``` + +We did not specify datasets manually, which means the datasets list comes from `boot.zfs.extraPools`. +To specify the datasets manually, you can use: + +```nix +{ + shb.zfs.snapshotBeforeActivation = { + enable = true; + datasets = [ "root" "root/var" ]; + }; +} +``` + ## Options Reference {#blocks-zfs-options} ```{=include=} options diff --git a/test/blocks/zfs.nix b/test/blocks/zfs.nix index 1098041..21c761b 100644 --- a/test/blocks/zfs.nix +++ b/test/blocks/zfs.nix @@ -63,10 +63,27 @@ group = "syncthing"; defaultACLs = "g:syncthing:rwX"; }; + + shb.zfs.snapshotBeforeActivation = { + enable = true; + recursive = true; + }; + + specialisation = { + test.configuration = { }; + test2.configuration = { + # A plus sign is not allowed as the snapshot name so it will make it fail. + shb.zfs.snapshotBeforeActivation.template = "+"; + }; + }; }; testScript = { nodes, ... }: + let + specialisations = "${nodes.machine.system.build.toplevel}/specialisation"; + switch = name: "${specialisations}/${name}/bin/switch-to-configuration test"; + in '' import difflib @@ -98,12 +115,38 @@ diff = difflib.context_diff(expect, out) raise Exception(f"Unexpected mounts:\n{"\n".join(diff)}") + def assert_count_snapshots(name, count): + out = machine.succeed("zfs list -Ht snapshot root/one") + print(out) + if count != len(out.splitlines()): + raise Exception(f"Expected {count} dataset") + machine.start(allow_reboot=True) machine.wait_for_unit("multi-user.target") assert_facl() assert_mounts() + with subtest("no snapshot yet"): + print(machine.succeed("zpool list")) + print(machine.succeed("zfs list")) + assert_count_snapshots("root/one", 0) + assert_count_snapshots("root/two", 0) + assert_count_snapshots("root/none", 0) + assert_count_snapshots("data/two", 0) + + machine.succeed("${switch "test"}") + + with subtest("snapshot created"): + print(machine.succeed("zpool list")) + print(machine.succeed("zfs list")) + assert_count_snapshots("root/one", 1) + assert_count_snapshots("root/two", 1) + assert_count_snapshots("root/none", 1) + assert_count_snapshots("data/two", 1) + + machine.fail("${switch "test2"}") + # TODO: make this work. zpool import does not work after reboot. # machine.crash() # machine.wait_for_unit("multi-user.target")