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