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
263 "sysconfig/snapper".text = ''
264 SNAPPER_CONFIGS="${lib.concatStringsSep " " (builtins.attrNames cfg.configs)}"
265 '';
266 }
267 // (lib.mapAttrs' (
268 name: subvolume:
269 lib.nameValuePair "snapper/configs/${name}" ({
270 text = lib.generators.toKeyValue { inherit mkKeyValue; } (
271 lib.filterAttrs (k: v: v != defaultOf k) subvolume
272 );
273 })
274 ) cfg.configs)
275 // (lib.optionalAttrs (cfg.filters != null) { "snapper/filters/default.txt".text = cfg.filters; });
276 };
277
278 services.dbus.packages = [ pkgs.snapper ];
279
280 systemd.services.snapperd = {
281 description = "DBus interface for snapper";
282 inherit documentation;
283 serviceConfig = {
284 Type = "dbus";
285 BusName = "org.opensuse.Snapper";
286 ExecStart = "${pkgs.snapper}/bin/snapperd";
287 CapabilityBoundingSet = "CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_SYS_ADMIN CAP_SYS_MODULE CAP_IPC_LOCK CAP_SYS_NICE";
288 LockPersonality = true;
289 NoNewPrivileges = false;
290 PrivateNetwork = true;
291 ProtectHostname = true;
292 RestrictAddressFamilies = "AF_UNIX";
293 RestrictRealtime = true;
294 };
295 };
296
297 systemd.services.snapper-timeline = {
298 description = "Timeline of Snapper Snapshots";
299 inherit documentation;
300 requires = [ "local-fs.target" ];
301 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --timeline";
302 };
303
304 systemd.timers.snapper-timeline = {
305 wantedBy = [ "timers.target" ];
306 timerConfig = {
307 Persistent = cfg.persistentTimer;
308 OnCalendar = cfg.snapshotInterval;
309 };
310 };
311
312 systemd.services.snapper-cleanup = {
313 description = "Cleanup of Snapper Snapshots";
314 inherit documentation;
315 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --cleanup";
316 };
317
318 systemd.timers.snapper-cleanup = {
319 description = "Cleanup of Snapper Snapshots";
320 inherit documentation;
321 wantedBy = [ "timers.target" ];
322 requires = [ "local-fs.target" ];
323 timerConfig.OnBootSec = "10m";
324 timerConfig.OnUnitActiveSec = cfg.cleanupInterval;
325 };
326
327 systemd.services.snapper-boot = lib.mkIf cfg.snapshotRootOnBoot {
328 description = "Take snapper snapshot of root on boot";
329 inherit documentation;
330 serviceConfig.ExecStart = "${pkgs.snapper}/bin/snapper --config root create --cleanup-algorithm number --description boot";
331 serviceConfig.Type = "oneshot";
332 requires = [ "local-fs.target" ];
333 wantedBy = [ "multi-user.target" ];
334 unitConfig.ConditionPathExists = "/etc/snapper/configs/root";
335 };
336
337 assertions = lib.concatMap (
338 name:
339 let
340 sub = cfg.configs.${name};
341 in
342 [
343 {
344 assertion = !(sub ? extraConfig);
345 message = ''
346 The option definition `services.snapper.configs.${name}.extraConfig' no longer has any effect; please remove it.
347 The contents of this option should be migrated to attributes on `services.snapper.configs.${name}'.
348 '';
349 }
350 ]
351 ++
352 map
353 (attr: {
354 assertion = !(lib.hasAttr attr sub);
355 message = ''
356 The option definition `services.snapper.configs.${name}.${attr}' has been renamed to `services.snapper.configs.${name}.${lib.toUpper attr}'.
357 '';
358 })
359 [
360 "fstype"
361 "subvolume"
362 ]
363 ) (lib.attrNames cfg.configs);
364 }
365 );
366
367 meta.maintainers = with lib.maintainers; [ Djabx ];
368}