diff --git a/home_backup/__main__.py b/home_backup/__main__.py index 78e9aac..cfe46cf 100644 --- a/home_backup/__main__.py +++ b/home_backup/__main__.py @@ -1,7 +1,7 @@ import argparse import asyncio import os -from . import user_service, sys_service +from . import client, sys_service, user_service from .sys_service import rpc from .user_service import rpc del rpc @@ -11,7 +11,11 @@ def main(args): # Parser parser = argparse.ArgumentParser("home-backup", description="Manage backup tools") sub_parser = parser.add_subparsers(dest="action") + parser_remote_add = sub_parser.add_parser("remote-add", help="Add remote for backups.") + parser_remote_add.add_argument("--type", nargs=1, type=str, default=["borgbackup"], help="The remote type.\nDefault: borgbackup") + parser_remote_add.add_argument("--target", nargs=1, type=str, default=None, help="The target of the remote (required by borgbackup).") + parser_remote_add.add_argument("name", nargs=1, type=str, help="The name of the remote.") parser_remote_list = sub_parser.add_parser("remote-list", help="List remotes.") parser_remote_delete = sub_parser.add_parser("remote-delete", help="Delete remote.") @@ -38,6 +42,23 @@ def main(args): asyncio.run(user_service.rpc.run_deamon(fork=result.fork)) # TODO: Change default path of user and system socket # Client actions + if result.action == "remote-add": + # Check remote type + remote_type = result.type[0] + if remote_type not in ("borgbackup",): + raise ValueError("Unknown backup type %s." % repr(remote_type)) + + # Parse type + info = {} + if remote_type == "borgbackup": + if result.target is None: + raise ValueError("Target isn't set.") + info["target"] = result.target[0] + else: + raise NotImplementedError("Type %s isn't supported well." % remote_type) + + # Add remote + client.run_command(client.remote_add_gen(name=result.name[0], rtype=remote_type, info=info)) # Not found action elif result.action is None: diff --git a/home_backup/client.py b/home_backup/client.py new file mode 100644 index 0000000..511d4f2 --- /dev/null +++ b/home_backup/client.py @@ -0,0 +1,31 @@ +import asyncio +from . import defaults, utils + +# +# Base connector +# +def run_command(async_func, user_path:str=None): + async def runner(): + # Connect to user socket + nonlocal user_path + if user_path is None: + user_path = defaults.USER_PATH + if user_path is None: + raise RuntimeError("User service socket path isn't set.") + sock = utils.Connection() + await sock.init(user_path) + + # Run async function + await async_func(sock) + asyncio.run(runner()) + + +# +# Remotes +# +def remote_add_gen(name:str, rtype:str, info): + async def remote_add(con:utils.Connection): + result = await con.call({"operation": "remote-add", "name": name, "type": rtype, "info": info}) + if result["status"] != "success": + raise RuntimeError("Wasn't able to add remote.") # TODO: Show error + return remote_add \ No newline at end of file diff --git a/home_backup/user_service/config.py b/home_backup/user_service/config.py index 0e56fe7..b7cc671 100644 --- a/home_backup/user_service/config.py +++ b/home_backup/user_service/config.py @@ -4,6 +4,7 @@ import configparser import io import os import time +from .remotes import Remote from .. import utils @@ -23,20 +24,14 @@ class Backup(): def __init__(self, name:str, periode:int=None, blocked:list=[]): # Check args - if not isinstance(name, str): - raise TypeError("Name has to be a string.") - for i in filter(lambda x: not("a" <= x <= "z" or "A" <= x <= "Z" or "0" <= x <= "9" or x in ("_", "-")), i): - raise ValueError("%s isn't a valid char in a backup name." % i) + utils.valid_name_check(name) if periode is not None and not isinstance(periode, int): raise TypeError("Periode have to be an integer or null.") if periode is not None and periode < 0: raise ValueError("periode can't be negetive.") blocked = set(blocked) for i in blocked: - if not isinstance(i, str): - raise TypeError("Blocked have to be a list of strings.") - for j in filter(lambda x: not("a" <= x <= "z" or "A" <= x <= "Z" or "0" <= x <= "9" or x in ("_", "-")), i): - raise ValueError("%s isn't a valid char in a block name." % i) + utils.valid_name_check(i) # Set values self.name = name @@ -52,31 +47,41 @@ class Backup(): backups = [] -backups_lock = asyncio.Lock() +remotes = {} +config_lock = asyncio.Lock() if os.path.exists(CONFIG_FILE): def _parse_config(): # Load config config = configparser.ConfigParser() config.read(CONFIG_FILE) - # Create backups backups = [] - for iID, i in filter(lambda x: x[0].startswith("BACKUP|"), config.items()): - iID = iID[len("BACKUP|"):] - periode = None - if "periode" in i: - periode = int(i["periode"]) - blocked = [] - if "blocked" in i: - blocked = i["blocked"].split(",") - backups.append(Backup(iID, periode=periode, blocked=blocked)) - return backups - backups = _parse_config() + remotes = {} + for iID, i in filter(lambda x: x[0] != "DEFAULT", config.items()): + if iID.startswith("BACKUP|"): # Parse backup config + iID = iID[len("BACKUP|"):] + periode = None + if "periode" in i: + periode = int(i["periode"]) + blocked = [] + if "blocked" in i: + blocked = i["blocked"].split(",") + backups.append(Backup(iID, periode=periode, blocked=blocked)) + elif iID.startswith("REMOTE|"): # Parse remote config + iID = iID[len("REMOTE|"):] + tmp = Remote.load_remote(iID, i) + remotes[tmp.name] = tmp + else: + raise ValueError("Unknown config part %s." % repr(iID)) + return backups, remotes + backups, remotes = _parse_config() async def save_config(): - # Write config + # Create config config = configparser.ConfigParser() + + # Add backups for i in backups: if not isinstance(i, Backup): raise ValueError("backups contains a non backup config entry.") @@ -86,12 +91,19 @@ async def save_config(): if i.periode is not None: backup_data["periode"] = str(i.periode) config["BACKUP|%s" % i.name] = backup_data - + + # Add remotes + for i in remotes.values(): + if not isinstance(i, Remote): + raise ValueError("remotes contains a non remote config entry.") + config["REMOTE|%s" % i.name] = i.dump_config() + # Write data tmp = io.StringIO() config.write(tmp) + to_write = tmp.getvalue() async with aiofile.AIOFile(CONFIG_FILE, "w") as f: - await f.write(tmp.read()) + await f.write(to_write) await f.fsync() @@ -120,7 +132,7 @@ class Timer(): next_time = int(time.time()) candiates = {} candidate_name = set() - async with backups_lock: + async with config_lock: for i in backups: if self.__latest < i.get_next_scedule(self.__latest, self.__zero) <= next_time: candiates[i.name] = i diff --git a/home_backup/user_service/remotes.py b/home_backup/user_service/remotes.py new file mode 100644 index 0000000..3f07544 --- /dev/null +++ b/home_backup/user_service/remotes.py @@ -0,0 +1,73 @@ +from .. import utils + + +class Remote(): + rtype:str + name:str + target:str + + def __init__(self, rtype:str, name:str, target:str=None): + # Check args + if rtype not in ("borgbackup",): + raise ValueError("rtype have to be borgbackup.") + utils.valid_name_check(name) + self.rtype = rtype + self.name = name + + # Check borg config + if rtype == "borgbackup": + if not isinstance(target, str): + raise TypeError("target have to be a string.") + self.target = target + + def dump_config(self): + if self.rtype == "borgbackup": + return {"type": "borgbackup", "target":self.target} + else: + raise NotImplementedError("Unknown backup type %s to dump." % self.rtype) + + @staticmethod + def load_remote(name:str, config): + config = dict(config.items()) + if config["type"] == "borgbackup": + # Borg backup + return Remote(rtype="borgbackup", name=name, target=config["target"]) + else: + raise ValueError("Unknown backup type %s." % repr(config["type"])) + + +# RPC implementations +async def add_remote(data:dict): + # Load config + from . import config + + # Load data + name = data["name"] + del data["name"] + + rtype = data["type"] + del data["type"] + + info = data["info"] + if not isinstance(info, dict): + raise TypeError("info isn't a object.") + info["type"] = rtype + del data["info"] + + for i in data.keys(): + raise ValueError("%s is an unknown option." % repr(i)) + + rem = Remote.load_remote(name, info) + + # Set data + async with config.config_lock: + # Check + if name in config.remotes: + raise ValueError("'%s' remote already exists." % name) + + # Set remote + config.remotes[name] = rem + await config.save_config() + + # Return success + return {"status": "success"} \ No newline at end of file diff --git a/home_backup/user_service/rpc.py b/home_backup/user_service/rpc.py index 2eb7118..b19508f 100644 --- a/home_backup/user_service/rpc.py +++ b/home_backup/user_service/rpc.py @@ -1,6 +1,6 @@ import asyncio import os -from . import config +from . import config, remotes from .. import defaults, utils @@ -33,9 +33,13 @@ def gen_callback_func(master:BackupManager): if "operation" not in data: raise ValueError("'operation' isn't set.") operation = data["operation"] + del data["operation"] # Run operation - raise NotImplementedError() + if operation == "remote-add": + return await remotes.add_remote(data) + else: + raise NotImplementedError("%s isn't a supported operation." % repr(operation)) return callback_func diff --git a/home_backup/utils.py b/home_backup/utils.py index faeba97..f02faf5 100644 --- a/home_backup/utils.py +++ b/home_backup/utils.py @@ -87,5 +87,12 @@ class Connection(): await self.__write.drain() # Recive date and return - size = _format_length.unpack(await self.__read.readexactly(_format_length.size)) - return json.loads((await self.__read.readexactly(size)).decode("UTF-8")) \ No newline at end of file + (size,) = _format_length.unpack(await self.__read.readexactly(_format_length.size)) + return json.loads((await self.__read.readexactly(size)).decode("UTF-8")) + + +def valid_name_check(name:str): + if not isinstance(name, str): + raise TypeError("name has to be a string.") + for i in filter(lambda x: not("a" <= x <= "z" or "A" <= x <= "Z" or "0" <= x <= "9" or x in ("_", "-")), name): + raise ValueError("names can't contain %s." % repr(i)) \ No newline at end of file