aboutsummaryrefslogtreecommitdiff
path: root/apps/pgbackup.nix
diff options
context:
space:
mode:
Diffstat (limited to 'apps/pgbackup.nix')
-rw-r--r--apps/pgbackup.nix337
1 files changed, 337 insertions, 0 deletions
diff --git a/apps/pgbackup.nix b/apps/pgbackup.nix
new file mode 100644
index 0000000..3428843
--- /dev/null
+++ b/apps/pgbackup.nix
@@ -0,0 +1,337 @@
+{ config, pkgs, lib, ... }:
+let
+
+ inherit (builtins)
+ elem isBool isList isString toString ;
+ inherit (lib)
+ concatMapStringsSep concatStringsSep filter filterAttrs
+ findFirst flatten hasPrefix mapAttrsToList mkIf
+ mkOption optionalString removeSuffix ;
+ inherit (lib.types)
+ bool either enum int listOf nullOr path str submodule ;
+
+ cfg = config.nixsap.apps.pgbackup;
+ privateDir = "/run/pgbackup";
+
+ s3cmd = "${pkgs.s3cmd}/bin/s3cmd ${optionalString (cfg.s3cfg != null) "-c '${cfg.s3cfg}'"}";
+
+ gpgPubKeys = flatten [ cfg.encrypt ];
+ gpg = "${pkgs.gpg}/bin/gpg2";
+ pubring = pkgs.runCommand "pubring.gpg" {} ''
+ ${gpg} --homedir . --import ${toString gpgPubKeys}
+ cp pubring.gpg $out
+ '';
+
+ default = d: t: mkOption { type = t; default = d; };
+ optional = type: mkOption { type = nullOr type; default = null; };
+ sub = options: submodule { inherit options; } ;
+ concatMapAttrsSep = s: f: attrs: concatStringsSep s (mapAttrsToList f attrs);
+
+ command = sub
+ {
+ blobs = optional bool;
+ clean = optional bool;
+ compress = default 9 int;
+ create = optional bool;
+ data-only = optional bool;
+ dbname = optional str;
+ exclude-schema = optional (either str (listOf str));
+ exclude-table = optional (either str (listOf str));
+ exclude-table-data = optional (either str (listOf str));
+ format = default "plain" (enum ["plain" "custom" "directory" "tar"]);
+ host = optional str;
+ if-exists = optional bool;
+ inserts = optional bool;
+ jobs = default 2 int;
+ oids = optional bool;
+ port = optional int;
+ quote-all-identifiers = optional bool;
+ role = optional str;
+ schema = optional (either str (listOf str));
+ schema-only = optional bool;
+ serializable-deferrable = optional bool;
+ table = optional (either str (listOf str));
+ username = optional str;
+ };
+
+ job = o:
+ let
+ dbname = findFirst (n: n != null) cfg.user [ o.dbname o.username ];
+ name = "pg_dump"
+ + optionalString (o.host != null && o.host != "localhost") "-${o.host}"
+ + optionalString (o.port != null) "-${toString o.port}"
+ + "-${dbname}"
+ + "-${o.format}";
+
+ args = filterAttrs (n: v:
+ v != null && n != "_module"
+ && (n == "host" -> v != "localhost")
+ && (n == "jobs" -> o.format == "directory")
+ # XXX will use pigz for others:
+ && (n == "compress" -> elem o.format ["directory" "custom"])
+ ) o;
+
+ mkArg = k: v:
+ if isBool v then (optionalString v "--${k}")
+ else if isList v then concatMapStringsSep " " (i: "--${k}='${i}'") v
+ else if isString v then "--${k}='${v}'"
+ else "--${k}=${toString v}" ;
+
+ # XXX: Use the latest pg_dump:
+ pg_dump = pkgs.writeBashScript name ''
+ ${optionalString (cfg.pgpass != null) "export PGPASSFILE='${cfg.pgpass}'"}
+ exec ${pkgs.postgresql95}/bin/pg_dump \
+ ${concatMapAttrsSep " " mkArg args} \
+ "$@"
+ '';
+
+ compExt = optionalString (o.compress > 0) ".gz";
+ compPipe = optionalString (o.compress > 0)
+ "| ${pkgs.pigz}/bin/pigz -${toString o.compress} -p${toString o.jobs}";
+ suff = if o.format == "directory" then "dir.tar"
+ else if o.format == "tar" then "tar${compExt}"
+ else if o.format == "custom" then "pgdump"
+ else "pgsql${compExt}" ;
+
+ in pkgs.writeBashScript "${name}-job" ''
+ set -euo pipefail
+ cd "${cfg.dumpDir}/$DATE"
+ ${
+ if o.host != null && o.host != "localhost" then
+ "host='${o.host}'"
+ else
+ "host=$(${pkgs.nettools}/bin/hostname -f)"
+ }
+
+ dump="${dbname}@''${host}${optionalString (o.port != null) ":${toString o.port}"},$DATE.${suff}"
+ ${
+ if (gpgPubKeys != []) then
+ ''aim="$dump.gpg"''
+ else
+ ''aim="$dump"''
+ }
+
+ if ! [ -r "$aim" ]; then
+ ${
+ if o.format == "directory" then ''
+ rm -rf "$dump.tmp"
+ ${pg_dump} -f "$dump.tmp"
+ ${pkgs.gnutar}/bin/tar \
+ --owner=0 --group=0 --mode u=rwX,g=rX,o= \
+ --remove-files --transform 's,\.dir\.tar\.tmp,,' -c "$dump.tmp" -f "$dump"
+ rm -rf "$dump.tmp"
+ '' else if o.format == "custom" then ''
+ ${pg_dump} -f "$dump.tmp"
+ mv "$dump".tmp "$dump"
+ '' else ''
+ ${pg_dump} ${compPipe} > "$dump.tmp"
+ mv "$dump".tmp "$dump"
+ ''
+ }
+
+ ${optionalString (gpgPubKeys != []) ''
+ recipient=( $(${gpg} --homedir '${privateDir}/gnupg' -k --with-colons --fast-list-mode | \
+ ${pkgs.gawk}/bin/awk -F: '/^pub/{print $5}') )
+ r=( "''${recipient[@]/#/-r}" )
+ ${gpg} --homedir '${privateDir}/gnupg' --batch --no-tty --yes \
+ "''${r[@]}" --trust-model always \
+ -v -e "$dump"
+ rm -f "$dump"
+ ''}
+ else
+ echo "$aim exists. Not dumping." >&2
+ fi
+ ${optionalString (cfg.s3uri != null) ''
+ remote="${removeSuffix "/" cfg.s3uri}/$DATE/$aim"
+ if ! ${s3cmd} ls "$remote" | ${pkgs.gnugrep}/bin/grep -qF "/$aim"; then
+ ${s3cmd} put "$aim" "$remote"
+ else
+ echo "$remote exists. Not uploading." >&2
+ fi
+ ''}
+ '';
+
+ preStart = ''
+ mkdir --mode=0750 -p '${cfg.dumpDir}'
+ chown -R ${cfg.user}:${cfg.user} '${cfg.dumpDir}'
+ chmod -R u=rwX,g=rX,o= ${cfg.dumpDir}
+
+ rm -rf '${privateDir}'
+ mkdir --mode=0700 -p '${privateDir}'
+ chown ${cfg.user}:${cfg.user} '${privateDir}'
+ '';
+
+ main = pkgs.writeBashScriptBin "pgbackup" ''
+ set -euo pipefail
+ umask 0027
+ DATE=$(date --iso-8601)
+ HOME='${privateDir}'
+ PARALLEL_SHELL=${pkgs.bash}/bin/bash
+ export DATE
+ export HOME
+ export PARALLEL_SHELL
+
+ clean() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' \
+ -name '*.tmp' -exec rm -rf {} + || true
+ }
+
+ listSets() {
+ ${pkgs.findutils}/bin/find '${cfg.dumpDir}' \
+ -maxdepth 1 -mindepth 1 -type d -name '????-??-??' \
+ | sort -V
+ }
+
+ enoughStorage() {
+ local n
+ local used
+ local total
+ local avg
+ local p
+ n=$(listSets | wc -l)
+ used=$(du -x -s --block-size=1M '${cfg.dumpDir}' | cut -f1)
+ total=$(df --output=size --block-size=1M '${cfg.dumpDir}' | tail -n 1)
+ if [ "$n" -eq 0 ]; then
+ echo "no sets" >&2
+ return 0
+ fi
+
+ avg=$(( used / n ))
+ p=$(( 100 * avg * (n + 1) / total ))
+ printf "estimated storage: %d of %d MiB (%d%%, max ${toString cfg.storage}%%)\n" \
+ "$((used + avg))" "$total" "$p" >&2
+ if [ "$p" -le ${toString cfg.storage} ]; then
+ return 0
+ else
+ return 1
+ fi
+ }
+
+ clean
+
+ listSets | head -n -${toString (cfg.slots - 1)} \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+
+ while ! enoughStorage; do
+ listSets | head -n 1 \
+ | ${pkgs.findutils}/bin/xargs --no-run-if-empty rm -rfv \
+ || true
+ done
+
+ mkdir -p "${cfg.dumpDir}/$DATE"
+
+ ${optionalString (gpgPubKeys != []) ''
+ # shellcheck disable=SC2174
+ mkdir --mode=0700 -p '${privateDir}/gnupg'
+ ln -sf ${pubring} '${privateDir}/gnupg/pubring.gpg'
+ ''}
+
+ failed=0
+ log="${cfg.dumpDir}/$DATE/joblog.txt"
+
+ # shellcheck disable=SC2016
+ ${pkgs.parallel}/bin/parallel \
+ --halt-on-error 0 \
+ --joblog "$log" \
+ --jobs 50% \
+ --line-buffer \
+ --no-notice \
+ --no-run-if-empty \
+ --retries 2 \
+ --rpl '{nixbase} s:^/nix/store/[^-]+-pg_dump-(.+)-job$:$1:' \
+ --tagstr '* {nixbase}:' \
+ --timeout ${toString (6 * 60 * 60)} ::: \
+ ${concatMapStringsSep " " job cfg.pg_dump} \
+ || failed=$?
+
+ cat "$log"
+ clean
+
+ du -sh "${cfg.dumpDir}/$DATE" || true
+ exit "$failed"
+ '';
+
+ keys = filter (f: f != null && hasPrefix "/run/keys/" f) ( [cfg.pgpass cfg.s3cfg] );
+
+in {
+ options.nixsap.apps.pgbackup = {
+ user = mkOption {
+ description = "User to run as";
+ default = "pgbackup";
+ type = str;
+ };
+
+ dumpDir = mkOption {
+ description = "Directory to save dumps in";
+ default = "/pgbackup";
+ type = path;
+ };
+
+ slots = mkOption {
+ description = ''
+ How many backup sets should be kept locally.
+ However, old sets will be removed anyway if storage
+ constraints apply.
+ '';
+ default = 60;
+ type = int;
+ };
+
+ storage = mkOption {
+ description = ''
+ Percent of storage backups can occupy.
+ '';
+ default = 75;
+ type = int;
+ };
+
+ encrypt = mkOption {
+ description = "Public GPG key(s) for encrypting the dumps";
+ default = [ ];
+ type = either path (listOf path);
+ };
+
+ s3cfg = mkOption {
+ description = "s3cmd config file (secret)";
+ type = nullOr path;
+ default = null;
+ };
+
+ s3uri = mkOption {
+ description = "S3 bucket URI with prefix in s3cmd format";
+ type = nullOr str;
+ default = null;
+ example = "s3://backups/nightly";
+ };
+
+ pg_dump = mkOption {
+ description = "pg_dump commands";
+ default = [];
+ type = listOf command;
+ };
+
+ pgpass = mkOption {
+ description = "The Password File (secret)";
+ type = nullOr path;
+ default = null;
+ };
+ };
+
+ config = mkIf (cfg.pg_dump != []) {
+ nixsap.system.users.daemons = [ cfg.user ];
+ nixsap.deployment.keyrings.${cfg.user} = keys;
+ systemd.services.pgbackup = {
+ description = "PostgreSQL backup";
+ after = [ "local-fs.target" "keys.target" "network.target" ];
+ wants = [ "keys.target" ];
+ startAt = "02:00";
+ inherit preStart;
+ serviceConfig = {
+ ExecStart = "${main}/bin/pgbackup";
+ User = cfg.user;
+ PermissionsStartOnly = true;
+ };
+ };
+ };
+}