| @ -0,0 +1,26 @@ | |||
| from pathlib import Path | |||
| class LocalServer: | |||
| """ | |||
| Backup server on localhost | |||
| """ | |||
| def __init__(self, params: dict): | |||
| """ | |||
| Constructor | |||
| """ | |||
| self.path = Path(params["path"]) | |||
| def repo_path(self, repo_name: str) -> str: | |||
| """ | |||
| Returns a full path to a given repo | |||
| """ | |||
| return str( self.path / repo_name ) | |||
| @staticmethod | |||
| def is_accessible() -> bool: | |||
| """ | |||
| Tests the connection to the server. | |||
| Since this is localhost, always return True | |||
| """ | |||
| return True | |||
| @ -0,0 +1,16 @@ | |||
| """ | |||
| Custom errors | |||
| """ | |||
| from subprocess import CompletedProcess | |||
| class BorgError(RuntimeError): | |||
| """ | |||
| Exception happened during borg command | |||
| """ | |||
| def __init__( self, command: str, run: CompletedProcess ): | |||
| super().__init__(self) | |||
| self.command = command | |||
| self.stdout = str(run.stdout) | |||
| self.stderr = str(run.stderr) | |||
| @ -0,0 +1,162 @@ | |||
| """ | |||
| Declares a borg repository | |||
| """ | |||
| import subprocess | |||
| from typing import List, Optional | |||
| from datetime import datetime | |||
| import json | |||
| import yaml | |||
| from backup.server import Server | |||
| from backup.exceptions import BorgError | |||
| class Repo: | |||
| """ | |||
| Interface to a borg repository | |||
| """ | |||
| def __init__(self, name: str, dirs: List[str]): | |||
| """ | |||
| Constructor. | |||
| Parameters: | |||
| - name: name of the repo. No white spaces allowed. | |||
| - dirs: list of directories to backup | |||
| """ | |||
| self.name = name | |||
| self.dirs = dirs | |||
| self.exclude_patterns: Optional[ List[str] ] = None | |||
| self.append_only = False | |||
| self.prune = True | |||
| self.keep_daily = 7 | |||
| self.keep_weekly = 4 | |||
| self.keep_monthly = 6 | |||
| def set_exclude_patterns(self, exclude_patterns: Optional[ List[str] ]): | |||
| """ | |||
| Sets exclude patterns | |||
| """ | |||
| self.exclude_patterns = exclude_patterns | |||
| def set_append_only(self, append_only: bool): | |||
| """ | |||
| Sets (or remove) the "append_only" flag on the repo | |||
| """ | |||
| self.append_only = append_only | |||
| def set_pruning(self, | |||
| do_prune: bool, | |||
| *, | |||
| keep_daily: int = 7, | |||
| keep_weekly: int = 4, | |||
| keep_monthly: int = 6): | |||
| """ | |||
| Sets up the pruning strategy. | |||
| Parameters: | |||
| - do_prune: if True, prunes periodically the repo | |||
| - keep_daily: number of backups to keep daily (not used if do_prune is False) | |||
| - keep_weekly: number of backups to keep weekly (not used if do_prune is False) | |||
| - keep_monthly: number of backups to keep monthly (not used if do_prune is False) | |||
| """ | |||
| def exists( self, server: Server ) -> bool: | |||
| """ | |||
| Checks if the repo exists on the server | |||
| """ | |||
| repo_path = server.repo_path(self.name) | |||
| run = subprocess.run([ | |||
| "borg", | |||
| "check", | |||
| repo_path | |||
| ], check=False) | |||
| return run.returncode == 0 | |||
| def init_or_config( self, server: Server ): | |||
| """ | |||
| Inits the repository (if not already done), | |||
| changes its configuration otherwise | |||
| """ | |||
| repo_path = server.repo_path(self.name) | |||
| if not self.exists( server ): | |||
| run = subprocess.run([ | |||
| "borg", "init", | |||
| repo_path, | |||
| "--encryption", "none" | |||
| ], check=False, capture_output=True) | |||
| if run.returncode != 0: | |||
| raise BorgError("init", run) | |||
| # configure append_only | |||
| run = subprocess.run([ | |||
| "borg", "config", | |||
| repo_path, | |||
| "append_only", ("1" if self.append_only else "0") | |||
| ], check=False, capture_output=True) | |||
| if run.returncode != 0: | |||
| raise BorgError("config (append_only)", run) | |||
| def backup( self, server: Server ) -> dict: | |||
| """ | |||
| Creates a backup archive. | |||
| Returns a dict of stats about the archive. | |||
| """ | |||
| cli = ["borg", "create", | |||
| "--stats", "--json", | |||
| "--compression", "lz4", | |||
| "--exclude-caches"] | |||
| if self.exclude_patterns is not None: | |||
| for pattern in self.exclude_patterns: | |||
| cli.append("--exclude") | |||
| cli.append(pattern) | |||
| repo_path = server.repo_path(self.name) | |||
| archive_name = datetime.now().isoformat() | |||
| cli.append(f"{ repo_path }::{ archive_name }") | |||
| for directory in self.dirs: | |||
| cli.append(directory) | |||
| run = subprocess.run(cli, check=False, capture_output=True) | |||
| if run.returncode != 0: | |||
| raise BorgError("create", run) | |||
| return json.loads(run.stdout) | |||
| def load_repo_from_dict( params: dict ) -> Repo: | |||
| """ | |||
| Creates a Repo from a parameters dict | |||
| """ | |||
| repo = Repo( params["name"], params["dirs"] ) | |||
| # replace defaults | |||
| if "exclude_patterns" in params: | |||
| repo.set_exclude_patterns( params["exclude_patterns"] ) | |||
| if "append_only" in params: | |||
| repo.set_append_only( params["append_only"] ) | |||
| if "prune" in params: | |||
| if params["prune"] is dict: | |||
| repo.set_pruning(True, | |||
| keep_daily = params["prune"]["keep_daily"], | |||
| keep_weekly = params["prune"]["keep_weekly"], | |||
| keep_monthly = params["prune"]["keep_monthly"]) | |||
| else: | |||
| repo.set_pruning(False) | |||
| return repo | |||
| def load_repo_from_file( filename: str ) -> Repo: | |||
| """ | |||
| Creates a Repo from a yaml config file | |||
| """ | |||
| with open( filename, 'r', encoding="UTF-8" ) as file: | |||
| params = yaml.safe_load( file ) | |||
| return load_repo_from_dict( params ) | |||
| @ -0,0 +1,62 @@ | |||
| """ | |||
| Declares the Borg backup server class, and functions to create servers from file | |||
| """ | |||
| import yaml | |||
| from backup.detail.server_local import LocalServer | |||
| class Server: | |||
| """ | |||
| Interface for abstracting multiple types of Borg servers. | |||
| """ | |||
| def __init__(self, server_type: str, params: dict): | |||
| """ | |||
| Constructor. | |||
| Parameters: | |||
| type: server type | |||
| params: parameters | |||
| """ | |||
| if server_type == "local": | |||
| self.server = LocalServer(params) | |||
| else: | |||
| raise AssertionError("Unknown server type") | |||
| def repo_path(self, repo_name: str) -> str: | |||
| """ | |||
| Returns a full path to a given repo | |||
| """ | |||
| return self.server.repo_path( repo_name ) | |||
| def is_accessible(self) -> bool: | |||
| """ | |||
| Tests the connection to the server. | |||
| Returns: | |||
| - True if the server is ready to be connected to | |||
| - False otherwise | |||
| """ | |||
| return self.server.is_accessible() | |||
| def load_server_from_dict( params: dict ) -> Server: | |||
| """ | |||
| Creates a Server from a parameters dict | |||
| """ | |||
| assert "type" in params | |||
| server_type = params["type"] | |||
| if server_type == "local": | |||
| return Server("local", params) | |||
| raise AssertionError("Unknown server type") | |||
| def load_server_from_file( filename: str ) -> Server: | |||
| """ | |||
| Creates a Server from a yaml config file | |||
| """ | |||
| with open( filename, 'r', encoding="UTF-8" ) as file: | |||
| params = yaml.safe_load( file ) | |||
| return load_server_from_dict( params ) | |||
| @ -0,0 +1,11 @@ | |||
| name: test | |||
| append_only: false | |||
| dirs: | |||
| - '/tmp/backup/src' | |||
| exclude_patterns: | |||
| - '/home/*/.cache/*' | |||
| - '/var/tmp/*' | |||
| prune: | |||
| keep_daily: 7 | |||
| keep_weekly: 4 | |||
| keep_monthly: 6 | |||
| @ -0,0 +1,3 @@ | |||
| name: "localhost" | |||
| type: local | |||
| path: "/tmp/backup/tgt" | |||
| @ -0,0 +1,75 @@ | |||
| #!/bin/python3 | |||
| """ | |||
| Automated backup script. | |||
| """ | |||
| import argparse | |||
| from pathlib import Path | |||
| import os | |||
| from typing import ( | |||
| Callable, | |||
| List | |||
| ) | |||
| import yaml | |||
| from backup.server import Server, load_server_from_file | |||
| from backup.repo import Repo, load_repo_from_file | |||
| ############# | |||
| # Arguments # | |||
| ############# | |||
| parser = argparse.ArgumentParser(description="Automated backup script") | |||
| parser.add_argument('--config_dir', | |||
| metavar="DIR", | |||
| default="./conf", | |||
| help="Directory for configuration files") | |||
| args = parser.parse_args() | |||
| ############# | |||
| # Functions # | |||
| ############# | |||
| def list_yml_files_in_dir( directory: Path ) -> List[str]: | |||
| """ | |||
| Lists the YAML files in a given directory. | |||
| """ | |||
| files: List[str] = [] | |||
| with os.scandir( directory ) as iterator: | |||
| for entry in iterator: | |||
| if not entry.is_file(): | |||
| continue | |||
| if entry.name.startswith('.'): | |||
| continue | |||
| if not entry.name.endswith('.yml'): | |||
| continue | |||
| files.append(entry.path) | |||
| return files | |||
| ########## | |||
| # Script # | |||
| ########## | |||
| config_dir = Path(args.config_dir) | |||
| # load servers | |||
| server_dir = config_dir / "servers" | |||
| servers: List[Server] = [] | |||
| for server_file in list_yml_files_in_dir( server_dir ): | |||
| servers.append( load_server_from_file( server_file ) ) | |||
| # load repos | |||
| repos_dir = config_dir / "backups" | |||
| repos: List[Repo] = [] | |||
| for repo_file in list_yml_files_in_dir( repos_dir ): | |||
| repos.append( load_repo_from_file( repo_file ) ) | |||
| # init backups | |||
| for server in servers: | |||
| for repo in repos: | |||
| repo.init_or_config( server ) | |||
| print(repo.backup( server )) | |||