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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
91 Whether to snapshot root on boot
92 '';
93 };
94
95 snapshotInterval = mkOption {
96 type = types.str;
97 default = "hourly";
98 description = ''
99 Snapshot interval.
100
101 The format is described in
102 {manpage}`systemd.time(7)`.
103 '';
104 };
105
106 persistentTimer = mkOption {
107 default = false;
108 type = types.bool;
109 example = true;
110 description = ''
111 Set the `persistentTimer` option for the
112 {manpage}`systemd.timer(5)`
113 which triggers the snapshot immediately if the last trigger
114 was missed (e.g. if the system was powered down).
115 '';
116 };
117
118 cleanupInterval = mkOption {
119 type = types.str;
120 default = "1d";
121 description = ''
122 Cleanup interval.
123
124 The format is described in
125 {manpage}`systemd.time(7)`.
126 '';
127 };
128
129 filters = mkOption {
130 type = types.nullOr types.lines;
131 default = null;
132 description = ''
133 Global display difference filter. See man:snapper(8) for more details.
134 '';
135 };
136
137 configs = mkOption {
138 default = { };
139 example = literalExpression ''
140 {
141 home = {
142 SUBVOLUME = "/home";
143 ALLOW_USERS = [ "alice" ];
144 TIMELINE_CREATE = true;
145 TIMELINE_CLEANUP = true;
146 };
147 }
148 '';
149
150 description = ''
151 Subvolume configuration. Any option mentioned in man:snapper-configs(5)
152 is valid here, even if NixOS doesn't document it.
153 '';
154
155 type = types.attrsOf (types.submodule {
156 freeformType = types.attrsOf (types.oneOf [ (types.listOf safeStr) types.bool safeStr types.number ]);
157
158 options = configOptions;
159 });
160 };
161 };
162
163 config = mkIf (cfg.configs != {}) (let
164 documentation = [ "man:snapper(8)" "man:snapper-configs(5)" ];
165 in {
166
167 environment = {
168
169 systemPackages = [ pkgs.snapper ];
170
171 # Note: snapper/config-templates/default is only needed for create-config
172 # which is not the NixOS way to configure.
173 etc = {
174
175 "sysconfig/snapper".text = ''
176 SNAPPER_CONFIGS="${lib.concatStringsSep " " (builtins.attrNames cfg.configs)}"
177 '';
178
179 }
180 // (mapAttrs' (name: subvolume: nameValuePair "snapper/configs/${name}" ({
181 text = lib.generators.toKeyValue { inherit mkKeyValue; } (filterAttrs (k: v: v != defaultOf k) subvolume);
182 })) cfg.configs)
183 // (lib.optionalAttrs (cfg.filters != null) {
184 "snapper/filters/default.txt".text = cfg.filters;
185 });
186
187 };
188
189 services.dbus.packages = [ pkgs.snapper ];
190
191 systemd.services.snapperd = {
192 description = "DBus interface for snapper";
193 inherit documentation;
194 serviceConfig = {
195 Type = "dbus";
196 BusName = "org.opensuse.Snapper";
197 ExecStart = "${pkgs.snapper}/bin/snapperd";
198 CapabilityBoundingSet = "CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_SYS_ADMIN CAP_SYS_MODULE CAP_IPC_LOCK CAP_SYS_NICE";
199 LockPersonality = true;
200 NoNewPrivileges = false;
201 PrivateNetwork = true;
202 ProtectHostname = true;
203 RestrictAddressFamilies = "AF_UNIX";
204 RestrictRealtime = true;
205 };
206 };
207
208 systemd.services.snapper-timeline = {
209 description = "Timeline of Snapper Snapshots";
210 inherit documentation;
211 requires = [ "local-fs.target" ];
212 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --timeline";
213 };
214
215 systemd.timers.snapper-timeline = {
216 wantedBy = [ "timers.target" ];
217 timerConfig = {
218 Persistent = cfg.persistentTimer;
219 OnCalendar = cfg.snapshotInterval;
220 };
221 };
222
223 systemd.services.snapper-cleanup = {
224 description = "Cleanup of Snapper Snapshots";
225 inherit documentation;
226 serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --cleanup";
227 };
228
229 systemd.timers.snapper-cleanup = {
230 description = "Cleanup of Snapper Snapshots";
231 inherit documentation;
232 wantedBy = [ "timers.target" ];
233 requires = [ "local-fs.target" ];
234 timerConfig.OnBootSec = "10m";
235 timerConfig.OnUnitActiveSec = cfg.cleanupInterval;
236 };
237
238 systemd.services.snapper-boot = lib.optionalAttrs cfg.snapshotRootOnBoot {
239 description = "Take snapper snapshot of root on boot";
240 inherit documentation;
241 serviceConfig.ExecStart = "${pkgs.snapper}/bin/snapper --config root create --cleanup-algorithm number --description boot";
242 serviceConfig.Type = "oneshot";
243 requires = [ "local-fs.target" ];
244 wantedBy = [ "multi-user.target" ];
245 unitConfig.ConditionPathExists = "/etc/snapper/configs/root";
246 };
247
248 assertions =
249 concatMap
250 (name:
251 let
252 sub = cfg.configs.${name};
253 in
254 [ { assertion = !(sub ? extraConfig);
255 message = ''
256 The option definition `services.snapper.configs.${name}.extraConfig' no longer has any effect; please remove it.
257 The contents of this option should be migrated to attributes on `services.snapper.configs.${name}'.
258 '';
259 }
260 ] ++
261 map
262 (attr: {
263 assertion = !(hasAttr attr sub);
264 message = ''
265 The option definition `services.snapper.configs.${name}.${attr}' has been renamed to `services.snapper.configs.${name}.${toUpper attr}'.
266 '';
267 })
268 [ "fstype" "subvolume" ]
269 )
270 (attrNames cfg.configs);
271 });
272}