1{ config, pkgs, lib, ... }:
2
3with lib;
4
5let
6 cfg = config.services.snapper;
7
8 mkValue = v:
9 if isList v then "\"${concatMapStringsSep " " (escape [ "\\" " " ]) v}\""
10 else if v == true then "yes"
11 else if v == false then "no"
12 else if isString v then "\"${v}\""
13 else builtins.toJSON v;
14
15 mkKeyValue = k: v: "${k}=${mkValue v}";
16
17 # "it's recommended to always specify the filesystem type" -- man snapper-configs
18 defaultOf = k: if k == "FSTYPE" then null else configOptions.${k}.default or null;
19
20 safeStr = types.strMatching "[^\n\"]*" // {
21 description = "string without line breaks or quotes";
22 descriptionClass = "conjunction";
23 };
24
25 configOptions = {
26 SUBVOLUME = mkOption {
27 type = types.path;
28 description = lib.mdDoc ''
29 Path of the subvolume or mount point.
30 This path is a subvolume and has to contain a subvolume named
31 .snapshots.
32 See also man:snapper(8) section PERMISSIONS.
33 '';
34 };
35
36 FSTYPE = mkOption {
37 type = types.enum [ "btrfs" ];
38 default = "btrfs";
39 description = lib.mdDoc ''
40 Filesystem type. Only btrfs is stable and tested.
41 '';
42 };
43
44 ALLOW_GROUPS = mkOption {
45 type = types.listOf safeStr;
46 default = [];
47 description = lib.mdDoc ''
48 List of groups allowed to operate with the config.
49
50 Also see the PERMISSIONS section in man:snapper(8).
51 '';
52 };
53
54 ALLOW_USERS = mkOption {
55 type = types.listOf safeStr;
56 default = [];
57 example = [ "alice" ];
58 description = lib.mdDoc ''
59 List of users allowed to operate with the config. "root" is always
60 implicitly included.
61
62 Also see the PERMISSIONS section in man:snapper(8).
63 '';
64 };
65
66 TIMELINE_CLEANUP = mkOption {
67 type = types.bool;
68 default = false;
69 description = lib.mdDoc ''
70 Defines whether the timeline cleanup algorithm should be run for the config.
71 '';
72 };
73
74 TIMELINE_CREATE = mkOption {
75 type = types.bool;
76 default = false;
77 description = lib.mdDoc ''
78 Defines whether hourly snapshots should be created.
79 '';
80 };
81 };
82in
83
84{
85 options.services.snapper = {
86
87 snapshotRootOnBoot = mkOption {
88 type = types.bool;
89 default = false;
90 description = lib.mdDoc ''
91 Whether to snapshot root on boot
92 '';
93 };
94
95 snapshotInterval = mkOption {
96 type = types.str;
97 default = "hourly";
98 description = lib.mdDoc ''
99 Snapshot interval.
100
101 The format is described in
102 {manpage}`systemd.time(7)`.
103 '';
104 };
105
106 cleanupInterval = mkOption {
107 type = types.str;
108 default = "1d";
109 description = lib.mdDoc ''
110 Cleanup interval.
111
112 The format is described in
113 {manpage}`systemd.time(7)`.
114 '';
115 };
116
117 filters = mkOption {
118 type = types.nullOr types.lines;
119 default = null;
120 description = lib.mdDoc ''
121 Global display difference filter. See man:snapper(8) for more details.
122 '';
123 };
124
125 configs = mkOption {
126 default = { };
127 example = literalExpression ''
128 {
129 home = {
130 SUBVOLUME = "/home";
131 ALLOW_USERS = [ "alice" ];
132 TIMELINE_CREATE = true;
133 TIMELINE_CLEANUP = true;
134 };
135 }
136 '';
137
138 description = lib.mdDoc ''
139 Subvolume configuration. Any option mentioned in man:snapper-configs(5)
140 is valid here, even if NixOS doesn't document it.
141 '';
142
143 type = types.attrsOf (types.submodule {
144 freeformType = types.attrsOf (types.oneOf [ (types.listOf safeStr) types.bool safeStr types.number ]);
145
146 options = configOptions;
147 });
148 };
149 };
150
151 config = mkIf (cfg.configs != {}) (let
152 documentation = [ "man:snapper(8)" "man:snapper-configs(5)" ];
153 in {
154
155 environment = {
156
157 systemPackages = [ pkgs.snapper ];
158
159 # Note: snapper/config-templates/default is only needed for create-config
160 # which is not the NixOS way to configure.
161 etc = {
162
163 "sysconfig/snapper".text = ''
164 SNAPPER_CONFIGS="${lib.concatStringsSep " " (builtins.attrNames cfg.configs)}"
165 '';
166
167 }
168 // (mapAttrs' (name: subvolume: nameValuePair "snapper/configs/${name}" ({
169 text = lib.generators.toKeyValue { inherit mkKeyValue; } (filterAttrs (k: v: v != defaultOf k) subvolume);
170 })) cfg.configs)
171 // (lib.optionalAttrs (cfg.filters != null) {
172 "snapper/filters/default.txt".text = cfg.filters;
173 });
174
175 };
176
177 services.dbus.packages = [ pkgs.snapper ];
178
179 systemd.services.snapperd = {
180 description = "DBus interface for snapper";
181 inherit documentation;
182 serviceConfig = {
183 Type = "dbus";
184 BusName = "org.opensuse.Snapper";
185 ExecStart = "${pkgs.snapper}/bin/snapperd";
186 CapabilityBoundingSet = "CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_SYS_ADMIN CAP_SYS_MODULE CAP_IPC_LOCK CAP_SYS_NICE";
187 LockPersonality = true;
188 NoNewPrivileges = false;
189 PrivateNetwork = true;
190 ProtectHostname = true;
191 RestrictAddressFamilies = "AF_UNIX";
192 RestrictRealtime = true;
193 };
194 };
195
196 systemd.services.snapper-timeline = {
197 description = "Timeline of Snapper Snapshots";
198 inherit documentation;
199 requires = [ "local-fs.target" ];
200 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --timeline";
201 startAt = cfg.snapshotInterval;
202 };
203
204 systemd.services.snapper-cleanup = {
205 description = "Cleanup of Snapper Snapshots";
206 inherit documentation;
207 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --cleanup";
208 };
209
210 systemd.timers.snapper-cleanup = {
211 description = "Cleanup of Snapper Snapshots";
212 inherit documentation;
213 wantedBy = [ "timers.target" ];
214 requires = [ "local-fs.target" ];
215 timerConfig.OnBootSec = "10m";
216 timerConfig.OnUnitActiveSec = cfg.cleanupInterval;
217 };
218
219 systemd.services.snapper-boot = lib.optionalAttrs cfg.snapshotRootOnBoot {
220 description = "Take snapper snapshot of root on boot";
221 inherit documentation;
222 serviceConfig.ExecStart = "${pkgs.snapper}/bin/snapper --config root create --cleanup-algorithm number --description boot";
223 serviceConfig.Type = "oneshot";
224 requires = [ "local-fs.target" ];
225 wantedBy = [ "multi-user.target" ];
226 unitConfig.ConditionPathExists = "/etc/snapper/configs/root";
227 };
228
229 assertions =
230 concatMap
231 (name:
232 let
233 sub = cfg.configs.${name};
234 in
235 [ { assertion = !(sub ? extraConfig);
236 message = ''
237 The option definition `services.snapper.configs.${name}.extraConfig' no longer has any effect; please remove it.
238 The contents of this option should be migrated to attributes on `services.snapper.configs.${name}'.
239 '';
240 }
241 ] ++
242 map
243 (attr: {
244 assertion = !(hasAttr attr sub);
245 message = ''
246 The option definition `services.snapper.configs.${name}.${attr}' has been renamed to `services.snapper.configs.${name}.${toUpper attr}'.
247 '';
248 })
249 [ "fstype" "subvolume" ]
250 )
251 (attrNames cfg.configs);
252 });
253}