at 25.11-pre 8.3 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 { 167 description = "backup files with duplicity"; 168 169 environment.HOME = stateDirectory; 170 171 script = 172 let 173 target = lib.escapeShellArg cfg.targetUrl; 174 extra = lib.escapeShellArgs ( 175 [ 176 "--archive-dir" 177 stateDirectory 178 ] 179 ++ cfg.extraFlags 180 ); 181 dup = "${pkgs.duplicity}/bin/duplicity"; 182 in 183 '' 184 set -x 185 ${dup} cleanup ${target} --force ${extra} 186 ${lib.optionalString ( 187 cfg.cleanup.maxAge != null 188 ) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"} 189 ${lib.optionalString ( 190 cfg.cleanup.maxFull != null 191 ) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"} 192 ${lib.optionalString ( 193 cfg.cleanup.maxIncr != null 194 ) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"} 195 exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${ 196 lib.escapeShellArgs ( 197 [ 198 cfg.root 199 cfg.targetUrl 200 ] 201 ++ lib.optionals (cfg.includeFileList != null) [ 202 "--include-filelist" 203 cfg.includeFileList 204 ] 205 ++ lib.optionals (cfg.excludeFileList != null) [ 206 "--exclude-filelist" 207 cfg.excludeFileList 208 ] 209 ++ lib.concatMap (p: [ 210 "--include" 211 p 212 ]) cfg.include 213 ++ lib.concatMap (p: [ 214 "--exclude" 215 p 216 ]) cfg.exclude 217 ++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [ 218 "--full-if-older-than" 219 cfg.fullIfOlderThan 220 ]) 221 ) 222 } ${extra} 223 ''; 224 serviceConfig = 225 { 226 PrivateTmp = true; 227 ProtectSystem = "strict"; 228 ProtectHome = "read-only"; 229 StateDirectory = baseNameOf stateDirectory; 230 } 231 // lib.optionalAttrs (localTarget != null) { 232 ReadWritePaths = localTarget; 233 } 234 // lib.optionalAttrs (cfg.secretFile != null) { 235 EnvironmentFile = cfg.secretFile; 236 }; 237 } 238 // lib.optionalAttrs (cfg.frequency != null) { 239 startAt = cfg.frequency; 240 }; 241 242 tmpfiles.rules = lib.optional (localTarget != null) "d ${localTarget} 0700 root root -"; 243 }; 244 245 assertions = lib.singleton { 246 # Duplicity will fail if the last file selection option is an include. It 247 # is not always possible to detect but this simple case can be caught. 248 assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ]; 249 message = '' 250 Duplicity will fail if you only specify included paths ("Because the 251 default is to include all files, the expression is redundant. Exiting 252 because this probably isn't what you meant.") 253 ''; 254 }; 255 }; 256}