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