From 591d397df9e54260485dffb2f44e639660bee103 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 19 Mar 2025 12:35:38 +0000 Subject: [PATCH] Decisions/clanModules: Add example borgbackup as real world example --- decisions/01-ClanModules.md | 290 ++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) diff --git a/decisions/01-ClanModules.md b/decisions/01-ClanModules.md index d12d498f4..57bc79880 100644 --- a/decisions/01-ClanModules.md +++ b/decisions/01-ClanModules.md @@ -255,3 +255,293 @@ The following thoughts went into this: ## Iteration note We want to implement the system as described. Once we have sufficient data on real world use-cases and modules we might revisit this document along with the updated implementation. + + +## Real world example + +The following module demonstrates the idea in the example of *borgbackup*. + +```nix +{ + _class = "clan.service"; + + # Define the 'options' of 'settings' see argument of perInstance + roles.server.interface = + { lib, ... }: + { + options = lib.mkOption { + type = lib.types.str; + default = "/var/lib/borgbackup"; + description = '' + The directory where the borgbackup repositories are stored. + ''; + }; + }; + + roles.server.perInstance = + { + instanceName, + settings, + roles, + ... + }: + { + nixosModule = + { config, lib, ... }: + let + dir = config.clan.core.settings.directory; + machineDir = dir + "/vars/per-machine/"; + allClients = roles.client.machines; + in + { + # services.borgbackup is a native nixos option + config.services.borgbackup.repos = + let + borgbackupIpMachinePath = machine: machineDir + machine + "/borgbackup/borgbackup.ssh.pub/value"; + + machinesMaybeKey = builtins.map ( + machine: + let + fullPath = borgbackupIpMachinePath machine; + in + if builtins.pathExists fullPath then + machine + else + lib.warn '' + Machine ${machine} does not have a borgbackup key at ${fullPath}, + run `clan var generate ${machine}` to generate it. + '' null + ) allClients; + + machinesWithKey = lib.filter (x: x != null) machinesMaybeKey; + + hosts = builtins.map (machine: { + name = instanceName + machine; + value = { + path = "${settings.directory}/${machine}"; + authorizedKeys = [ (builtins.readFile (borgbackupIpMachinePath machine)) ]; + }; + }) machinesWithKey; + in + if (builtins.listToAttrs hosts) != [ ] then builtins.listToAttrs hosts else { }; + }; + }; + + roles.client.interface = + { lib, ... }: + { + # There might be a better interface now. This is just how clan borgbackup was configured in the 'old' way + options.destinations = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, ... }: + { + options = { + name = lib.mkOption { + type = lib.types.strMatching "^[a-zA-Z0-9._-]+$"; + default = name; + description = "the name of the backup job"; + }; + repo = lib.mkOption { + type = lib.types.str; + description = "the borgbackup repository to backup to"; + }; + rsh = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + defaultText = "ssh -i \${config.clan.core.vars.generators.borgbackup.files.\"borgbackup.ssh\".path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; + description = "the rsh to use for the backup"; + }; + }; + } + ) + ); + default = { }; + description = '' + destinations where the machine should be backed up to + ''; + }; + + options.exclude = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = [ "*.pyc" ]; + default = [ ]; + description = '' + Directories/Files to exclude from the backup. + Use * as a wildcard. + ''; + }; + }; + roles.client.perInstance = + { + instanceName, + roles, + machine, + settings, + ... + }: + { + nixosModule = + { + config, + lib, + pkgs, + ... + }: + let + allServers = roles.server.machines; + + # machineName = config.clan.core.settings.machine.name; + + # cfg = config.clan.borgbackup; + preBackupScript = '' + declare -A preCommandErrors + + ${lib.concatMapStringsSep "\n" ( + state: + lib.optionalString (state.preBackupCommand != null) '' + echo "Running pre-backup command for ${state.name}" + if ! /run/current-system/sw/bin/${state.preBackupCommand}; then + preCommandErrors["${state.name}"]=1 + fi + '' + ) (lib.attrValues config.clan.core.state)} + + if [[ ''${#preCommandErrors[@]} -gt 0 ]]; then + echo "pre-backup commands failed for the following services:" + for state in "''${!preCommandErrors[@]}"; do + echo " $state" + done + exit 1 + fi + ''; + + destinations = + let + destList = builtins.map (serverName: { + name = "${instanceName}-${serverName}"; + value = { + repo = "borg@${serverName}:/var/lib/borgbackup/${machine.name}"; + rsh = "ssh -i ${ + config.clan.core.vars.generators."borgbackup-${instanceName}".files."borgbackup.ssh".path + } -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=Yes"; + } // settings.destinations.${serverName}; + }) allServers; + in + (builtins.listToAttrs destList); + in + { + config = { + # Derived from the destinations + systemd.services = lib.mapAttrs' ( + _: dest: + lib.nameValuePair "borgbackup-job-${instanceName}-${dest.name}" { + # since borgbackup mounts the system read-only, we need to run in a ExecStartPre script, so we can generate additional files. + serviceConfig.ExecStartPre = [ + ''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}'' + ]; + } + ) destinations; + + services.borgbackup.jobs = lib.mapAttrs (_destinationName: dest: { + paths = lib.unique ( + lib.flatten (map (state: state.folders) (lib.attrValues config.clan.core.state)) + ); + exclude = settings.exclude; + repo = dest.repo; + environment.BORG_RSH = dest.rsh; + compression = "auto,zstd"; + startAt = "*-*-* 01:00:00"; + persistentTimer = true; + + encryption = { + mode = "repokey"; + passCommand = "cat ${config.clan.core.vars.generators."borgbackup-${instanceName}".files."borgbackup.repokey".path}"; + }; + + prune.keep = { + within = "1d"; # Keep all archives from the last day + daily = 7; + weekly = 4; + monthly = 0; + }; + }) destinations; + + environment.systemPackages = [ + (pkgs.writeShellApplication { + name = "borgbackup-create"; + runtimeInputs = [ config.systemd.package ]; + text = '' + ${lib.concatMapStringsSep "\n" (dest: '' + systemctl start borgbackup-job-${dest.name} + '') (lib.attrValues destinations)} + ''; + }) + (pkgs.writeShellApplication { + name = "borgbackup-list"; + runtimeInputs = [ pkgs.jq ]; + text = '' + (${ + lib.concatMapStringsSep "\n" ( + dest: + # we need yes here to skip the changed url verification + ''echo y | /run/current-system/sw/bin/borg-job-${dest.name} list --json | jq '[.archives[] | {"name": ("${dest.name}::${dest.repo}::" + .name)}]' '' + ) (lib.attrValues destinations) + }) | jq -s 'add // []' + ''; + }) + (pkgs.writeShellApplication { + name = "borgbackup-restore"; + runtimeInputs = [ pkgs.gawk ]; + text = '' + cd / + IFS=':' read -ra FOLDER <<< "''${FOLDERS-}" + job_name=$(echo "$NAME" | awk -F'::' '{print $1}') + backup_name=''${NAME#"$job_name"::} + if [[ ! -x /run/current-system/sw/bin/borg-job-"$job_name" ]]; then + echo "borg-job-$job_name not found: Backup name is invalid" >&2 + exit 1 + fi + echo y | /run/current-system/sw/bin/borg-job-"$job_name" extract "$backup_name" "''${FOLDER[@]}" + ''; + }) + ]; + # every borgbackup instance adds its own vars + clan.core.vars.generators."borgbackup-${instanceName}" = { + files."borgbackup.ssh.pub".secret = false; + files."borgbackup.ssh" = { }; + files."borgbackup.repokey" = { }; + + migrateFact = "borgbackup"; + runtimeInputs = [ + pkgs.coreutils + pkgs.openssh + pkgs.xkcdpass + ]; + script = '' + ssh-keygen -t ed25519 -N "" -f $out/borgbackup.ssh + xkcdpass -n 4 -d - > $out/borgbackup.repokey + ''; + }; + }; + }; + }; + + perMachine = { + nixosModule = + { ... }: + { + clan.core.backups.providers.borgbackup = { + list = "borgbackup-list"; + create = "borgbackup-create"; + restore = "borgbackup-restore"; + }; + }; + }; +} +``` + +## Prior-art + +- https://github.com/NixOS/nixops +- https://github.com/infinisil/nixus