1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7let
8 cfg = config.services.snapper;
9
10 mkValue =
11 v:
12 if lib.isList v then
13 "\"${
14 lib.concatMapStringsSep " " (lib.escape [
15 "\\"
16 " "
17 ]) v
18 }\""
19 else if v == true then
20 "yes"
21 else if v == false then
22 "no"
23 else if lib.isString v then
24 "\"${v}\""
25 else
26 builtins.toJSON v;
27
28 mkKeyValue = k: v: "${k}=${mkValue v}";
29
30 # "it's recommended to always specify the filesystem type" -- man snapper-configs
31 defaultOf = k: if k == "FSTYPE" then null else configOptions.${k}.default or null;
32
33 safeStr = lib.types.strMatching "[^\n\"]*" // {
34 description = "string without line breaks or quotes";
35 descriptionClass = "conjunction";
36 };
37
38 intOrNumberOrRange = lib.types.either lib.types.ints.unsigned (
39 lib.types.strMatching "[[:digit:]]+(-[[:digit:]]+)?"
40 // {
41 description = "string containing either a number or a range";
42 descriptionClass = "conjunction";
43 }
44 );
45
46 configOptions = {
47 SUBVOLUME = lib.mkOption {
48 type = lib.types.path;
49 description = ''
50 Path of the subvolume or mount point.
51 This path is a subvolume and has to contain a subvolume named
52 .snapshots.
53 See also man:snapper(8) section PERMISSIONS.
54 '';
55 };
56
57 FSTYPE = lib.mkOption {
58 type = lib.types.enum [
59 "btrfs"
60 "bcachefs"
61 ];
62 default = "btrfs";
63 description = ''
64 Filesystem type. Only btrfs is stable and tested.
65
66 bcachefs support is experimental.
67 '';
68 };
69
70 ALLOW_GROUPS = lib.mkOption {
71 type = lib.types.listOf safeStr;
72 default = [ ];
73 description = ''
74 List of groups allowed to operate with the config.
75
76 Also see the PERMISSIONS section in man:snapper(8).
77 '';
78 };
79
80 ALLOW_USERS = lib.mkOption {
81 type = lib.types.listOf safeStr;
82 default = [ ];
83 example = [ "alice" ];
84 description = ''
85 List of users allowed to operate with the config. "root" is always
86 implicitly included.
87
88 Also see the PERMISSIONS section in man:snapper(8).
89 '';
90 };
91
92 TIMELINE_CLEANUP = lib.mkOption {
93 type = lib.types.bool;
94 default = false;
95 description = ''
96 Defines whether the timeline cleanup algorithm should be run for the config.
97 '';
98 };
99
100 TIMELINE_CREATE = lib.mkOption {
101 type = lib.types.bool;
102 default = false;
103 description = ''
104 Defines whether hourly snapshots should be created.
105 '';
106 };
107
108 TIMELINE_LIMIT_HOURLY = lib.mkOption {
109 type = intOrNumberOrRange;
110 default = 10;
111 description = ''
112 Limits for timeline cleanup.
113 '';
114 };
115
116 TIMELINE_LIMIT_DAILY = lib.mkOption {
117 type = intOrNumberOrRange;
118 default = 10;
119 description = ''
120 Limits for timeline cleanup.
121 '';
122 };
123
124 TIMELINE_LIMIT_WEEKLY = lib.mkOption {
125 type = intOrNumberOrRange;
126 default = 0;
127 description = ''
128 Limits for timeline cleanup.
129 '';
130 };
131
132 TIMELINE_LIMIT_MONTHLY = lib.mkOption {
133 type = intOrNumberOrRange;
134 default = 10;
135 description = ''
136 Limits for timeline cleanup.
137 '';
138 };
139
140 TIMELINE_LIMIT_QUARTERLY = lib.mkOption {
141 type = intOrNumberOrRange;
142 default = 0;
143 description = ''
144 Limits for timeline cleanup.
145 '';
146 };
147
148 TIMELINE_LIMIT_YEARLY = lib.mkOption {
149 type = intOrNumberOrRange;
150 default = 10;
151 description = ''
152 Limits for timeline cleanup.
153 '';
154 };
155 };
156in
157
158{
159 options.services.snapper = {
160
161 snapshotRootOnBoot = lib.mkOption {
162 type = lib.types.bool;
163 default = false;
164 description = ''
165 Whether to snapshot root on boot
166 '';
167 };
168
169 snapshotInterval = lib.mkOption {
170 type = lib.types.str;
171 default = "hourly";
172 description = ''
173 Snapshot interval.
174
175 The format is described in
176 {manpage}`systemd.time(7)`.
177 '';
178 };
179
180 persistentTimer = lib.mkOption {
181 default = false;
182 type = lib.types.bool;
183 example = true;
184 description = ''
185 Set the `Persistent` option for the
186 {manpage}`systemd.timer(5)`
187 which triggers the snapshot immediately if the last trigger
188 was missed (e.g. if the system was powered down).
189 '';
190 };
191
192 cleanupInterval = lib.mkOption {
193 type = lib.types.str;
194 default = "1d";
195 description = ''
196 Cleanup interval.
197
198 The format is described in
199 {manpage}`systemd.time(7)`.
200 '';
201 };
202
203 filters = lib.mkOption {
204 type = lib.types.nullOr lib.types.lines;
205 default = null;
206 description = ''
207 Global display difference filter. See man:snapper(8) for more details.
208 '';
209 };
210
211 configs = lib.mkOption {
212 default = { };
213 example = lib.literalExpression ''
214 {
215 home = {
216 SUBVOLUME = "/home";
217 ALLOW_USERS = [ "alice" ];
218 TIMELINE_CREATE = true;
219 TIMELINE_CLEANUP = true;
220 };
221 }
222 '';
223
224 description = ''
225 Subvolume configuration. Any option mentioned in man:snapper-configs(5)
226 is valid here, even if NixOS doesn't document it.
227 '';
228
229 type = lib.types.attrsOf (
230 lib.types.submodule {
231 freeformType = lib.types.attrsOf (
232 lib.types.oneOf [
233 (lib.types.listOf safeStr)
234 lib.types.bool
235 safeStr
236 lib.types.number
237 ]
238 );
239
240 options = configOptions;
241 }
242 );
243 };
244 };
245
246 config = lib.mkIf (cfg.configs != { }) (
247 let
248 documentation = [
249 "man:snapper(8)"
250 "man:snapper-configs(5)"
251 ];
252 in
253 {
254 environment = {
255
256 systemPackages = [ pkgs.snapper ];
257
258 # Note: snapper/config-templates/default is only needed for create-config
259 # which is not the NixOS way to configure.
260 etc = {
261
262 "sysconfig/snapper".text = ''
263 SNAPPER_CONFIGS="${lib.concatStringsSep " " (builtins.attrNames cfg.configs)}"
264 '';
265 }
266 // (lib.mapAttrs' (
267 name: subvolume:
268 lib.nameValuePair "snapper/configs/${name}" ({
269 text = lib.generators.toKeyValue { inherit mkKeyValue; } (
270 lib.filterAttrs (k: v: v != defaultOf k) subvolume
271 );
272 })
273 ) cfg.configs)
274 // (lib.optionalAttrs (cfg.filters != null) { "snapper/filters/default.txt".text = cfg.filters; });
275 };
276
277 services.dbus.packages = [ pkgs.snapper ];
278
279 systemd.services.snapperd = {
280 description = "DBus interface for snapper";
281 inherit documentation;
282 serviceConfig = {
283 Type = "dbus";
284 BusName = "org.opensuse.Snapper";
285 ExecStart = "${pkgs.snapper}/bin/snapperd";
286 CapabilityBoundingSet = "CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_SYS_ADMIN CAP_SYS_MODULE CAP_IPC_LOCK CAP_SYS_NICE";
287 LockPersonality = true;
288 NoNewPrivileges = false;
289 PrivateNetwork = true;
290 ProtectHostname = true;
291 RestrictAddressFamilies = "AF_UNIX";
292 RestrictRealtime = true;
293 };
294 };
295
296 systemd.services.snapper-timeline = {
297 description = "Timeline of Snapper Snapshots";
298 inherit documentation;
299 requires = [ "local-fs.target" ];
300 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --timeline";
301 };
302
303 systemd.timers.snapper-timeline = {
304 wantedBy = [ "timers.target" ];
305 timerConfig = {
306 Persistent = cfg.persistentTimer;
307 OnCalendar = cfg.snapshotInterval;
308 };
309 };
310
311 systemd.services.snapper-cleanup = {
312 description = "Cleanup of Snapper Snapshots";
313 inherit documentation;
314 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --cleanup";
315 };
316
317 systemd.timers.snapper-cleanup = {
318 description = "Cleanup of Snapper Snapshots";
319 inherit documentation;
320 wantedBy = [ "timers.target" ];
321 requires = [ "local-fs.target" ];
322 timerConfig.OnBootSec = "10m";
323 timerConfig.OnUnitActiveSec = cfg.cleanupInterval;
324 };
325
326 systemd.services.snapper-boot = lib.mkIf cfg.snapshotRootOnBoot {
327 description = "Take snapper snapshot of root on boot";
328 inherit documentation;
329 serviceConfig.ExecStart = "${pkgs.snapper}/bin/snapper --config root create --cleanup-algorithm number --description boot";
330 serviceConfig.Type = "oneshot";
331 requires = [ "local-fs.target" ];
332 wantedBy = [ "multi-user.target" ];
333 unitConfig.ConditionPathExists = "/etc/snapper/configs/root";
334 };
335
336 assertions = lib.concatMap (
337 name:
338 let
339 sub = cfg.configs.${name};
340 in
341 [
342 {
343 assertion = !(sub ? extraConfig);
344 message = ''
345 The option definition `services.snapper.configs.${name}.extraConfig' no longer has any effect; please remove it.
346 The contents of this option should be migrated to attributes on `services.snapper.configs.${name}'.
347 '';
348 }
349 ]
350 ++
351 map
352 (attr: {
353 assertion = !(lib.hasAttr attr sub);
354 message = ''
355 The option definition `services.snapper.configs.${name}.${attr}' has been renamed to `services.snapper.configs.${name}.${lib.toUpper attr}'.
356 '';
357 })
358 [
359 "fstype"
360 "subvolume"
361 ]
362 ) (lib.attrNames cfg.configs);
363 }
364 );
365
366 meta.maintainers = with lib.maintainers; [ Djabx ];
367}