at 23.11-pre 6.5 kB view raw
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}