stockholm/krebs/3modules/backup.nix

237 lines
7.8 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
out = {
options.krebs.backup = api;
config = mkIf cfg.enable imp;
};
cfg = config.krebs.backup;
api = {
enable = mkEnableOption "krebs.backup" // { default = true; };
plans = mkOption {
default = {};
type = types.attrsOf (types.submodule ({ config, ... }: {
options = {
enable = mkEnableOption "krebs.backup.${config.name}" // {
default = true;
};
method = mkOption {
type = types.enum ["pull" "push"];
};
name = mkOption {
type = types.str;
default = config._module.args.name;
};
src = mkOption {
type = types.krebs.file-location;
};
dst = mkOption {
type = types.krebs.file-location;
};
startAt = mkOption {
default = "hourly";
type = with types; nullOr str; # TODO systemd.time(7)'s calendar event
};
snapshots = mkOption {
default = {
hourly = { format = "%Y-%m-%dT%H"; retain = 4; };
daily = { format = "%Y-%m-%d"; retain = 7; };
weekly = { format = "%YW%W"; retain = 4; };
monthly = { format = "%Y-%m"; retain = 12; };
yearly = { format = "%Y"; };
};
type = types.attrsOf (types.submodule {
options = {
format = mkOption {
type = types.str; # TODO date's +FORMAT
};
retain = mkOption {
type = types.nullOr types.int;
default = null; # null = retain all snapshots
};
};
});
};
};
}));
};
};
imp = {
systemd.services =
listToAttrs (map (plan: nameValuePair "backup.${plan.name}" {
# TODO if there is plan.user, then use its privkey
# TODO push destination users need a similar path
path = with pkgs; [
coreutils
gnused
openssh
rsync
utillinux
];
serviceConfig = rec {
ExecStart = start plan;
SyslogIdentifier = ExecStart.name;
Type = "oneshot";
};
} // optionalAttrs (plan.startAt != null) {
inherit (plan) startAt;
}) (filter (plan: build-host-is "pull" "dst" plan ||
build-host-is "push" "src" plan)
enabled-plans));
users.groups.backup.gid = genid "backup";
users.users.root.openssh.authorizedKeys.keys =
map (plan: getAttr plan.method {
push = plan.src.host.ssh.pubkey;
pull = plan.dst.host.ssh.pubkey;
}) (filter (plan: build-host-is "pull" "src" plan ||
build-host-is "push" "dst" plan)
enabled-plans);
};
enabled-plans = filter (getAttr "enable") (attrValues cfg.plans);
build-host-is = method: side: plan:
plan.method == method &&
config.krebs.build.host.name == plan.${side}.host.name;
start = plan: pkgs.writeDash "backup.${plan.name}" ''
set -efu
${getAttr plan.method {
push = ''
identity=${shell.escape plan.src.host.ssh.privkey.path}
src_path=${shell.escape plan.src.path}
src=$src_path
dst_user=root
dst_host=$(${fastest-address plan.dst.host})
dst_port=$(${network-ssh-port plan.dst.host "$dst_host"})
dst_path=${shell.escape plan.dst.path}
dst=$dst_user@$dst_host:$dst_path
echo "update snapshot: current; $src -> $dst" >&2
dst_shell() {
exec ssh -F /dev/null \
-i "$identity" \
''${dst_port:+-p $dst_port} \
"$dst_user@$dst_host" \
-T "$with_dst_path_lock_script"
}
'';
pull = ''
identity=${shell.escape plan.dst.host.ssh.privkey.path}
src_user=root
src_host=$(${fastest-address plan.src.host})
src_port=$(${network-ssh-port plan.src.host "$src_host"})
src_path=${shell.escape plan.src.path}
src=$src_user@$src_host:$src_path
dst_path=${shell.escape plan.dst.path}
dst=$dst_path
echo "update snapshot: current; $dst <- $src" >&2
dst_shell() {
eval "$with_dst_path_lock_script"
}
'';
}}
# Note that this only works because we trust date +%s to produce output
# that doesn't need quoting when used to generate a command string.
# TODO relax this requirement by selectively allowing to inject variables
# e.g.: ''${shell.quote "exec env NOW=''${shell.unquote "$NOW"} ..."}
with_dst_path_lock_script="exec env start_date=$(date +%s) "${shell.escape
"flock -n ${shell.escape plan.dst.path} /bin/sh"
}
rsync >&2 \
-aAXF --delete \
-e "ssh -F /dev/null -i $identity ''${dst_port:+-p $dst_port}" \
--rsync-path ${shell.escape (concatStringsSep " && " [
"mkdir -m 0700 -p ${shell.escape plan.dst.path}/current"
"exec flock -n ${shell.escape plan.dst.path} rsync"
])} \
--link-dest="$dst_path/current" \
"$src/" \
"$dst/.partial"
dst_shell < ${toFile "backup.${plan.name}.take-snapshots" ''
set -efu
: $start_date
dst=${shell.escape plan.dst.path}
mv "$dst/current" "$dst/.previous"
mv "$dst/.partial" "$dst/current"
rm -fR "$dst/.previous"
echo >&2
snapshot() {(
: $ns $format $retain
name=$(date --date="@$start_date" +"$format")
if ! test -e "$dst/$ns/$name"; then
echo >&2 "create snapshot: $ns/$name"
mkdir -m 0700 -p "$dst/$ns"
rsync >&2 \
-aAXF --delete \
--link-dest="$dst/current" \
"$dst/current/" \
"$dst/$ns/.partial.$name"
mv "$dst/$ns/.partial.$name" "$dst/$ns/$name"
echo >&2
fi
case $retain in
([0-9]*)
delete_from=$(($retain + 1))
ls -r "$dst/$ns" \
| sed -n "$delete_from,\$p" \
| while read old_name; do
echo >&2 "delete snapshot: $ns/$old_name"
rm -fR "$dst/$ns/$old_name"
done
;;
(ALL)
:
;;
esac
)}
${concatStringsSep "\n" (mapAttrsToList (ns: { format, retain, ... }:
toString (map shell.escape [
"ns=${ns}"
"format=${format}"
"retain=${if retain == null then "ALL" else toString retain}"
"snapshot"
]))
plan.snapshots)}
''}
'';
# XXX Is one ping enough to determine fastest address?
fastest-address = host: ''
{ ${pkgs.fping}/bin/fping </dev/null -a \
${concatMapStringsSep " " shell.escape
(mapAttrsToList (_: net: head net.aliases) host.nets)} \
| ${pkgs.coreutils}/bin/head -1; }
'';
# Note that we don't escape word on purpose, so we deref shell vars.
# TODO type word
network-ssh-port = host: word: ''
case ${word} in
${concatStringsSep ";;\n" (mapAttrsToList
(_: net: "(${head net.aliases}) echo ${toString net.ssh.port}")
host.nets)};;
esac
'';
in out
# TODO ionice
# TODO mail on failed push, pull
# TODO mail on missing push
# TODO don't cancel plans on activation
# also, don't hang while deploying at:
# starting the following units: backup.wu-home-xu.push.service, backup.wu-home-xu.push.timer
# TODO make sure /bku is properly mounted
# TODO make sure that secure hosts cannot backup to insecure ones
# TODO optionally only backup when src and dst are near enough :)
# TODO try using btrfs for snapshots (configurable)
# TODO warn if partial snapshots are found
# TODO warn if unknown stuff is found in dst path