1{ config, lib, pkgs, ... }:
2
3with lib;
4let
5 cfg = config.services.duplicity;
6
7 stateDirectory = "/var/lib/duplicity";
8
9 localTarget =
10 if hasPrefix "file://" cfg.targetUrl
11 then removePrefix "file://" cfg.targetUrl else null;
12
13in
14{
15 options.services.duplicity = {
16 enable = mkEnableOption (lib.mdDoc "backups with duplicity");
17
18 root = mkOption {
19 type = types.path;
20 default = "/";
21 description = lib.mdDoc ''
22 Root directory to backup.
23 '';
24 };
25
26 include = mkOption {
27 type = types.listOf types.str;
28 default = [ ];
29 example = [ "/home" ];
30 description = lib.mdDoc ''
31 List of paths to include into the backups. See the FILE SELECTION
32 section in {manpage}`duplicity(1)` for details on the syntax.
33 '';
34 };
35
36 exclude = mkOption {
37 type = types.listOf types.str;
38 default = [ ];
39 description = lib.mdDoc ''
40 List of paths to exclude from backups. See the FILE SELECTION section in
41 {manpage}`duplicity(1)` for details on the syntax.
42 '';
43 };
44
45 targetUrl = mkOption {
46 type = types.str;
47 example = "s3://host:port/prefix";
48 description = lib.mdDoc ''
49 Target url to backup to. See the URL FORMAT section in
50 {manpage}`duplicity(1)` for supported urls.
51 '';
52 };
53
54 secretFile = mkOption {
55 type = types.nullOr types.path;
56 default = null;
57 description = lib.mdDoc ''
58 Path of a file containing secrets (gpg passphrase, access key...) in
59 the format of EnvironmentFile as described by
60 {manpage}`systemd.exec(5)`. For example:
61 ```
62 PASSPHRASE=«...»
63 AWS_ACCESS_KEY_ID=«...»
64 AWS_SECRET_ACCESS_KEY=«...»
65 ```
66 '';
67 };
68
69 frequency = mkOption {
70 type = types.nullOr types.str;
71 default = "daily";
72 description = lib.mdDoc ''
73 Run duplicity with the given frequency (see
74 {manpage}`systemd.time(7)` for the format).
75 If null, do not run automatically.
76 '';
77 };
78
79 extraFlags = mkOption {
80 type = types.listOf types.str;
81 default = [ ];
82 example = [ "--backend-retry-delay" "100" ];
83 description = lib.mdDoc ''
84 Extra command-line flags passed to duplicity. See
85 {manpage}`duplicity(1)`.
86 '';
87 };
88
89 fullIfOlderThan = mkOption {
90 type = types.str;
91 default = "never";
92 example = "1M";
93 description = lib.mdDoc ''
94 If `"never"` (the default) always do incremental
95 backups (the first backup will be a full backup, of course). If
96 `"always"` always do full backups. Otherwise, this
97 must be a string representing a duration. Full backups will be made
98 when the latest full backup is older than this duration. If this is not
99 the case, an incremental backup is performed.
100 '';
101 };
102
103 cleanup = {
104 maxAge = mkOption {
105 type = types.nullOr types.str;
106 default = null;
107 example = "6M";
108 description = lib.mdDoc ''
109 If non-null, delete all backup sets older than the given time. Old backup sets
110 will not be deleted if backup sets newer than time depend on them.
111 '';
112 };
113 maxFull = mkOption {
114 type = types.nullOr types.int;
115 default = null;
116 example = 2;
117 description = lib.mdDoc ''
118 If non-null, delete all backups sets that are older than the count:th last full
119 backup (in other words, keep the last count full backups and
120 associated incremental sets).
121 '';
122 };
123 maxIncr = mkOption {
124 type = types.nullOr types.int;
125 default = null;
126 example = 1;
127 description = lib.mdDoc ''
128 If non-null, delete incremental sets of all backups sets that are
129 older than the count:th last full backup (in other words, keep only
130 old full backups and not their increments).
131 '';
132 };
133 };
134 };
135
136 config = mkIf cfg.enable {
137 systemd = {
138 services.duplicity = {
139 description = "backup files with duplicity";
140
141 environment.HOME = stateDirectory;
142
143 script =
144 let
145 target = escapeShellArg cfg.targetUrl;
146 extra = escapeShellArgs ([ "--archive-dir" stateDirectory ] ++ cfg.extraFlags);
147 dup = "${pkgs.duplicity}/bin/duplicity";
148 in
149 ''
150 set -x
151 ${dup} cleanup ${target} --force ${extra}
152 ${lib.optionalString (cfg.cleanup.maxAge != null) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"}
153 ${lib.optionalString (cfg.cleanup.maxFull != null) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"}
154 ${lib.optionalString (cfg.cleanup.maxIncr != null) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"}
155 exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${lib.escapeShellArgs (
156 [ cfg.root cfg.targetUrl ]
157 ++ concatMap (p: [ "--include" p ]) cfg.include
158 ++ concatMap (p: [ "--exclude" p ]) cfg.exclude
159 ++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [ "--full-if-older-than" cfg.fullIfOlderThan ])
160 )} ${extra}
161 '';
162 serviceConfig = {
163 PrivateTmp = true;
164 ProtectSystem = "strict";
165 ProtectHome = "read-only";
166 StateDirectory = baseNameOf stateDirectory;
167 } // optionalAttrs (localTarget != null) {
168 ReadWritePaths = localTarget;
169 } // optionalAttrs (cfg.secretFile != null) {
170 EnvironmentFile = cfg.secretFile;
171 };
172 } // optionalAttrs (cfg.frequency != null) {
173 startAt = cfg.frequency;
174 };
175
176 tmpfiles.rules = optional (localTarget != null) "d ${localTarget} 0700 root root -";
177 };
178
179 assertions = singleton {
180 # Duplicity will fail if the last file selection option is an include. It
181 # is not always possible to detect but this simple case can be caught.
182 assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ];
183 message = ''
184 Duplicity will fail if you only specify included paths ("Because the
185 default is to include all files, the expression is redundant. Exiting
186 because this probably isn't what you meant.")
187 '';
188 };
189 };
190}