diff --git a/checks/borgbackup/default.nix b/checks/borgbackup/default.nix index 9bf7aa831..c8cb0c077 100644 --- a/checks/borgbackup/default.nix +++ b/checks/borgbackup/default.nix @@ -16,7 +16,7 @@ { clanCore.machineName = "machine"; clanCore.clanDir = ./.; - clanCore.state."/etc/state" = { }; + clanCore.state.testState.folders = [ "/etc/state" ]; environment.etc.state.text = "hello world"; clan.borgbackup = { enable = true; diff --git a/clanModules/borgbackup.nix b/clanModules/borgbackup.nix index fa5550cd5..933889c6a 100644 --- a/clanModules/borgbackup.nix +++ b/clanModules/borgbackup.nix @@ -33,7 +33,7 @@ in config = lib.mkIf cfg.enable { services.borgbackup.jobs = lib.mapAttrs (_: dest: { - paths = map (state: state.folder) (lib.attrValues config.clanCore.state); + paths = lib.flatten (map (state: state.folders) (lib.attrValues config.clanCore.state)); exclude = [ "*.pyc" ]; @@ -58,16 +58,23 @@ in clanCore.backups.providers.borgbackup = { list = '' ${lib.concatMapStringsSep "\n" (dest: '' - echo listing backups for ${dest} - borg-job-${dest} list - '') cfg.destinations} + ( + export BORG_REPO=${lib.escapeShellArg dest.repo} + export BORG_RSH=${lib.escapeShellArg dest.rsh} + ${lib.getExe config.services.borgbackup.package} list + ) + '') (lib.attrValues cfg.destinations)} ''; start = '' - ${lib.concatMapStringsSep "\n" (dest: '' - systemctl start borgbackup-job-${dest} - '') cfg.destinations} + ssh ${config.clan.networking.deploymentAddress} -- ' + ${lib.concatMapStringsSep "\n" (dest: '' + systemctl start borgbackup-job-${dest.name} + '') (lib.attrValues cfg.destinations)} + ' ''; + restore = '' + ''; }; }; } diff --git a/nixosModules/clanCore/backups.nix b/nixosModules/clanCore/backups.nix index 3e905029a..61f2b2224 100644 --- a/nixosModules/clanCore/backups.nix +++ b/nixosModules/clanCore/backups.nix @@ -3,15 +3,22 @@ options.clanCore.state = lib.mkOption { default = { }; type = lib.types.attrsOf - (lib.types.submodule ({ name, ... }: { + (lib.types.submodule ({ ... }: { options = { - folder = lib.mkOption { - type = lib.types.str; - default = name; + folders = lib.mkOption { + type = lib.types.listOf lib.types.str; description = '' Folder where state resides in ''; }; + restoreScript = lib.mkOption { + type = lib.types.str; + default = ":"; + description = '' + script to restore the service after the state dir was restored from a backup + ''; + + }; }; })); }; @@ -32,10 +39,11 @@ script to list backups ''; }; - delete = lib.mkOption { + restore = lib.mkOption { type = lib.types.str; description = '' - script to delete a backup + script to restore a backup + should take an optional service name as argument ''; }; start = lib.mkOption { diff --git a/nixosModules/clanCore/zerotier/default.nix b/nixosModules/clanCore/zerotier/default.nix index 20c1394ff..2c231ccab 100644 --- a/nixosModules/clanCore/zerotier/default.nix +++ b/nixosModules/clanCore/zerotier/default.nix @@ -147,6 +147,8 @@ in --network-id "$facts/zerotier-network-id" ''; }; + clanCore.state.zerotier.folders = [ "/var/lib/zerotier-one" ]; + environment.systemPackages = [ config.clanCore.clanPkgs.zerotier-members ]; }) (lib.mkIf (config.clanCore.secretsUploadDirectory != null && !cfg.controller.enable && cfg.networkId != null) { diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 79e3db807..a595a482f 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from types import ModuleType from typing import Any -from . import config, flakes, machines, secrets, vms, webui +from . import backups, config, flakes, machines, secrets, vms, webui from .custom_logger import setup_logging from .dirs import get_clan_flake_toplevel, is_clan_flake from .ssh import cli as ssh_cli @@ -81,6 +81,11 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser: subparsers = parser.add_subparsers() + parser_backups = subparsers.add_parser( + "backups", help="manage backups of clan machines" + ) + backups.register_parser(parser_backups) + parser_flake = subparsers.add_parser( "flakes", help="create a clan flake inside the current directory" ) diff --git a/pkgs/clan-cli/clan_cli/backups/__init__.py b/pkgs/clan-cli/clan_cli/backups/__init__.py new file mode 100644 index 000000000..b060d45ea --- /dev/null +++ b/pkgs/clan-cli/clan_cli/backups/__init__.py @@ -0,0 +1,25 @@ +# !/usr/bin/env python3 +import argparse + +from .create import register_create_parser +from .list import register_list_parser +from .restore import register_restore_parser + + +# takes a (sub)parser and configures it +def register_parser(parser: argparse.ArgumentParser) -> None: + subparser = parser.add_subparsers( + title="command", + description="the command to run", + help="the command to run", + required=True, + ) + + list_parser = subparser.add_parser("list", help="list backups") + register_list_parser(list_parser) + + create_parser = subparser.add_parser("create", help="create backups") + register_create_parser(create_parser) + + restore_parser = subparser.add_parser("restore", help="restore backups") + register_restore_parser(restore_parser) diff --git a/pkgs/clan-cli/clan_cli/backups/create.py b/pkgs/clan-cli/clan_cli/backups/create.py new file mode 100644 index 000000000..5bbe5e8a8 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/backups/create.py @@ -0,0 +1,45 @@ +import argparse +import json +import subprocess +from typing import Optional + +from ..errors import ClanError +from ..machines.machines import Machine + + +def create_backup(machine: Machine, provider: Optional[str] = None) -> None: + backup_scripts = json.loads( + machine.eval_nix(f"nixosConfigurations.{machine.name}.config.clanCore.backups") + ) + if provider is None: + for provider in backup_scripts["providers"]: + proc = subprocess.run( + ["bash", "-c", backup_scripts["providers"][provider]["start"]], + ) + if proc.returncode != 0: + raise ClanError("failed to start backup") + else: + print("successfully started backup") + else: + if provider not in backup_scripts["providers"]: + raise ClanError(f"provider {provider} not found") + proc = subprocess.run( + ["bash", "-c", backup_scripts["providers"][provider]["start"]], + ) + if proc.returncode != 0: + raise ClanError("failed to start backup") + else: + print("successfully started backup") + + +def create_command(args: argparse.Namespace) -> None: + machine = Machine(name=args.machine, flake_dir=args.flake) + create_backup(machine=machine, provider=args.provider) + + +def register_create_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "machine", type=str, help="machine in the flake to create backups of" + ) + parser.add_argument("--provider", type=str, help="backup provider to use") + parser.set_defaults(func=create_command) diff --git a/pkgs/clan-cli/clan_cli/backups/list.py b/pkgs/clan-cli/clan_cli/backups/list.py new file mode 100644 index 000000000..c7fe84579 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/backups/list.py @@ -0,0 +1,63 @@ +import argparse +import pprint +from pathlib import Path +from typing import Optional + +from ..errors import ClanError + + +def list_backups( + flake_dir: Path, machine: str, provider: Optional[str] = None +) -> dict[str, dict[str, list[dict[str, str]]]]: + dummy_data = { + "testhostname": { + "borgbackup": [ + {"date": "2021-01-01T00:00:00Z", "id": "1"}, + {"date": "2022-01-01T00:00:00Z", "id": "2"}, + {"date": "2023-01-01T00:00:00Z", "id": "3"}, + ], + "restic": [ + {"date": "2021-01-01T00:00:00Z", "id": "1"}, + {"date": "2022-01-01T00:00:00Z", "id": "2"}, + {"date": "2023-01-01T00:00:00Z", "id": "3"}, + ], + }, + "another host": { + "borgbackup": [ + {"date": "2021-01-01T00:00:00Z", "id": "1"}, + {"date": "2022-01-01T00:00:00Z", "id": "2"}, + {"date": "2023-01-01T00:00:00Z", "id": "3"}, + ], + }, + } + + if provider is not None: + new_data = {} + for machine_ in dummy_data: + if provider in dummy_data[machine_]: + new_data[machine_] = {provider: dummy_data[machine_][provider]} + dummy_data = new_data + + if machine is None: + return dummy_data + else: + return {machine: dummy_data[machine]} + + +def list_command(args: argparse.Namespace) -> None: + if args.flake is None: + raise ClanError("Could not find clan flake toplevel directory") + backups = list_backups( + Path(args.flake), machine=args.machine, provider=args.provider + ) + if len(backups) > 0: + pp = pprint.PrettyPrinter(depth=4) + pp.pprint(backups) + + +def register_list_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "machine", type=str, help="machine in the flake to show backups of" + ) + parser.add_argument("--provider", type=str, help="backup provider to filter by") + parser.set_defaults(func=list_command) diff --git a/pkgs/clan-cli/clan_cli/backups/restore.py b/pkgs/clan-cli/clan_cli/backups/restore.py new file mode 100644 index 000000000..64102fc0a --- /dev/null +++ b/pkgs/clan-cli/clan_cli/backups/restore.py @@ -0,0 +1,42 @@ +import argparse +from pathlib import Path +from typing import Optional + +from ..errors import ClanError + + +def restore_backup( + flake_dir: Path, + machine: str, + provider: str, + backup_id: str, + service: Optional[str] = None, +) -> None: + if service is None: + print("would restore backup", machine, provider, backup_id) + else: + print( + "would restore backup", machine, provider, backup_id, "of service:", service + ) + + +def restore_command(args: argparse.Namespace) -> None: + if args.flake is None: + raise ClanError("Could not find clan flake toplevel directory") + restore_backup( + Path(args.flake), + machine=args.machine, + provider=args.provider, + backup_id=args.backup_id, + service=args.service, + ) + + +def register_restore_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "machine", type=str, help="machine in the flake to create backups of" + ) + parser.add_argument("provider", type=str, help="backup provider to use") + parser.add_argument("backup_id", type=str, help="id of the backup to restore") + parser.add_argument("--service", type=str, help="name of the service to restore") + parser.set_defaults(func=restore_command) diff --git a/pkgs/clan-cli/tests/test_backups.py b/pkgs/clan-cli/tests/test_backups.py new file mode 100644 index 000000000..4129fa70f --- /dev/null +++ b/pkgs/clan-cli/tests/test_backups.py @@ -0,0 +1,22 @@ +import logging + +from cli import Cli +from fixtures_flakes import FlakeForTest + +log = logging.getLogger(__name__) + + +def test_backups( + test_flake: FlakeForTest, +) -> None: + cli = Cli() + + cli.run( + [ + "--flake", + str(test_flake.path), + "backups", + "list", + "testhostname", + ] + )