From 8c13da97b4b721e31be9f024d834bfaf2bf84a2e Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 26 Oct 2023 00:51:12 +0200 Subject: [PATCH 01/28] New TelegramHandler --- .dockerignore | 3 +- src/log_dev.conf | 36 ++++++ src/singleton.py | 6 + src/telegram_handler.py | 225 +++++++++++++++++------------------- src/telegram_handler_old.py | 176 ++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 118 deletions(-) create mode 100644 src/log_dev.conf create mode 100644 src/singleton.py create mode 100644 src/telegram_handler_old.py diff --git a/.dockerignore b/.dockerignore index 4a4f22f..efad6da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,4 +13,5 @@ source target logs secrets -test_docker_compose.yaml \ No newline at end of file +test_docker_compose.yaml +src/log_dev.py \ No newline at end of file diff --git a/src/log_dev.conf b/src/log_dev.conf new file mode 100644 index 0000000..f016683 --- /dev/null +++ b/src/log_dev.conf @@ -0,0 +1,36 @@ +[loggers] +keys=root,pybackupper + +[handlers] +keys=consoleHandler,fileHandler + +[formatters] +keys=consoleFormatter,fileFormater + +[logger_root] +level=DEBUG +handlers=consoleHandler + +[logger_pybackupper] +level=DEBUG +handlers=consoleHandler,fileHandler +qualname=pybackupper_logger +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=INFO +formatter=consoleFormatter +args=(sys.stdout,) + +[handler_fileHandler] +class=handlers.TimedRotatingFileHandler +level=INFO +formatter=fileFormater +args=('./log.log', "D", 7, 10) + +[formatter_consoleFormatter] +format=%(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s + +[formatter_fileFormater] +format=%(asctime)s - %(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s \ No newline at end of file diff --git a/src/singleton.py b/src/singleton.py new file mode 100644 index 0000000..8a09d95 --- /dev/null +++ b/src/singleton.py @@ -0,0 +1,6 @@ +class Singleton(type): + _instances = {} + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] \ No newline at end of file diff --git a/src/telegram_handler.py b/src/telegram_handler.py index e917245..89d3b10 100644 --- a/src/telegram_handler.py +++ b/src/telegram_handler.py @@ -1,48 +1,83 @@ -"""Module for handling Telegram bot commands. -""" import logging import logging.config +from singleton import Singleton import requests -import os -from pprint import pformat +from os.path import exists, isfile -class TelegramHandler(): - """Class for handling Telegram bot commands. - """ - def __init__(self, token:str, chat_id:str, logger:logging.Logger=None): - """_summary_ - Args: - token (str): Telegram bot token. - chat_id (str): Telegram chat id. - logger (logging.Logger, optional): Logger to use. Defaults to None. - - Raises: - ValueError: Exception raised when required argument has invalid value. - """ - if logger is None: - logging.config.fileConfig("log.conf") - self.logger = logging.getLogger('pybackupper_logger') - else: - self.logger = logger - - if token is None or token == "": - self.logger.error("Telegram token is not set.") - raise ValueError("Telegram token is not set.") - - if chat_id is None or chat_id == "": - self.logger.error("Telegram chat_id is not set.") - raise ValueError("Telegram chat_id is not set.") +class TelegramHandler(metaclass=Singleton): + def __init__(self, token:str, chat_id:str, logger:logging.Logger=None) -> None: + self.logger = logger self.token = token self.chat_id = chat_id self.logger.info("TelegramHandler initialized.") + + @property + def token(self) -> str: + return self._token + + @token.setter + def token(self, token:str) -> None: + if token is None or token == "": + self.logger.error(f"Telegram {token=} is not valid.") + raise ValueError(f"Telegram {token=} is not valid.") + self._token = token + + @property + def chat_id(self) -> str: + return self._chat_id + + @chat_id.setter + def chat_id(self, chat_id:str) -> None: + if chat_id is None or chat_id == "": + self.logger.error(f"Telegram {chat_id=} is not valid.") + raise ValueError(f"Telegram {chat_id=} is not valid.") + self._chat_id = chat_id + + @property + def logger(self) -> logging.Logger: + return self._logger + + @logger.setter + def logger(self, logger:logging.Logger) -> None: + if logger is None: + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') + else: + self._logger = logger + + def test_connection(self) -> bool: + """Tests connection to Telegram bot. + + Returns: + bool: True if connection is successful, False otherwise. + """ + url = f"https://api.telegram.org/bot{self.token}/getMe" + try: + response = requests.get(url) + + if response.status_code != 200: + self.logger.error(f"Telegram connection test failed. Status code: {response.status_code}.") + return False + elif response.json()['ok'] != True: + self.logger.error(f"Telegram connection test failed. Status code: {response.status_code}. Response: {response.json()}.") + return False + else: + self.logger.info("Telegram connection test successful.") + return True + except Exception as e: + self.logger.error(f"Telegram connection test failed. Exception: {e}.") + return False - def send_message(self, message:str): + def send_message(self, message:str, silent:bool=False, markdown:bool=False, html:bool=False): """Sends message to Telegram chat. Args: message (str): Message to send. + silent (bool, optional): Whether to send message silently. Defaults to False. + markdown (bool, optional): Whether to parse message as markdown. Defaults to False. + html (bool, optional): Whether to parse message as html. Defaults to False. Raises: ValueError: Empty message. @@ -57,28 +92,41 @@ def send_message(self, message:str): data = { "chat_id": self.chat_id, "text": message, - "parse_mode": "markdown" + "disable_notification": silent, } + if markdown: + data["parse_mode"] = "MarkdownV2" + + if html: + data["parse_mode"] = "HTML" + + if markdown and html: + self.logger.error("Message can't be parsed as markdown and html at the same time.") + raise ValueError("Message can't be parsed as markdown and html at the same time.") + try: response = requests.post(url, data=data) - if response.status_code != 200: - self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") - self.logger.debug("Message sent to Telegram chat.") + if response.status_code != 200 or response.json()['ok'] != True: + self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") + raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") + + self.logger.debug(f"Message sent to Telegram chat.") except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send message to Telegram chat.") + self.logger.exception(e, exc_info=True) + self.logger.exception("Failed to send message to Telegram chat.") raise e - def send_file(self, file_path:str): + def send_file(self, file_path:str, caption:str=None, silent:bool=False): """Sends file to Telegram chat. Args: file_path (str): Path to file to send. + caption (str, optional): Caption for file. Defaults to None. + silent (bool, optional): Whether to send message silently. Defaults to False. Raises: - ValueError: File path is empty. + ValueError: Empty file path. FileNotFoundError: File does not exist. Exception: Failed to send file to Telegram chat. e: Exception raised when failed to send file to Telegram chat. @@ -86,91 +134,34 @@ def send_file(self, file_path:str): if file_path is None or file_path == "": self.logger.error("File path is empty.") raise ValueError("File path is empty.") + elif not exists(file_path): + self.logger.error(f"File {file_path=} does not exist.") + raise FileNotFoundError(f"File {file_path=} does not exist.") + elif not isfile(file_path): + self.logger.error(f"File {file_path=} is not a file.") + raise FileNotFoundError(f"File {file_path=} is not a file.") - if not os.path.exists(file_path): - self.logger.error(f"File {file_path} does not exist.") - raise FileNotFoundError(f"File {file_path} does not exist.") + if caption is not None and caption == "": + self.logger.error("Caption is provided, but is empty.") + raise ValueError("Caption is provided, but is empty.") url = f"https://api.telegram.org/bot{self.token}/sendDocument" data = { "chat_id": self.chat_id, - } - files = { - "document": open(file_path, "rb"), + "disable_notification": silent, } - try: - response = requests.post(url, data=data, files=files) - if response.status_code != 200: - self.logger.error(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send file to Telegram chat. Status code: {response.status_code}") - self.logger.debug("File sent to Telegram chat.") - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send file to Telegram chat.") - raise e - - def send_backup_info(self, hostname:str, response:str, backup_info:dict): - """Sends backup info to Telegram chat. - - Args: - hostname (str): Hostname. - backup_info (dict): Backup info. - - Raises: - ValueError: Hostname or backup info is empty. - Exception: Failed to send message to Telegram chat. - e: Exception raised when failed to send message to Telegram chat. - """ - - if hostname is None or hostname == "": - self.logger.error("Hostname is empty.") - raise ValueError("Hostname is empty.") - - if response is None or response == "": - self.logger.error("Response is empty.") - raise ValueError("Response is empty.") - - if backup_info is None or backup_info == {}: - self.logger.error("Backup info is empty.") - raise ValueError("Backup info is empty.") - - url = f"https://api.telegram.org/bot{self.token}/sendMessage" - data = { - "chat_id": self.chat_id, - "text": f"""*PyBackUpper*\n*Hostname: {hostname}*\n\nOutput: {response}\n\nBackup info:\n`{pformat(backup_info)}`""", - "parse_mode": "markdown", - } + if caption is not None: + data["caption"] = caption try: - response = requests.post(url, data=data) - if response.status_code != 200: - self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") - self.logger.debug("Message sent to Telegram chat.") + response = requests.post(url, data=data, files={"document": open(file_path, "rb")}) + if response.status_code != 200 or response.json()['ok'] != True: + self.logger.error(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") + raise Exception(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") + + self.logger.debug(f"File sent to Telegram chat.") except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send message to Telegram chat.") + self.logger.exception(e, exc_info=True) + self.logger.exception("Failed to send file to Telegram chat.") raise e - - def test_connection(self) -> bool: - """Tests connection to Telegram chat. - - Returns: - bool: True if connection is successful. - """ - url = f"https://api.telegram.org/bot{self.token}/getMe" - - try: - response = requests.post(url) - if response.status_code != 200: - self.logger.error(f"Failed to test connection to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - return False - self.logger.debug("Connection to Telegram chat successful.") - return True - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to test connection to Telegram chat.") - return False - - diff --git a/src/telegram_handler_old.py b/src/telegram_handler_old.py new file mode 100644 index 0000000..e917245 --- /dev/null +++ b/src/telegram_handler_old.py @@ -0,0 +1,176 @@ +"""Module for handling Telegram bot commands. +""" +import logging +import logging.config +import requests +import os +from pprint import pformat + +class TelegramHandler(): + """Class for handling Telegram bot commands. + """ + def __init__(self, token:str, chat_id:str, logger:logging.Logger=None): + """_summary_ + + Args: + token (str): Telegram bot token. + chat_id (str): Telegram chat id. + logger (logging.Logger, optional): Logger to use. Defaults to None. + + Raises: + ValueError: Exception raised when required argument has invalid value. + """ + if logger is None: + logging.config.fileConfig("log.conf") + self.logger = logging.getLogger('pybackupper_logger') + else: + self.logger = logger + + if token is None or token == "": + self.logger.error("Telegram token is not set.") + raise ValueError("Telegram token is not set.") + + if chat_id is None or chat_id == "": + self.logger.error("Telegram chat_id is not set.") + raise ValueError("Telegram chat_id is not set.") + + self.token = token + self.chat_id = chat_id + self.logger.info("TelegramHandler initialized.") + + def send_message(self, message:str): + """Sends message to Telegram chat. + + Args: + message (str): Message to send. + + Raises: + ValueError: Empty message. + Exception: Failed to send message to Telegram chat. + e: Exception raised when failed to send message to Telegram chat. + """ + if message is None or message == "": + self.logger.error("Message is empty.") + raise ValueError("Message is empty.") + + url = f"https://api.telegram.org/bot{self.token}/sendMessage" + data = { + "chat_id": self.chat_id, + "text": message, + "parse_mode": "markdown" + } + + try: + response = requests.post(url, data=data) + if response.status_code != 200: + self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") + raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") + self.logger.debug("Message sent to Telegram chat.") + except Exception as e: + self.logger.error(e, exc_info=True) + self.logger.error("Failed to send message to Telegram chat.") + raise e + + def send_file(self, file_path:str): + """Sends file to Telegram chat. + + Args: + file_path (str): Path to file to send. + + Raises: + ValueError: File path is empty. + FileNotFoundError: File does not exist. + Exception: Failed to send file to Telegram chat. + e: Exception raised when failed to send file to Telegram chat. + """ + if file_path is None or file_path == "": + self.logger.error("File path is empty.") + raise ValueError("File path is empty.") + + if not os.path.exists(file_path): + self.logger.error(f"File {file_path} does not exist.") + raise FileNotFoundError(f"File {file_path} does not exist.") + + url = f"https://api.telegram.org/bot{self.token}/sendDocument" + data = { + "chat_id": self.chat_id, + } + files = { + "document": open(file_path, "rb"), + } + + try: + response = requests.post(url, data=data, files=files) + if response.status_code != 200: + self.logger.error(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.text}") + raise Exception(f"Failed to send file to Telegram chat. Status code: {response.status_code}") + self.logger.debug("File sent to Telegram chat.") + except Exception as e: + self.logger.error(e, exc_info=True) + self.logger.error("Failed to send file to Telegram chat.") + raise e + + def send_backup_info(self, hostname:str, response:str, backup_info:dict): + """Sends backup info to Telegram chat. + + Args: + hostname (str): Hostname. + backup_info (dict): Backup info. + + Raises: + ValueError: Hostname or backup info is empty. + Exception: Failed to send message to Telegram chat. + e: Exception raised when failed to send message to Telegram chat. + """ + + if hostname is None or hostname == "": + self.logger.error("Hostname is empty.") + raise ValueError("Hostname is empty.") + + if response is None or response == "": + self.logger.error("Response is empty.") + raise ValueError("Response is empty.") + + if backup_info is None or backup_info == {}: + self.logger.error("Backup info is empty.") + raise ValueError("Backup info is empty.") + + url = f"https://api.telegram.org/bot{self.token}/sendMessage" + data = { + "chat_id": self.chat_id, + "text": f"""*PyBackUpper*\n*Hostname: {hostname}*\n\nOutput: {response}\n\nBackup info:\n`{pformat(backup_info)}`""", + "parse_mode": "markdown", + } + + try: + response = requests.post(url, data=data) + if response.status_code != 200: + self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") + raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") + self.logger.debug("Message sent to Telegram chat.") + except Exception as e: + self.logger.error(e, exc_info=True) + self.logger.error("Failed to send message to Telegram chat.") + raise e + + def test_connection(self) -> bool: + """Tests connection to Telegram chat. + + Returns: + bool: True if connection is successful. + """ + url = f"https://api.telegram.org/bot{self.token}/getMe" + + try: + response = requests.post(url) + if response.status_code != 200: + self.logger.error(f"Failed to test connection to Telegram chat. Status code: {response.status_code}. Response: {response.text}") + return False + self.logger.debug("Connection to Telegram chat successful.") + return True + except Exception as e: + self.logger.error(e, exc_info=True) + self.logger.error("Failed to test connection to Telegram chat.") + return False + + From 6f86c8b57f50f07229d09d1796ebcfc8db6bfdb6 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 1 Nov 2023 00:42:29 +0100 Subject: [PATCH 02/28] Backup class init --- src/backup.py | 132 ++++++++++++++++++ ...kups_manager.py => backups_manager_old.py} | 0 src/log_dev.conf | 4 +- src/telegram_handler.py | 3 +- 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/backup.py rename src/{backups_manager.py => backups_manager_old.py} (100%) diff --git a/src/backup.py b/src/backup.py new file mode 100644 index 0000000..0958fe9 --- /dev/null +++ b/src/backup.py @@ -0,0 +1,132 @@ +import logging +import logging.config +import inspect +from os.path import exists + +class Backup(): + def __init__(self, name:str, dest_path:str, logger:logging.Logger=None) -> None: + self.logger = logger + self.name = name + self.dest_path = dest_path + self.completed = False + self.logger.info(f"Backup {self.name} initialized.") + + + @property + def logger(self) -> logging.Logger: + """Returns logger for the class. + + Returns: + logging.Logger: Logger for the class. + """ + return self._logger + + @logger.setter + def logger(self, logger:logging.Logger) -> None: + """Sets logger for the class. + + Args: + logger (logging.Logger): Logger for the class. + """ + if logger is None: + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') + else: + self._logger = logger + + @property + def name(self) -> str: + """Returns name of the backup. + + Returns: + str: Name of the backup. + """ + return self._name + + @name.setter + def name(self, name:str) -> None: + """Sets name of the backup. + + Args: + name (str): Name of the backup. + + Raises: + ValueError: Name of the backup is not valid. + PermissionError: Change of `name` property is not allowed for Backup. + """ + if name is None or name == "": + self.logger.error(f"Backup {name=} is not valid.") + raise ValueError(f"Backup {name=} is not valid.") + + try: + if self._name != "": + self.logger.error(f"Cannot change name of the backup.") + raise PermissionError(f"Cannot change name of the backup.") + except AttributeError: + self.logger.debug(f"Setting name of the backup to {name=}.") + self._name = name + + @property + def dest_path(self) -> str: + """Returns destination path of the backup. + + Returns: + str: Destination path of the backup. + """ + return self._dest_path + + @dest_path.setter + def dest_path(self, dest_path:str) -> None: + """Sets destination path of the backup. + + Args: + dest_path (str): Destination path of the backup. + + Raises: + ValueError: Destination path of the backup is not valid. + FileNotFoundError: Destination path of the backup does not exist. + PermissionError: Change of `dest_path` property is not allowed for Backup. + """ + if dest_path is None or dest_path == "": + self.logger.error(f"Backup {dest_path=} is not valid.") + raise ValueError(f"Backup {dest_path=} is not valid.") + + if not exists(dest_path): + self.logger.error(f"Backup {dest_path=} does not exist.") + raise FileNotFoundError(f"Backup {dest_path=} does not exist.") + + try: + if self._dest_path != "": + self.logger.error(f"Cannot change destination path of the backup.") + raise PermissionError(f"Cannot change destination path of the backup.") + except AttributeError: + self.logger.debug(f"Setting destination path of the backup to {dest_path=}.") + self._dest_path = dest_path + + @property + def completed(self) -> bool: + """Returns True if backup is completed, False otherwise. + + Returns: + bool: True if backup is completed, False otherwise. + """ + return self._completed + + @completed.setter + def completed(self, completed:bool) -> None: + """Sets completed property of the backup. + + Args: + completed (bool): True if backup is completed, False otherwise. + + Raises: + PermissionError: Change of `completed` property is not allowed for Backup. + """ + caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ + + if caller_class == self.__class__.__name__: + self.logger.debug(f"Setting completed property of the backup to {completed=}.") + self._completed = completed + else: + self.logger.error(f"Change of `completed` property is not allowed for {caller_class=}.") + raise PermissionError(f"Change of `completed` property is not allowed for {caller_class=}.") diff --git a/src/backups_manager.py b/src/backups_manager_old.py similarity index 100% rename from src/backups_manager.py rename to src/backups_manager_old.py diff --git a/src/log_dev.conf b/src/log_dev.conf index f016683..7ad6f28 100644 --- a/src/log_dev.conf +++ b/src/log_dev.conf @@ -19,13 +19,13 @@ propagate=0 [handler_consoleHandler] class=StreamHandler -level=INFO +level=DEBUG formatter=consoleFormatter args=(sys.stdout,) [handler_fileHandler] class=handlers.TimedRotatingFileHandler -level=INFO +level=DEBUG formatter=fileFormater args=('./log.log', "D", 7, 10) diff --git a/src/telegram_handler.py b/src/telegram_handler.py index 89d3b10..0ba8e93 100644 --- a/src/telegram_handler.py +++ b/src/telegram_handler.py @@ -1,11 +1,10 @@ import logging import logging.config -from singleton import Singleton import requests from os.path import exists, isfile -class TelegramHandler(metaclass=Singleton): +class TelegramHandler(): def __init__(self, token:str, chat_id:str, logger:logging.Logger=None) -> None: self.logger = logger From 514a8b9813900308fab3da356d9d057cb0d96a4c Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 1 Nov 2023 10:54:36 +0100 Subject: [PATCH 03/28] PyBackUpper Schema --- .gitignore | 4 +++- PyBackUpper Schema.png | Bin 0 -> 19720 bytes src/telegram_handler.py | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 PyBackUpper Schema.png diff --git a/.gitignore b/.gitignore index d10432f..7b94f54 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,6 @@ target/* # ignore all files in logs directory logs/* -secrets/* \ No newline at end of file +secrets/* + +*.png.bkp \ No newline at end of file diff --git a/PyBackUpper Schema.png b/PyBackUpper Schema.png new file mode 100644 index 0000000000000000000000000000000000000000..13a72bed26034223b13a98d317e030cb994177af GIT binary patch literal 19720 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 z zAx(8wa7=61{>n(oiT7H+=L-r1X%`MGe(_#Uib#O_-B8ZaF{?)V9@#rsCcim-v`K`f zq)k*-x4(^YS1}yrbR-j{%*V`%-J_7pTF`mNGu;W#+NGPUUEj}3g)|d^>h`<}<*TbO&y}(gt)~ z-mvGVb|}tHa!{C@8}JINo%d47>FwUwk+Cx+c!0pE?s?(AT{3~Kyjqu-xkLi>civNF ztDiJoA$F5-()`5=Wvz^DCAH;e5Mx#Y5>#0;(Ujbm!{$6g7+lLf|5PH59c>cmc>zm3 zIlcCVA);6Ygx9Y_f^xHl&EEI|fp2>#In4vtga4~y@>h}mepOOPbXXBW+h8-IQXcy3 zpvXuS2+FL?V|~a|stn7hgqmLmx%I4k#`hGF5UgLnuS3iBCpG4-d|7=+kxA;y(hExN zuRJ@kQEv#$20RvJ1>wG+0n+!cFhV$<_cTHj1IbUe&1br^2E4WL@k$Mxv}zb9qEX@m zRpn%Y$*Y9QIFrGi_}PAu32tiOd*cShgg(k1t^jHNt=Pn=vv2Q0F(kuF-}|?dul4~C zLiQZLH<$6ppOR1-%fp?~jm6f2HOI_FTlTSH-UuC9=91jRTKe3}*gv;mA)D zReK&;-zTf_el(P{KwVrc})Wpv62C4JKcl`4W51zx;ESqda ziCvhhlxzG{BU9GfEi(85so8LGW~y1bk7V{kahF}rMk7gHrxsVvN*Heh;>evKgZ2VGY9E6JJ+mQLYUQ3J(4-je=0Zs zdR4hc8`Pp-0uRq^W3zhJABMUWYqwsSuBNemN6?XbYwnf>Eua`HU^5;;t(LNk(kWB~ z_pH`#h8q=TRY#E)Y#+_qT-nTZ(UGn;4J~r-e^M!&+vd9_PMgd`5Z(^tRMj08dRVYu z&_m0l-d{M&V^Kpt%_!6lVr z{*4ap_{wDm(!bFf+LuJU%k52Q(;XOlFoI@LPK!e<5x{$#N_$}+Z(e)6=FCLTEaqLk zrC(-?HV84f@BBM*RFk)k>qpY5(dT)QQuBSS*7tzmx;kP-?VZ=c0fWy2>yQf6(*2Q{ zv&ji%@{&zHsnmiZ?ECkHMxfgFNLWVI-J*r%KY%(IRC@(oW_G)ZgM*X7{=Wc-l&;b& z&-?0T@RtOxiGdFymb69kg|L%zv;BU3%ak9&qK507Uh(Y&KtNl0rh2Pia)yvSqj7G? zppV{OR>t#{wvZnjxmq52r7EGq{3^f#vWjW3`N%+M_2t&w-$S3EO)}F{3zVSrM81yz zDuFJg`9s9R&=9Jc)P;d(j>0t13K=CON$-^*Cn6(Ga|x{HffMZjEy@|QLO*? zA@Y9RI>Z7eC(Wf3LJ7zDpg&$ZAtxd!W0qJBrA_ksreC<p z9T0df_GV@dNDzqwM_oKv?~ETgn_A76LVWqzl*|AhJ57!>nB6?nEy?fed6B@CS1kkt zPc>e=Z!arPGa`5AtB5F3E9Worvpf^lGfTgeUOw?=G2>agMv zRxMe=XDb>e)r@)Gt>Dn&*6H4p0j8oQeVE6p?l0qAX{TWBh#5l&bu;uNJ?*Z*N6n!huLp0L0y~zevkfO_FP!whL}TTiFFO zZ{q9iu4NmL7i$S%>Xa8{1W+{@7(1SozwdP-E!&-x_h9S3sQpM@c)9d3= ztisFvd}=iHI#Di&-!bUdtZlM0Pdwly5;i7!%TC#Qj3?T*mmdQh!QU!G-^d_Y>w6-C zUt8CdF|-ua=m!QT`2-cLE}c#9dPtDe8Q`=|qC_zzDrU((-%bcCw|-8ma8{{EoqyR+ zPrtw~5mk4r?uW2v$8-MptKo6aRJBebm*4qo7hPJg8r?WVL+#FJ%$`JVFb8nRZ=YKu{qn?zlj~+xEGUkx ziH~$Xg{9M0=EedeMX`}vB+`L=@7JNp8eieQ?S&|$v*+e?u-`;;5#mmW02&p0z_*j5^e<&u7T3lZg2$<&%J%f` zL&;DYH-`F0|b}SW05jx2p?f-fk{o1p~fd2b#*T1iUp?}u None: self.logger = logger @@ -69,7 +69,7 @@ def test_connection(self) -> bool: self.logger.error(f"Telegram connection test failed. Exception: {e}.") return False - def send_message(self, message:str, silent:bool=False, markdown:bool=False, html:bool=False): + def send_message(self, message:str, silent:bool=False, markdown:bool=False, html:bool=False) -> None: """Sends message to Telegram chat. Args: @@ -116,7 +116,7 @@ def send_message(self, message:str, silent:bool=False, markdown:bool=False, html self.logger.exception("Failed to send message to Telegram chat.") raise e - def send_file(self, file_path:str, caption:str=None, silent:bool=False): + def send_file(self, file_path:str, caption:str=None, silent:bool=False) -> None: """Sends file to Telegram chat. Args: From 33c2f9c0e59b881bc1e2b47607a319041f5fd7ff Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 1 Nov 2023 10:56:38 +0100 Subject: [PATCH 04/28] Added background to schema --- .gitignore | 3 ++- PyBackUpper Schema.png | Bin 19720 -> 20893 bytes 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7b94f54..616cda5 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,5 @@ logs/* secrets/* -*.png.bkp \ No newline at end of file +*.png.bkp +*.png.dtmp \ No newline at end of file diff --git a/PyBackUpper Schema.png b/PyBackUpper Schema.png index 13a72bed26034223b13a98d317e030cb994177af..219a4257211aca82fc1e333e8a2edaf81773ce11 100644 GIT binary patch literal 20893 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 T>nZSiixlcG9pysBJNNz<6#?N+ literal 19720 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 z zAx(8wa7=61{>n(oiT7H+=L-r1X%`MGe(_#Uib#O_-B8ZaF{?)V9@#rsCcim-v`K`f zq)k*-x4(^YS1}yrbR-j{%*V`%-J_7pTF`mNGu;W#+NGPUUEj}3g)|d^>h`<}<*TbO&y}(gt)~ z-mvGVb|}tHa!{C@8}JINo%d47>FwUwk+Cx+c!0pE?s?(AT{3~Kyjqu-xkLi>civNF ztDiJoA$F5-()`5=Wvz^DCAH;e5Mx#Y5>#0;(Ujbm!{$6g7+lLf|5PH59c>cmc>zm3 zIlcCVA);6Ygx9Y_f^xHl&EEI|fp2>#In4vtga4~y@>h}mepOOPbXXBW+h8-IQXcy3 zpvXuS2+FL?V|~a|stn7hgqmLmx%I4k#`hGF5UgLnuS3iBCpG4-d|7=+kxA;y(hExN zuRJ@kQEv#$20RvJ1>wG+0n+!cFhV$<_cTHj1IbUe&1br^2E4WL@k$Mxv}zb9qEX@m zRpn%Y$*Y9QIFrGi_}PAu32tiOd*cShgg(k1t^jHNt=Pn=vv2Q0F(kuF-}|?dul4~C zLiQZLH<$6ppOR1-%fp?~jm6f2HOI_FTlTSH-UuC9=91jRTKe3}*gv;mA)D zReK&;-zTf_el(P{KwVrc})Wpv62C4JKcl`4W51zx;ESqda ziCvhhlxzG{BU9GfEi(85so8LGW~y1bk7V{kahF}rMk7gHrxsVvN*Heh;>evKgZ2VGY9E6JJ+mQLYUQ3J(4-je=0Zs zdR4hc8`Pp-0uRq^W3zhJABMUWYqwsSuBNemN6?XbYwnf>Eua`HU^5;;t(LNk(kWB~ z_pH`#h8q=TRY#E)Y#+_qT-nTZ(UGn;4J~r-e^M!&+vd9_PMgd`5Z(^tRMj08dRVYu z&_m0l-d{M&V^Kpt%_!6lVr z{*4ap_{wDm(!bFf+LuJU%k52Q(;XOlFoI@LPK!e<5x{$#N_$}+Z(e)6=FCLTEaqLk zrC(-?HV84f@BBM*RFk)k>qpY5(dT)QQuBSS*7tzmx;kP-?VZ=c0fWy2>yQf6(*2Q{ zv&ji%@{&zHsnmiZ?ECkHMxfgFNLWVI-J*r%KY%(IRC@(oW_G)ZgM*X7{=Wc-l&;b& z&-?0T@RtOxiGdFymb69kg|L%zv;BU3%ak9&qK507Uh(Y&KtNl0rh2Pia)yvSqj7G? zppV{OR>t#{wvZnjxmq52r7EGq{3^f#vWjW3`N%+M_2t&w-$S3EO)}F{3zVSrM81yz zDuFJg`9s9R&=9Jc)P;d(j>0t13K=CON$-^*Cn6(Ga|x{HffMZjEy@|QLO*? zA@Y9RI>Z7eC(Wf3LJ7zDpg&$ZAtxd!W0qJBrA_ksreC<p z9T0df_GV@dNDzqwM_oKv?~ETgn_A76LVWqzl*|AhJ57!>nB6?nEy?fed6B@CS1kkt zPc>e=Z!arPGa`5AtB5F3E9Worvpf^lGfTgeUOw?=G2>agMv zRxMe=XDb>e)r@)Gt>Dn&*6H4p0j8oQeVE6p?l0qAX{TWBh#5l&bu;uNJ?*Z*N6n!huLp0L0y~zevkfO_FP!whL}TTiFFO zZ{q9iu4NmL7i$S%>Xa8{1W+{@7(1SozwdP-E!&-x_h9S3sQpM@c)9d3= ztisFvd}=iHI#Di&-!bUdtZlM0Pdwly5;i7!%TC#Qj3?T*mmdQh!QU!G-^d_Y>w6-C zUt8CdF|-ua=m!QT`2-cLE}c#9dPtDe8Q`=|qC_zzDrU((-%bcCw|-8ma8{{EoqyR+ zPrtw~5mk4r?uW2v$8-MptKo6aRJBebm*4qo7hPJg8r?WVL+#FJ%$`JVFb8nRZ=YKu{qn?zlj~+xEGUkx ziH~$Xg{9M0=EedeMX`}vB+`L=@7JNp8eieQ?S&|$v*+e?u-`;;5#mmW02&p0z_*j5^e<&u7T3lZg2$<&%J%f` zL&;DYH-`F0|b}SW05jx2p?f-fk{o1p~fd2b#*T1iUp?}u Date: Wed, 1 Nov 2023 10:59:05 +0100 Subject: [PATCH 05/28] . --- PyBackUpper Schema.png | Bin 20893 -> 21386 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/PyBackUpper Schema.png b/PyBackUpper Schema.png index 219a4257211aca82fc1e333e8a2edaf81773ce11..8b5d0616b664089a313e9ef6d1a09a07c4aab766 100644 GIT binary patch literal 21386 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 Uuh~uo9FVdQ&MBb@03bk!p8x;= literal 20893 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 T>nZSiixlcG9pysBJNNz<6#?N+ From 8c7a0753c0d7d06416d8c3d061bc866fa432fd0f Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 1 Nov 2023 12:25:52 +0100 Subject: [PATCH 06/28] Some improvements --- .dockerignore | 3 +- PyBackUpper Schema.png | Bin 21386 -> 21422 bytes src/backup.py | 118 ++++++++++++++++++++++++++++++++++++++++- src/log_dev.conf | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index efad6da..befddc9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,4 +14,5 @@ target logs secrets test_docker_compose.yaml -src/log_dev.py \ No newline at end of file +src/log_dev.conf +src/__pycache__ \ No newline at end of file diff --git a/PyBackUpper Schema.png b/PyBackUpper Schema.png index 8b5d0616b664089a313e9ef6d1a09a07c4aab766..a91f1a057c2bcb8a8e9ada73725e0a14d26e97b8 100644 GIT binary patch literal 21422 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 Uuh~uo9FVdQ&MBb@03bk!p8x;= diff --git a/src/backup.py b/src/backup.py index 0958fe9..deb5571 100644 --- a/src/backup.py +++ b/src/backup.py @@ -1,14 +1,24 @@ import logging import logging.config import inspect -from os.path import exists +import shutil +from os.path import exists, join class Backup(): - def __init__(self, name:str, dest_path:str, logger:logging.Logger=None) -> None: + def __init__(self, name:str, dest_path:str, ignored:str, logger:logging.Logger=None) -> None: + """Initializes Backup object. + + Args: + name (str): Backup name. + dest_path (str): Destination path of the backup. + ignored (str): Ignored patterns of the backup. + logger (logging.Logger, optional): Logger for the class. Defaults to None. + """ self.logger = logger self.name = name self.dest_path = dest_path self.completed = False + self.ignored = ignored self.logger.info(f"Backup {self.name} initialized.") @@ -102,6 +112,15 @@ def dest_path(self, dest_path:str) -> None: except AttributeError: self.logger.debug(f"Setting destination path of the backup to {dest_path=}.") self._dest_path = dest_path + + backup_path = join(self.dest_path, self.name) + try: + if exists(f"{backup_path}") and self.get_raw_size() > 0: + self.logger.warning(f"Backup {backup_path} already exists. Marking it as completed.") + self.completed = True + except FileNotFoundError: + pass + @property def completed(self) -> bool: @@ -130,3 +149,98 @@ def completed(self, completed:bool) -> None: else: self.logger.error(f"Change of `completed` property is not allowed for {caller_class=}.") raise PermissionError(f"Change of `completed` property is not allowed for {caller_class=}.") + + @property + def ignored(self) -> str: + """Returns ignored patterns of the backup. + + Returns: + str: Ignored patterns of the backup. + """ + return self._ignored + + @ignored.setter + def ignored(self, ignored:str) -> None: + """Sets ignored files of the backup. + + Args: + ignored (str): Ignored files of the backup. + + Raises: + ValueError: Ignored files of the backup is not valid. + PermissionError: Change of `ignored` property is not allowed for Backup. + """ + + if ignored is None or ignored == "": + self.logger.error(f"Backup {ignored=} is not valid.") + raise ValueError(f"Backup {ignored=} is not valid.") + + # check if ignored will match pattern "*.ext1, *.ext2, *.ext3, ..." + if not all([pattern.startswith("*.") for pattern in ignored.split(", ")]): + self.logger.error(f"Backup {ignored=} is not valid.") + raise ValueError(f"Backup {ignored=} is not valid.") + + try: + if self._ignored != "": + self.logger.error(f"Cannot change ignored files of the backup.") + raise PermissionError(f"Cannot change ignored files of the backup.") + except AttributeError: + self.logger.debug(f"Setting ignored files of the backup to {ignored=}.") + self._ignored = ignored + + def get_raw_size(self) -> int: + """Returns raw size of the backup. + + Returns: + int: Raw size of the backup. + + Raises: + FileNotFoundError: Backup does not exist. + """ + backup_path = join(self.dest_path, self.name) + self.logger.debug(f"Getting raw size of the backup {self.name}.") + + if not exists(backup_path): + self.logger.error(f"Backup {backup_path} does not exist.") + raise FileNotFoundError(f"Backup {backup_path} does not exist.") + + return shutil.disk_usage(backup_path).used + + + def create_raw_backup(self, src_path:str) -> None: + """Creates raw backup of the `src_path` to the `dest_path`. + + Args: + src_path (str): Source path of the backup. + + Raises: + FileExistsError: Backup is already completed. + FileNotFoundError: Source path of the backup does not exist. + shutil.Error: Backup failed. + """ + if self.completed: + self.logger.error(f"Backup {self.name=} is already completed.") + raise FileExistsError(f"Backup {self.name=} is already completed.") + + if not exists(src_path): + self.logger.error(f"Backup {src_path=} does not exist.") + raise FileNotFoundError(f"Backup {src_path=} does not exist.") + + ignored_extensions = [x for x in self.ignored.split(", ")] + + try: + self.logger.debug(f"Creating raw backup of {src_path=} to {self.dest_path=}.") + shutil.copytree(src_path, + self.dest_path, + symlinks=True, + ignore_dangling_symlinks=True, + ignore=shutil.ignore_patterns(*ignored_extensions)) + except shutil.Error as e: + self.logger.exception(f"Backup {self.name=} failed. Exception: {e}.") + raise e + + self.completed = True + self.logger.debug(f"Backup {self.name=} completed.") + + + \ No newline at end of file diff --git a/src/log_dev.conf b/src/log_dev.conf index 7ad6f28..8b894e0 100644 --- a/src/log_dev.conf +++ b/src/log_dev.conf @@ -27,7 +27,7 @@ args=(sys.stdout,) class=handlers.TimedRotatingFileHandler level=DEBUG formatter=fileFormater -args=('./log.log', "D", 7, 10) +args=('./test-logs/log.log', "D", 7, 10) [formatter_consoleFormatter] format=%(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s From 52817595448dab161c6ba917ef5ae696416629ca Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Tue, 7 Nov 2023 19:14:11 +0100 Subject: [PATCH 07/28] . --- .gitignore | 9 +-- src/backup.py | 167 ++++++++++++++++++++++++++++++++++++++--------- src/log_dev.conf | 2 +- src/tools.py | 45 +++++++++++++ 4 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 src/tools.py diff --git a/.gitignore b/.gitignore index 616cda5..2c112dc 100644 --- a/.gitignore +++ b/.gitignore @@ -130,9 +130,9 @@ dmypy.json .vscode *.code-workspace -.test-source -.test-target -.test-logs +test-source/* +test-target/* +test-logs/* test.cmd test_docker_compose.yaml # ignore all files in source directory @@ -147,4 +147,5 @@ logs/* secrets/* *.png.bkp -*.png.dtmp \ No newline at end of file +*.png.dtmp +.sync-exclude.lst \ No newline at end of file diff --git a/src/backup.py b/src/backup.py index deb5571..3c99c5c 100644 --- a/src/backup.py +++ b/src/backup.py @@ -2,10 +2,15 @@ import logging.config import inspect import shutil -from os.path import exists, join +from os import walk +from os.path import exists, join, normpath +from tools import size_to_human_readable +from zipfile import ZipFile, ZIP_DEFLATED, ZIP_BZIP2, ZIP_LZMA +from threading import Lock +from concurrent.futures import ThreadPoolExecutor class Backup(): - def __init__(self, name:str, dest_path:str, ignored:str, logger:logging.Logger=None) -> None: + def __init__(self, name:str, dest_path:str, ignored:str = None, logger:logging.Logger=None) -> None: """Initializes Backup object. Args: @@ -15,12 +20,29 @@ def __init__(self, name:str, dest_path:str, ignored:str, logger:logging.Logger=N logger (logging.Logger, optional): Logger for the class. Defaults to None. """ self.logger = logger + self.completed = False self.name = name self.dest_path = dest_path - self.completed = False self.ignored = ignored + self.compressed = False self.logger.info(f"Backup {self.name} initialized.") + def __str__(self) -> str: + """Returns string representation of the backup. + + Returns: + str: String representation of the backup. + """ + + size = size_to_human_readable(self.get_raw_size()) + + return f"Backup {self.name}:\n" \ + f" Destination path: {self.dest_path}\n" \ + f" Completed: {self.completed}\n" \ + f" Ignored: {self.ignored}\n" \ + f" Size: {size}\n" \ + f" Compressed: {self.compressed}\n" + @property def logger(self) -> logging.Logger: @@ -65,15 +87,15 @@ def name(self, name:str) -> None: PermissionError: Change of `name` property is not allowed for Backup. """ if name is None or name == "": - self.logger.error(f"Backup {name=} is not valid.") - raise ValueError(f"Backup {name=} is not valid.") + self.logger.error(f"Backup {name} is not valid.") + raise ValueError(f"Backup {name} is not valid.") try: if self._name != "": self.logger.error(f"Cannot change name of the backup.") raise PermissionError(f"Cannot change name of the backup.") except AttributeError: - self.logger.debug(f"Setting name of the backup to {name=}.") + self.logger.debug(f"Setting name of the backup to {name}.") self._name = name @property @@ -98,24 +120,25 @@ def dest_path(self, dest_path:str) -> None: PermissionError: Change of `dest_path` property is not allowed for Backup. """ if dest_path is None or dest_path == "": - self.logger.error(f"Backup {dest_path=} is not valid.") - raise ValueError(f"Backup {dest_path=} is not valid.") + self.logger.error(f"Backup {dest_path} is not valid.") + raise ValueError(f"Backup {dest_path} is not valid.") if not exists(dest_path): - self.logger.error(f"Backup {dest_path=} does not exist.") - raise FileNotFoundError(f"Backup {dest_path=} does not exist.") + self.logger.error(f"Backup {dest_path} does not exist.") + raise FileNotFoundError(f"Backup {dest_path} does not exist.") try: if self._dest_path != "": self.logger.error(f"Cannot change destination path of the backup.") raise PermissionError(f"Cannot change destination path of the backup.") except AttributeError: - self.logger.debug(f"Setting destination path of the backup to {dest_path=}.") + self.logger.debug(f"Setting destination path of the backup to {dest_path}.") self._dest_path = dest_path - - backup_path = join(self.dest_path, self.name) + + backup_path = join(dest_path, self.name) + try: - if exists(f"{backup_path}") and self.get_raw_size() > 0: + if exists(backup_path) and self.get_raw_size() > 0: self.logger.warning(f"Backup {backup_path} already exists. Marking it as completed.") self.completed = True except FileNotFoundError: @@ -144,11 +167,11 @@ def completed(self, completed:bool) -> None: caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ if caller_class == self.__class__.__name__: - self.logger.debug(f"Setting completed property of the backup to {completed=}.") + self.logger.debug(f"Setting completed property of the backup to {completed}.") self._completed = completed else: - self.logger.error(f"Change of `completed` property is not allowed for {caller_class=}.") - raise PermissionError(f"Change of `completed` property is not allowed for {caller_class=}.") + self.logger.error(f"Change of `completed` property is not allowed for {caller_class}.") + raise PermissionError(f"Change of `completed` property is not allowed for {caller_class}.") @property def ignored(self) -> str: @@ -172,22 +195,50 @@ def ignored(self, ignored:str) -> None: """ if ignored is None or ignored == "": - self.logger.error(f"Backup {ignored=} is not valid.") - raise ValueError(f"Backup {ignored=} is not valid.") + ignored = "*.sock, *.pid, *.lock" # check if ignored will match pattern "*.ext1, *.ext2, *.ext3, ..." if not all([pattern.startswith("*.") for pattern in ignored.split(", ")]): - self.logger.error(f"Backup {ignored=} is not valid.") - raise ValueError(f"Backup {ignored=} is not valid.") + self.logger.error(f"Backup {ignored} is not valid.") + raise ValueError(f"Backup {ignored} is not valid.") try: if self._ignored != "": self.logger.error(f"Cannot change ignored files of the backup.") raise PermissionError(f"Cannot change ignored files of the backup.") except AttributeError: - self.logger.debug(f"Setting ignored files of the backup to {ignored=}.") + self.logger.debug(f"Setting ignored files of the backup to {ignored}.") self._ignored = ignored + @property + def compressed(self) -> bool: + """Returns True if backup is compressed, False otherwise. + + Returns: + bool: True if backup is compressed, False otherwise. + """ + return self._compressed + + @compressed.setter + def compressed(self, compressed:bool) -> None: + """Sets compressed property of the backup. + + Args: + compressed (bool): Compressed property of the backup. + + Raises: + PermissionError: Change of `compressed` property is not allowed for Backup. + """ + caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ + + if caller_class == self.__class__.__name__: + self.logger.debug(f"Setting compressed property of the backup to {compressed}.") + self._compressed = compressed + else: + self.logger.error(f"Change of `compressed` property is not allowed for {caller_class}.") + raise PermissionError(f"Change of `compressed` property is not allowed for {caller_class}.") + + def get_raw_size(self) -> int: """Returns raw size of the backup. @@ -204,7 +255,11 @@ def get_raw_size(self) -> int: self.logger.error(f"Backup {backup_path} does not exist.") raise FileNotFoundError(f"Backup {backup_path} does not exist.") - return shutil.disk_usage(backup_path).used + size = sum(os.path.getsize(join(root, file)) for root, dirs, files in os.walk(backup_path) for file in files) + + self.logger.debug(f"Raw size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + + return size def create_raw_backup(self, src_path:str) -> None: @@ -219,28 +274,76 @@ def create_raw_backup(self, src_path:str) -> None: shutil.Error: Backup failed. """ if self.completed: - self.logger.error(f"Backup {self.name=} is already completed.") - raise FileExistsError(f"Backup {self.name=} is already completed.") + self.logger.error(f"Backup {self.name} is already completed.") + raise FileExistsError(f"Backup {self.name} is already completed.") if not exists(src_path): - self.logger.error(f"Backup {src_path=} does not exist.") - raise FileNotFoundError(f"Backup {src_path=} does not exist.") + self.logger.error(f"Backup {src_path} does not exist.") + raise FileNotFoundError(f"Backup {src_path} does not exist.") ignored_extensions = [x for x in self.ignored.split(", ")] + backup_path = join(self.dest_path, self.name) + try: - self.logger.debug(f"Creating raw backup of {src_path=} to {self.dest_path=}.") + self.logger.debug(f"Creating raw backup of {src_path} to {self.dest_path}.") shutil.copytree(src_path, - self.dest_path, + backup_path, symlinks=True, ignore_dangling_symlinks=True, ignore=shutil.ignore_patterns(*ignored_extensions)) except shutil.Error as e: - self.logger.exception(f"Backup {self.name=} failed. Exception: {e}.") + self.logger.exception(f"Backup {self.name} failed. Exception: {e}.") raise e self.completed = True - self.logger.debug(f"Backup {self.name=} completed.") + self.logger.debug(f"Backup {self.name} completed.") + + def _add_to_zip(self, lock: Lock, handle: ZipFile, file_paths_batch: list) -> None: + + file_data = [] + backup_path = join(self.dest_path, self.name) + for file_path in file_paths_batch: + with open(join(backup_path, file_path), 'r') as f: + file_data.append(f.read()) + + with lock: + for file_path, data in zip(file_paths_batch, file_data): + handle.writestr(file_path, data) + print(f"Added {file_path} to zip.") + + def compress_raw_backup(self) -> None: + + if not self.completed: + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + if self.compressed: + self.logger.warning(f"Backup {self.name} is already compressed. Nothing to do 😍.") + return + + self.logger.debug(f"Compressing raw backup {self.name}.") + backup_path = join(self.dest_path, self.name) + + file_paths = [] + + for root, dirs, files in walk(backup_path): + for file in files: + file_paths.append(normpath(join(root, file).lstrip(backup_path))) + + lock = Lock() + n_workers = 20 + chunk_size = len(file_paths) // n_workers + + with ZipFile(f"{backup_path}.zip", 'w', compression=ZIP_DEFLATED) as handle: + with ThreadPoolExecutor(n_workers) as executor: + for i in range(0, len(file_paths), chunk_size): + + file_paths_batch = file_paths[i:i+chunk_size] + + _ = executor.submit(self._add_to_zip, lock, handle, file_paths_batch) - \ No newline at end of file +backup = Backup(name="test", dest_path="../test-target") +backup.create_raw_backup(src_path="../test-source") +backup.compress_raw_backup() \ No newline at end of file diff --git a/src/log_dev.conf b/src/log_dev.conf index 8b894e0..27bea7d 100644 --- a/src/log_dev.conf +++ b/src/log_dev.conf @@ -27,7 +27,7 @@ args=(sys.stdout,) class=handlers.TimedRotatingFileHandler level=DEBUG formatter=fileFormater -args=('./test-logs/log.log', "D", 7, 10) +args=('../test-logs/log.log', "D", 7, 10) [formatter_consoleFormatter] format=%(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s diff --git a/src/tools.py b/src/tools.py new file mode 100644 index 0000000..a21a1c3 --- /dev/null +++ b/src/tools.py @@ -0,0 +1,45 @@ +from datetime import datetime + +def size_to_human_readable(size: int) -> str: + """Converts the size in bytes to a human readable format. + + Args: + size (int): Size in bytes. + + Returns: + str: Human readable size. + """ + if isinstance(size, str): + try: + size = int(size) + except ValueError: + return f"{size}B" + power = 2 ** 10 + n = 0 + power_labels = {0: '', 1: 'k', 2: 'M', 3: 'G', 4: 'T'} + while size > power: + size /= power + n += 1 + return f"{size:.2f}{power_labels[n]}B" + +def timestamp_to_human_readable(timestamp: int) -> str: + """Converts the timestamp to a human readable format. + + Args: + timestamp (int): Timestamp. + + Returns: + str: Human readable timestamp. + """ + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + +def timestamp_to_file_name(timestamp: int) -> str: + """Converts the timestamp to a file name. + + Args: + timestamp (int): Timestamp. + + Returns: + str: File name. + """ + return datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M_%S") From 1c4e0bac45e5b4ea4f8b44b7febaa468521575e2 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Tue, 7 Nov 2023 23:58:50 +0100 Subject: [PATCH 08/28] . --- src/backup.py | 228 ++++++++++++++++++++++++++++++++++++++++++++------ src/tools.py | 19 +++++ 2 files changed, 222 insertions(+), 25 deletions(-) diff --git a/src/backup.py b/src/backup.py index 3c99c5c..6dac0e1 100644 --- a/src/backup.py +++ b/src/backup.py @@ -3,11 +3,13 @@ import inspect import shutil from os import walk -from os.path import exists, join, normpath -from tools import size_to_human_readable -from zipfile import ZipFile, ZIP_DEFLATED, ZIP_BZIP2, ZIP_LZMA +from os.path import exists, join, normpath, getsize +from tools import size_to_human_readable, timeit +from zipfile import ZipFile, ZIP_BZIP2 from threading import Lock from concurrent.futures import ThreadPoolExecutor +from multiprocessing import cpu_count +from hashlib import md5, sha256, sha512, sha1 class Backup(): def __init__(self, name:str, dest_path:str, ignored:str = None, logger:logging.Logger=None) -> None: @@ -255,10 +257,28 @@ def get_raw_size(self) -> int: self.logger.error(f"Backup {backup_path} does not exist.") raise FileNotFoundError(f"Backup {backup_path} does not exist.") - size = sum(os.path.getsize(join(root, file)) for root, dirs, files in os.walk(backup_path) for file in files) - + size = sum(getsize(join(root, file)) for root, dirs, files in walk(backup_path) for file in files) self.logger.debug(f"Raw size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + return size + + def get_compressed_size(self) -> int: + """Returns compressed size of the backup. + + Raises: + FileNotFoundError: Backup does not exist. + + Returns: + int: Compressed size of the backup. + """ + backup_path = join(self.dest_path, self.name) + self.logger.debug(f"Getting compressed size of the backup {self.name}.") + + if not exists(f"{backup_path}.zip"): + self.logger.error(f"Backup {backup_path}.zip does not exist.") + raise FileNotFoundError(f"Backup {backup_path}.zip does not exist.") + size = getsize(f"{backup_path}.zip") + self.logger.debug(f"Compressed size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") return size @@ -300,26 +320,33 @@ def create_raw_backup(self, src_path:str) -> None: self.logger.debug(f"Backup {self.name} completed.") def _add_to_zip(self, lock: Lock, handle: ZipFile, file_paths_batch: list) -> None: - - file_data = [] - backup_path = join(self.dest_path, self.name) + """Adds files to the zip file. + + Args: + lock (Lock): Lock for the zip file. + handle (ZipFile): Zip file handle. + file_paths_batch (list): List of file paths to add to the zip file. + """ + backup_path = normpath(join(self.dest_path, self.name)) + + with lock: for file_path in file_paths_batch: - with open(join(backup_path, file_path), 'r') as f: - file_data.append(f.read()) - - with lock: - for file_path, data in zip(file_paths_batch, file_data): - handle.writestr(file_path, data) - print(f"Added {file_path} to zip.") + handle.write(file_path, normpath(file_path).replace(backup_path, "").lstrip("\\").lstrip("/")) - def compress_raw_backup(self) -> None: - + def compress_raw_backup(self) -> None: + """Compresses raw backup to the zip file. + + Raises: + FileNotFoundError: Backup is not completed. + FileNotFoundError: Zip file was not created. + """ + if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") if self.compressed: - self.logger.warning(f"Backup {self.name} is already compressed. Nothing to do 😍.") + self.logger.info(f"Backup {self.name} is already compressed. Nothing to do 😍.") return self.logger.debug(f"Compressing raw backup {self.name}.") @@ -327,23 +354,174 @@ def compress_raw_backup(self) -> None: file_paths = [] - for root, dirs, files in walk(backup_path): + for root, _, files in walk(backup_path): for file in files: - file_paths.append(normpath(join(root, file).lstrip(backup_path))) - + file_paths.append(normpath(join(root, file))) + lock = Lock() - n_workers = 20 + n_workers = cpu_count() * 2 + + self.logger.debug(f"Using {n_workers} workers to compress the backup.") + chunk_size = len(file_paths) // n_workers + if chunk_size == 0: + chunk_size = 1 - with ZipFile(f"{backup_path}.zip", 'w', compression=ZIP_DEFLATED) as handle: - with ThreadPoolExecutor(n_workers) as executor: + with ZipFile(f"{backup_path}.zip", 'w', compression=ZIP_BZIP2) as handle: + with ThreadPoolExecutor(max_workers=n_workers) as executor: for i in range(0, len(file_paths), chunk_size): file_paths_batch = file_paths[i:i+chunk_size] _ = executor.submit(self._add_to_zip, lock, handle, file_paths_batch) + + if not exists(f"{backup_path}.zip"): + self.logger.error(f"Zip file {backup_path}.zip was not created.") + raise FileNotFoundError(f"Zip file {backup_path}.zip was not created.") + + self.compressed = True + self.logger.debug(f"Backup {self.name} compressed.") + + def delete_raw_backup(self) -> None: + """Deletes raw backup. + + Raises: + FileNotFoundError: Backup is not completed. + """ + if not self.completed: + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + self.logger.debug(f"Deleting raw backup {self.name}.") + backup_path = join(self.dest_path, self.name) + + shutil.rmtree(backup_path) + + self.completed = False + self.logger.debug(f"Backup {self.name} deleted.") + + def delete_compressed_backup(self) -> None: + """Deletes compressed backup. + + Raises: + FileNotFoundError: Backup is not completed. + """ + if not self.completed: + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + self.logger.debug(f"Deleting compressed backup {self.name}.") + backup_path = join(self.dest_path, self.name) + + shutil.rmtree(f"{backup_path}.zip") + + self.compressed = False + self.logger.debug(f"Backup {self.name} deleted.") + + def delete_backup(self) -> None: + """Deletes backup. + + Raises: + FileNotFoundError: Backup is not completed. + """ + if not self.completed: + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + self.logger.debug(f"Deleting backup {self.name}.") + backup_path = join(self.dest_path, self.name) + + shutil.rmtree(backup_path) + shutil.rmtree(f"{backup_path}.zip") + + self.completed = False + self.compressed = False + self.logger.debug(f"Backup {self.name} deleted.") + + def restore_backup_from_raw(self, dest_path:str) -> None: + """Restores backup from raw. + + Args: + dest_path (str): Destination path of the backup. + + Raises: + FileExistsError: Backup is already completed. + FileNotFoundError: Backup does not exist. + shutil.Error: Backup failed. + """ + if self.completed: + self.logger.error(f"Backup {self.name} is not completed.") + raise FileExistsError(f"Backup {self.name} is not completed.") + + backup_path = join(self.dest_path, self.name) + if not exists(backup_path): + self.logger.error(f"Backup {backup_path} does not exist.") + raise FileNotFoundError(f"Backup {backup_path} does not exist.") + + try: + self.logger.debug(f"Restoring backup {self.name} from raw to {dest_path}.") + shutil.copytree(backup_path, + dest_path, + symlinks=True, + dirs_exist_ok=True, + ignore_dangling_symlinks=True) + except shutil.Error as e: + self.logger.exception(f"Backup {self.name} failed. Exception: {e}.") + raise e + + self.completed = True + self.logger.debug(f"Backup {self.name} completed.") + + def unpack_compressed(self) -> None: + """Unpacks compressed backup. + + Raises: + FileNotFoundError: Zip file was not created. + """ + if not self.compressed or not exists(f"{self.dest_path}.zip"): + self.logger.error(f"Backup {self.name} is not compressed.") + raise FileNotFoundError(f"Backup {self.name} is not compressed.") + + self.logger.debug(f"Unpacking compressed backup {self.name}.") + backup_path = join(self.dest_path, self.name) + + with ZipFile(f"{backup_path}.zip", 'r') as handle: + handle.extractall(backup_path) + + self.completed = True + + self.logger.debug(f"Backup {self.name} unpacked.") + + def calculate_raw_md5(self) -> str: + """Calculates MD5 hash of the raw backup. + + Returns: + str: MD5 hash of the raw backup. + """ + backup_path = join(self.dest_path, self.name) + + md5_hash = md5() + + for root, _, files in walk(backup_path): + for file in files: + with open(join(root, file), "rb") as handle: + md5_hash.update(handle.read()) + + md5_hash = md5_hash.hexdigest() + + self.logger.debug(f"MD5 hash of the raw backup {self.name} is {md5_hash}.") + return md5_hash + + +shutil.rmtree("../test-target/test", ignore_errors=True) +shutil.rmtree("../test-target/test.zip", ignore_errors=True) + backup = Backup(name="test", dest_path="../test-target") backup.create_raw_backup(src_path="../test-source") -backup.compress_raw_backup() \ No newline at end of file +backup.calculate_raw_md5() +backup.compress_raw_backup() +backup.delete_raw_backup() +backup.unpack_compressed() +backup.calculate_raw_md5() diff --git a/src/tools.py b/src/tools.py index a21a1c3..f2e1aef 100644 --- a/src/tools.py +++ b/src/tools.py @@ -1,4 +1,5 @@ from datetime import datetime +from time import perf_counter def size_to_human_readable(size: int) -> str: """Converts the size in bytes to a human readable format. @@ -43,3 +44,21 @@ def timestamp_to_file_name(timestamp: int) -> str: str: File name. """ return datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M_%S") + + +def timeit(func): + """Decorator to measure the execution time of a function. + + Args: + func (function): Function to measure. + + Returns: + function: Decorated function. + """ + def wrapper(*args, **kwargs): + start = perf_counter() + result = func(*args, **kwargs) + end = perf_counter() + print(f"Time elapsed: {end - start:.2f}s") + return result + return wrapper \ No newline at end of file From f2b60a774b1666191679661ba878cefb3f88b1f0 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 8 Nov 2023 23:14:51 +0100 Subject: [PATCH 09/28] Backup module --- PyBackUpper Schema.png | Bin 21422 -> 49266 bytes src/backup.py | 185 ++++++++++++++++++++++++++++++++++------- 2 files changed, 153 insertions(+), 32 deletions(-) diff --git a/PyBackUpper Schema.png b/PyBackUpper Schema.png index a91f1a057c2bcb8a8e9ada73725e0a14d26e97b8..3dace8b9ae12480137d14d8c1b6d4c923dad0ab7 100644 GIT binary patch literal 49266 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 XRMAvQt9$SP@Tab#tz4vdC-DCO9xvwq literal 21422 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 int: size = getsize(f"{backup_path}.zip") self.logger.debug(f"Compressed size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") return size + + def get_size(self) -> int: + """Returns size of the backup. + + Returns: + int: Size of the backup. + """ + try: + raw_size = self.get_raw_size() if self.completed else 0 + except FileNotFoundError: + raw_size = 0 + + try: + compressed_size = self.get_compressed_size() if self.compressed else 0 + except FileNotFoundError: + compressed_size = 0 + + size = raw_size + compressed_size + + self.logger.debug(f"Size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + return size def create_raw_backup(self, src_path:str) -> None: @@ -439,31 +460,27 @@ def delete_backup(self) -> None: self.compressed = False self.logger.debug(f"Backup {self.name} deleted.") - def restore_backup_from_raw(self, dest_path:str) -> None: + def restore_backup_from_raw(self, restore_path:str) -> None: """Restores backup from raw. Args: - dest_path (str): Destination path of the backup. + restore_path (str): Destination path of the backup. Raises: FileExistsError: Backup is already completed. FileNotFoundError: Backup does not exist. shutil.Error: Backup failed. """ - if self.completed: - self.logger.error(f"Backup {self.name} is not completed.") - raise FileExistsError(f"Backup {self.name} is not completed.") - backup_path = join(self.dest_path, self.name) - if not exists(backup_path): - self.logger.error(f"Backup {backup_path} does not exist.") - raise FileNotFoundError(f"Backup {backup_path} does not exist.") + if not self.completed or not exists(backup_path): + self.logger.error(f"Backup {self.name} is not completed.") + raise FileExistsError(f"Backup {self.name} is not completed.") try: - self.logger.debug(f"Restoring backup {self.name} from raw to {dest_path}.") + self.logger.debug(f"Restoring backup {self.name} from raw to {restore_path}.") shutil.copytree(backup_path, - dest_path, + restore_path, symlinks=True, dirs_exist_ok=True, ignore_dangling_symlinks=True) @@ -480,7 +497,8 @@ def unpack_compressed(self) -> None: Raises: FileNotFoundError: Zip file was not created. """ - if not self.compressed or not exists(f"{self.dest_path}.zip"): + backup_path = join(self.dest_path, self.name) + if not self.compressed or not exists(f"{backup_path}.zip"): self.logger.error(f"Backup {self.name} is not compressed.") raise FileNotFoundError(f"Backup {self.name} is not compressed.") @@ -494,34 +512,137 @@ def unpack_compressed(self) -> None: self.logger.debug(f"Backup {self.name} unpacked.") - def calculate_raw_md5(self) -> str: - """Calculates MD5 hash of the raw backup. + def calculate_raw_hash(self, method:str) -> str: + """Calculates hash of the raw backup. Returns: - str: MD5 hash of the raw backup. + str: hash of the raw backup. + Raises: + FileNotFoundError: Backup is not completed. + ValueError: Method is not supported. """ + methods = { + "md5": md5, + "sha1": sha1, + "sha256": sha256, + "sha512": sha512 + } + + if method not in methods.keys(): + self.logger.error(f"Method {method} is not supported.") + raise ValueError(f"Method {method} is not supported. Supported methods: md5, sha1, sha256, sha512.") + backup_path = join(self.dest_path, self.name) - md5_hash = md5() + if not exists(backup_path): + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + if not self.completed: + self.logger.warning(f"Backup {self.name} is not completed. Calculating hash of the incomplete backup.") + + hash = methods[method]() for root, _, files in walk(backup_path): for file in files: with open(join(root, file), "rb") as handle: - md5_hash.update(handle.read()) + hash.update(handle.read()) - md5_hash = md5_hash.hexdigest() + hash = hash.hexdigest() - self.logger.debug(f"MD5 hash of the raw backup {self.name} is {md5_hash}.") - return md5_hash + self.logger.debug(f"{method} hash of the raw backup {self.name} is {hash}.") + return hash + + def calculate_compressed_hash(self, method) -> str: + """Calculates hash of the compressed backup. + + Returns: + str: hash of the compressed backup. + Raises: + FileNotFoundError: Backup is not completed. + ValueError: Method is not supported. + """ + + methods = { + "md5": md5, + "sha1": sha1, + "sha256": sha256, + "sha512": sha512 + } + + if method not in methods.keys(): + self.logger.error(f"Method {method} is not supported.") + raise ValueError(f"Method {method} is not supported. Supported methods: md5, sha1, sha256, sha512.") + + backup_path = join(self.dest_path, self.name) + + if not exists(f"{backup_path}.zip"): + self.logger.error(f"Backup {self.name} is not completed.") + raise FileNotFoundError(f"Backup {self.name} is not completed.") + + if not self.completed: + self.logger.warning(f"Backup {self.name} is not completed. Calculating hash of the incomplete backup.") + + hash = methods[method]() + + with open(f"{backup_path}.zip", "rb") as handle: + hash.update(handle.read()) + + hash = hash.hexdigest() + + self.logger.debug(f"{method} hash of the compressed backup {self.name} is {hash}.") + return hash + + def restore_backup(self, restore_path:str) -> bool: + """Restores backup. + + Args: + restore_path: Path to restore the backup. + + Raises: + FileNotFoundError: Restore path does not exist. + FileNotFoundError: Backup is not available. + ValueError: Restore path is not valid. + + Returns: + bool: True if backup was restored successfully, False if errors occurred. + """ + if restore_path is None or restore_path == "": + self.logger.error(f"Restore path {restore_path} is not valid.") + raise ValueError(f"Restore path {restore_path} is not valid.") + + if not exists(restore_path): + self.logger.error(f"Restore path {restore_path} does not exist.") + raise FileNotFoundError(f"Restore path {restore_path} does not exist.") + + if self.completed: + self.logger.info(f"Restoring backup {self.name} from raw to {restore_path}.") + self.restore_backup_from_raw(restore_path) + + elif self.compressed: + self.logger.info(f"Restoring backup {self.name} from compressed to {restore_path}.") + self.unpack_compressed() + self.restore_backup_from_raw(restore_path) + + else: + self.logger.error(f"Backup {self.name} is not available.") + raise FileNotFoundError(f"Backup {self.name} is not available.") + + + backup_hash = self.calculate_raw_hash(method="sha256") + restore_hash = sha256() + + for root, _, files in walk(restore_path): + for file in files: + with open(join(root, file), "rb") as handle: + restore_hash.update(handle.read()) + + restore_hash = restore_hash.hexdigest() + + if backup_hash == restore_hash: + self.logger.info(f"Backup {self.name} restored to {restore_path} successfully.") + return True + else: + self.logger.warning(f"Backup {self.name} restored to {restore_path}, but hashes are different. Backup hash: {backup_hash}, restore hash: {restore_hash}.") + return False - -shutil.rmtree("../test-target/test", ignore_errors=True) -shutil.rmtree("../test-target/test.zip", ignore_errors=True) - -backup = Backup(name="test", dest_path="../test-target") -backup.create_raw_backup(src_path="../test-source") -backup.calculate_raw_md5() -backup.compress_raw_backup() -backup.delete_raw_backup() -backup.unpack_compressed() -backup.calculate_raw_md5() From 15dff784df8f6bc2e8632714a69a6ba3631ad322 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 8 Nov 2023 23:16:45 +0100 Subject: [PATCH 10/28] todo --- src/backup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backup.py b/src/backup.py index 984b091..09cf98d 100644 --- a/src/backup.py +++ b/src/backup.py @@ -231,6 +231,7 @@ def compressed(self, compressed:bool) -> None: Raises: PermissionError: Change of `compressed` property is not allowed for Backup. """ + # TODO: check if zip file exists caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ if caller_class == self.__class__.__name__: From 2fc91d2b38bd862c79b6bbd66ec85b62aa0bd3ca Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 9 Nov 2023 18:48:33 +0100 Subject: [PATCH 11/28] backup module --- src/backup.py | 54 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/backup.py b/src/backup.py index 09cf98d..ba97e3b 100644 --- a/src/backup.py +++ b/src/backup.py @@ -2,7 +2,7 @@ import logging.config import inspect import shutil -from os import walk +from os import walk, remove from os.path import exists, join, normpath, getsize from tools import size_to_human_readable from zipfile import ZipFile, ZIP_BZIP2 @@ -22,12 +22,19 @@ def __init__(self, name:str, dest_path:str, ignored:str = None, logger:logging.L logger (logging.Logger, optional): Logger for the class. Defaults to None. """ self.logger = logger - self.completed = False self.name = name self.dest_path = dest_path self.ignored = ignored - self.compressed = False - self.logger.info(f"Backup {self.name} initialized.") + + try: + size = self.get_raw_size() + self.completed = True if size > 0 else False + except FileNotFoundError: + self.completed = False + + self.compressed = True if exists(f"{join(self.dest_path, self.name)}.zip") else False + + self.logger.info(f"Backup {self.name} initialized.\n{self}") def __str__(self) -> str: """Returns string representation of the backup. @@ -36,13 +43,13 @@ def __str__(self) -> str: str: String representation of the backup. """ - size = size_to_human_readable(self.get_raw_size()) + size = size_to_human_readable(self.get_size()) return f"Backup {self.name}:\n" \ f" Destination path: {self.dest_path}\n" \ - f" Completed: {self.completed}\n" \ f" Ignored: {self.ignored}\n" \ f" Size: {size}\n" \ + f" Completed: {self.completed}\n" \ f" Compressed: {self.compressed}\n" @@ -231,7 +238,6 @@ def compressed(self, compressed:bool) -> None: Raises: PermissionError: Change of `compressed` property is not allowed for Backup. """ - # TODO: check if zip file exists caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ if caller_class == self.__class__.__name__: @@ -259,7 +265,7 @@ def get_raw_size(self) -> int: raise FileNotFoundError(f"Backup {backup_path} does not exist.") size = sum(getsize(join(root, file)) for root, dirs, files in walk(backup_path) for file in files) - self.logger.debug(f"Raw size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + self.logger.debug(f"Raw size of the backup {self.name} is {size_to_human_readable(size)}.") return size def get_compressed_size(self) -> int: @@ -279,7 +285,7 @@ def get_compressed_size(self) -> int: raise FileNotFoundError(f"Backup {backup_path}.zip does not exist.") size = getsize(f"{backup_path}.zip") - self.logger.debug(f"Compressed size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + self.logger.debug(f"Compressed size of the backup {self.name} is {size_to_human_readable(size)}.") return size def get_size(self) -> int: @@ -362,13 +368,13 @@ def compress_raw_backup(self) -> None: FileNotFoundError: Backup is not completed. FileNotFoundError: Zip file was not created. """ - + if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") if self.compressed: - self.logger.info(f"Backup {self.name} is already compressed. Nothing to do 😍.") + self.logger.info(f"Backup {self.name} is already compressed. Nothing to do :).") return self.logger.debug(f"Compressing raw backup {self.name}.") @@ -385,7 +391,7 @@ def compress_raw_backup(self) -> None: n_workers = cpu_count() * 2 self.logger.debug(f"Using {n_workers} workers to compress the backup.") - + chunk_size = len(file_paths) // n_workers if chunk_size == 0: chunk_size = 1 @@ -452,10 +458,12 @@ def delete_backup(self) -> None: raise FileNotFoundError(f"Backup {self.name} is not completed.") self.logger.debug(f"Deleting backup {self.name}.") - backup_path = join(self.dest_path, self.name) + backup_path = normpath(join(self.dest_path, self.name)) - shutil.rmtree(backup_path) - shutil.rmtree(f"{backup_path}.zip") + shutil.rmtree(backup_path, ignore_errors=True) + + if self.compressed: + remove(f"{backup_path}.zip") self.completed = False self.compressed = False @@ -647,3 +655,19 @@ def restore_backup(self, restore_path:str) -> bool: self.logger.warning(f"Backup {self.name} restored to {restore_path}, but hashes are different. Backup hash: {backup_hash}, restore hash: {restore_hash}.") return False + def calculate_compression_ratio(self) -> float: + """Calculates compression ratio of the backup. + + Returns: + float: Compression ratio of the backup. + """ + try: + raw_size = self.get_raw_size() + compressed_size = self.get_compressed_size() + + ratio = raw_size / compressed_size + except FileNotFoundError: + ratio = 0.0 + + self.logger.debug(f"Compression ratio of the backup {self.name} is {ratio}.") + return ratio From 43baa075bfc821f2e7c12ae922a6d820099b37e7 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 9 Nov 2023 19:43:12 +0100 Subject: [PATCH 12/28] pylint --- src/backup.py | 337 +++++++++++++++++++++------------------- src/telegram_handler.py | 155 ++++++++++++------ 2 files changed, 287 insertions(+), 205 deletions(-) diff --git a/src/backup.py b/src/backup.py index ba97e3b..717130d 100644 --- a/src/backup.py +++ b/src/backup.py @@ -1,18 +1,25 @@ +"""Backup class for pybackupper.""" + import logging import logging.config import inspect import shutil from os import walk, remove from os.path import exists, join, normpath, getsize -from tools import size_to_human_readable from zipfile import ZipFile, ZIP_BZIP2 from threading import Lock from concurrent.futures import ThreadPoolExecutor from multiprocessing import cpu_count from hashlib import md5, sha256, sha512, sha1 +from tools import size_to_human_readable class Backup(): - def __init__(self, name:str, dest_path:str, ignored:str = None, logger:logging.Logger=None) -> None: + """Backup class for pybackupper.""" + def __init__(self, + name:str, + dest_path:str, + ignored:str = None, + logger:logging.Logger=None) -> None: """Initializes Backup object. Args: @@ -25,34 +32,33 @@ def __init__(self, name:str, dest_path:str, ignored:str = None, logger:logging.L self.name = name self.dest_path = dest_path self.ignored = ignored - + try: size = self.get_raw_size() self.completed = True if size > 0 else False except FileNotFoundError: self.completed = False - + self.compressed = True if exists(f"{join(self.dest_path, self.name)}.zip") else False - + self.logger.info(f"Backup {self.name} initialized.\n{self}") - + def __str__(self) -> str: """Returns string representation of the backup. Returns: str: String representation of the backup. """ - + size = size_to_human_readable(self.get_size()) - + return f"Backup {self.name}:\n" \ f" Destination path: {self.dest_path}\n" \ f" Ignored: {self.ignored}\n" \ f" Size: {size}\n" \ f" Completed: {self.completed}\n" \ f" Compressed: {self.compressed}\n" - - + @property def logger(self) -> logging.Logger: """Returns logger for the class. @@ -61,7 +67,7 @@ def logger(self) -> logging.Logger: logging.Logger: Logger for the class. """ return self._logger - + @logger.setter def logger(self, logger:logging.Logger) -> None: """Sets logger for the class. @@ -74,7 +80,7 @@ def logger(self, logger:logging.Logger) -> None: self._logger = logging.getLogger('pybackupper_logger') else: self._logger = logger - + @property def name(self) -> str: """Returns name of the backup. @@ -83,7 +89,7 @@ def name(self) -> str: str: Name of the backup. """ return self._name - + @name.setter def name(self, name:str) -> None: """Sets name of the backup. @@ -98,15 +104,15 @@ def name(self, name:str) -> None: if name is None or name == "": self.logger.error(f"Backup {name} is not valid.") raise ValueError(f"Backup {name} is not valid.") - + try: if self._name != "": - self.logger.error(f"Cannot change name of the backup.") - raise PermissionError(f"Cannot change name of the backup.") + self.logger.error("Cannot change name of the backup.") + raise PermissionError("Cannot change name of the backup.") except AttributeError: self.logger.debug(f"Setting name of the backup to {name}.") self._name = name - + @property def dest_path(self) -> str: """Returns destination path of the backup. @@ -115,7 +121,7 @@ def dest_path(self) -> str: str: Destination path of the backup. """ return self._dest_path - + @dest_path.setter def dest_path(self, dest_path:str) -> None: """Sets destination path of the backup. @@ -131,29 +137,30 @@ def dest_path(self, dest_path:str) -> None: if dest_path is None or dest_path == "": self.logger.error(f"Backup {dest_path} is not valid.") raise ValueError(f"Backup {dest_path} is not valid.") - + if not exists(dest_path): self.logger.error(f"Backup {dest_path} does not exist.") raise FileNotFoundError(f"Backup {dest_path} does not exist.") - + try: if self._dest_path != "": - self.logger.error(f"Cannot change destination path of the backup.") - raise PermissionError(f"Cannot change destination path of the backup.") + self.logger.error("Cannot change destination path of the backup.") + raise PermissionError("Cannot change destination path of the backup.") except AttributeError: self.logger.debug(f"Setting destination path of the backup to {dest_path}.") self._dest_path = dest_path - + backup_path = join(dest_path, self.name) - + try: if exists(backup_path) and self.get_raw_size() > 0: - self.logger.warning(f"Backup {backup_path} already exists. Marking it as completed.") + self.logger.warning(f"Backup {backup_path} already exists. "\ + "Marking it as completed.") self.completed = True except FileNotFoundError: pass - - + + @property def completed(self) -> bool: """Returns True if backup is completed, False otherwise. @@ -162,7 +169,7 @@ def completed(self) -> bool: bool: True if backup is completed, False otherwise. """ return self._completed - + @completed.setter def completed(self, completed:bool) -> None: """Sets completed property of the backup. @@ -174,14 +181,15 @@ def completed(self, completed:bool) -> None: PermissionError: Change of `completed` property is not allowed for Backup. """ caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ - + if caller_class == self.__class__.__name__: self.logger.debug(f"Setting completed property of the backup to {completed}.") self._completed = completed else: self.logger.error(f"Change of `completed` property is not allowed for {caller_class}.") - raise PermissionError(f"Change of `completed` property is not allowed for {caller_class}.") - + raise PermissionError( + f"Change of `completed` property is not allowed for {caller_class}.") + @property def ignored(self) -> str: """Returns ignored patterns of the backup. @@ -190,7 +198,7 @@ def ignored(self) -> str: str: Ignored patterns of the backup. """ return self._ignored - + @ignored.setter def ignored(self, ignored:str) -> None: """Sets ignored files of the backup. @@ -202,23 +210,23 @@ def ignored(self, ignored:str) -> None: ValueError: Ignored files of the backup is not valid. PermissionError: Change of `ignored` property is not allowed for Backup. """ - + if ignored is None or ignored == "": ignored = "*.sock, *.pid, *.lock" - + # check if ignored will match pattern "*.ext1, *.ext2, *.ext3, ..." if not all([pattern.startswith("*.") for pattern in ignored.split(", ")]): self.logger.error(f"Backup {ignored} is not valid.") raise ValueError(f"Backup {ignored} is not valid.") - + try: if self._ignored != "": - self.logger.error(f"Cannot change ignored files of the backup.") - raise PermissionError(f"Cannot change ignored files of the backup.") + self.logger.error("Cannot change ignored files of the backup.") + raise PermissionError("Cannot change ignored files of the backup.") except AttributeError: self.logger.debug(f"Setting ignored files of the backup to {ignored}.") self._ignored = ignored - + @property def compressed(self) -> bool: """Returns True if backup is compressed, False otherwise. @@ -227,7 +235,7 @@ def compressed(self) -> bool: bool: True if backup is compressed, False otherwise. """ return self._compressed - + @compressed.setter def compressed(self, compressed:bool) -> None: """Sets compressed property of the backup. @@ -239,35 +247,37 @@ def compressed(self, compressed:bool) -> None: PermissionError: Change of `compressed` property is not allowed for Backup. """ caller_class = inspect.currentframe().f_back.f_locals.get("self").__class__.__name__ - + if caller_class == self.__class__.__name__: self.logger.debug(f"Setting compressed property of the backup to {compressed}.") self._compressed = compressed else: self.logger.error(f"Change of `compressed` property is not allowed for {caller_class}.") - raise PermissionError(f"Change of `compressed` property is not allowed for {caller_class}.") - - + raise PermissionError( + f"Change of `compressed` property is not allowed for {caller_class}.") + + def get_raw_size(self) -> int: """Returns raw size of the backup. Returns: int: Raw size of the backup. - + Raises: FileNotFoundError: Backup does not exist. """ backup_path = join(self.dest_path, self.name) self.logger.debug(f"Getting raw size of the backup {self.name}.") - + if not exists(backup_path): self.logger.error(f"Backup {backup_path} does not exist.") raise FileNotFoundError(f"Backup {backup_path} does not exist.") - - size = sum(getsize(join(root, file)) for root, dirs, files in walk(backup_path) for file in files) + + size = sum(getsize(join(root, file)) + for root, dirs, files in walk(backup_path) for file in files) self.logger.debug(f"Raw size of the backup {self.name} is {size_to_human_readable(size)}.") return size - + def get_compressed_size(self) -> int: """Returns compressed size of the backup. @@ -279,34 +289,37 @@ def get_compressed_size(self) -> int: """ backup_path = join(self.dest_path, self.name) self.logger.debug(f"Getting compressed size of the backup {self.name}.") - + if not exists(f"{backup_path}.zip"): self.logger.error(f"Backup {backup_path}.zip does not exist.") raise FileNotFoundError(f"Backup {backup_path}.zip does not exist.") - + size = getsize(f"{backup_path}.zip") - self.logger.debug(f"Compressed size of the backup {self.name} is {size_to_human_readable(size)}.") + self.logger.debug( + f"Compressed size of the backup {self.name} is {size_to_human_readable(size)}.") return size - + def get_size(self) -> int: """Returns size of the backup. Returns: int: Size of the backup. - """ + """ try: raw_size = self.get_raw_size() if self.completed else 0 except FileNotFoundError: raw_size = 0 - + try: compressed_size = self.get_compressed_size() if self.compressed else 0 except FileNotFoundError: compressed_size = 0 - + size = raw_size + compressed_size - - self.logger.debug(f"Size of the backup {self.name} is {size}. Human readable: {size_to_human_readable(size)}.") + + self.logger.debug( + f"Size of the backup {self.name} is {size}. "\ + f"Human readable: {size_to_human_readable(size)}.") return size @@ -324,29 +337,29 @@ def create_raw_backup(self, src_path:str) -> None: if self.completed: self.logger.error(f"Backup {self.name} is already completed.") raise FileExistsError(f"Backup {self.name} is already completed.") - + if not exists(src_path): self.logger.error(f"Backup {src_path} does not exist.") raise FileNotFoundError(f"Backup {src_path} does not exist.") - - ignored_extensions = [x for x in self.ignored.split(", ")] - + + ignored_extensions = self.ignored.split(", ") + backup_path = join(self.dest_path, self.name) - + try: self.logger.debug(f"Creating raw backup of {src_path} to {self.dest_path}.") - shutil.copytree(src_path, - backup_path, - symlinks=True, - ignore_dangling_symlinks=True, + shutil.copytree(src_path, + backup_path, + symlinks=True, + ignore_dangling_symlinks=True, ignore=shutil.ignore_patterns(*ignored_extensions)) except shutil.Error as e: self.logger.exception(f"Backup {self.name} failed. Exception: {e}.") raise e - + self.completed = True self.logger.debug(f"Backup {self.name} completed.") - + def _add_to_zip(self, lock: Lock, handle: ZipFile, file_paths_batch: list) -> None: """Adds files to the zip file. @@ -355,13 +368,14 @@ def _add_to_zip(self, lock: Lock, handle: ZipFile, file_paths_batch: list) -> No handle (ZipFile): Zip file handle. file_paths_batch (list): List of file paths to add to the zip file. """ - backup_path = normpath(join(self.dest_path, self.name)) - + backup_path = normpath(join(self.dest_path, self.name)) + with lock: for file_path in file_paths_batch: - handle.write(file_path, normpath(file_path).replace(backup_path, "").lstrip("\\").lstrip("/")) - - def compress_raw_backup(self) -> None: + handle.write(file_path, + normpath(file_path).replace(backup_path, "").lstrip("\\").lstrip("/")) + + def compress_raw_backup(self) -> None: """Compresses raw backup to the zip file. Raises: @@ -372,45 +386,45 @@ def compress_raw_backup(self) -> None: if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + if self.compressed: self.logger.info(f"Backup {self.name} is already compressed. Nothing to do :).") return self.logger.debug(f"Compressing raw backup {self.name}.") backup_path = join(self.dest_path, self.name) - + file_paths = [] - + for root, _, files in walk(backup_path): for file in files: file_paths.append(normpath(join(root, file))) - + lock = Lock() - + n_workers = cpu_count() * 2 - + self.logger.debug(f"Using {n_workers} workers to compress the backup.") chunk_size = len(file_paths) // n_workers if chunk_size == 0: chunk_size = 1 - + with ZipFile(f"{backup_path}.zip", 'w', compression=ZIP_BZIP2) as handle: with ThreadPoolExecutor(max_workers=n_workers) as executor: for i in range(0, len(file_paths), chunk_size): - + file_paths_batch = file_paths[i:i+chunk_size] - + _ = executor.submit(self._add_to_zip, lock, handle, file_paths_batch) - + if not exists(f"{backup_path}.zip"): self.logger.error(f"Zip file {backup_path}.zip was not created.") raise FileNotFoundError(f"Zip file {backup_path}.zip was not created.") - + self.compressed = True self.logger.debug(f"Backup {self.name} compressed.") - + def delete_raw_backup(self) -> None: """Deletes raw backup. @@ -420,15 +434,15 @@ def delete_raw_backup(self) -> None: if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + self.logger.debug(f"Deleting raw backup {self.name}.") backup_path = join(self.dest_path, self.name) - + shutil.rmtree(backup_path) - + self.completed = False self.logger.debug(f"Backup {self.name} deleted.") - + def delete_compressed_backup(self) -> None: """Deletes compressed backup. @@ -438,15 +452,15 @@ def delete_compressed_backup(self) -> None: if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + self.logger.debug(f"Deleting compressed backup {self.name}.") backup_path = join(self.dest_path, self.name) - + shutil.rmtree(f"{backup_path}.zip") - + self.compressed = False self.logger.debug(f"Backup {self.name} deleted.") - + def delete_backup(self) -> None: """Deletes backup. @@ -456,19 +470,19 @@ def delete_backup(self) -> None: if not self.completed: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + self.logger.debug(f"Deleting backup {self.name}.") backup_path = normpath(join(self.dest_path, self.name)) - + shutil.rmtree(backup_path, ignore_errors=True) - + if self.compressed: remove(f"{backup_path}.zip") - + self.completed = False self.compressed = False self.logger.debug(f"Backup {self.name} deleted.") - + def restore_backup_from_raw(self, restore_path:str) -> None: """Restores backup from raw. @@ -481,46 +495,46 @@ def restore_backup_from_raw(self, restore_path:str) -> None: shutil.Error: Backup failed. """ backup_path = join(self.dest_path, self.name) - + if not self.completed or not exists(backup_path): self.logger.error(f"Backup {self.name} is not completed.") raise FileExistsError(f"Backup {self.name} is not completed.") - + try: self.logger.debug(f"Restoring backup {self.name} from raw to {restore_path}.") - shutil.copytree(backup_path, - restore_path, + shutil.copytree(backup_path, + restore_path, symlinks=True, dirs_exist_ok=True, ignore_dangling_symlinks=True) except shutil.Error as e: self.logger.exception(f"Backup {self.name} failed. Exception: {e}.") raise e - + self.completed = True self.logger.debug(f"Backup {self.name} completed.") - + def unpack_compressed(self) -> None: """Unpacks compressed backup. Raises: FileNotFoundError: Zip file was not created. - """ + """ backup_path = join(self.dest_path, self.name) if not self.compressed or not exists(f"{backup_path}.zip"): self.logger.error(f"Backup {self.name} is not compressed.") raise FileNotFoundError(f"Backup {self.name} is not compressed.") - + self.logger.debug(f"Unpacking compressed backup {self.name}.") backup_path = join(self.dest_path, self.name) - + with ZipFile(f"{backup_path}.zip", 'r') as handle: handle.extractall(backup_path) - + self.completed = True - + self.logger.debug(f"Backup {self.name} unpacked.") - + def calculate_raw_hash(self, method:str) -> str: """Calculates hash of the raw backup. @@ -536,32 +550,34 @@ def calculate_raw_hash(self, method:str) -> str: "sha256": sha256, "sha512": sha512 } - - if method not in methods.keys(): + + if method not in methods: self.logger.error(f"Method {method} is not supported.") - raise ValueError(f"Method {method} is not supported. Supported methods: md5, sha1, sha256, sha512.") - + raise ValueError(f"Method {method} is not supported. "\ + "Supported methods: md5, sha1, sha256, sha512.") + backup_path = join(self.dest_path, self.name) - + if not exists(backup_path): self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + if not self.completed: - self.logger.warning(f"Backup {self.name} is not completed. Calculating hash of the incomplete backup.") - - hash = methods[method]() - + self.logger.warning(f"Backup {self.name} is not completed. "\ + "Calculating hash of the incomplete backup.") + + dir_hash = methods[method]() + for root, _, files in walk(backup_path): for file in files: with open(join(root, file), "rb") as handle: - hash.update(handle.read()) - - hash = hash.hexdigest() - - self.logger.debug(f"{method} hash of the raw backup {self.name} is {hash}.") - return hash - + dir_hash.update(handle.read()) + + dir_hash = dir_hash.hexdigest() + + self.logger.debug(f"{method} hash of the raw backup {self.name} is {dir_hash}.") + return dir_hash + def calculate_compressed_hash(self, method) -> str: """Calculates hash of the compressed backup. @@ -571,37 +587,39 @@ def calculate_compressed_hash(self, method) -> str: FileNotFoundError: Backup is not completed. ValueError: Method is not supported. """ - + methods = { "md5": md5, "sha1": sha1, "sha256": sha256, "sha512": sha512 } - - if method not in methods.keys(): + + if method not in methods: self.logger.error(f"Method {method} is not supported.") - raise ValueError(f"Method {method} is not supported. Supported methods: md5, sha1, sha256, sha512.") - + raise ValueError(f"Method {method} is not supported. "\ + "Supported methods: md5, sha1, sha256, sha512.") + backup_path = join(self.dest_path, self.name) - + if not exists(f"{backup_path}.zip"): self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - + if not self.completed: - self.logger.warning(f"Backup {self.name} is not completed. Calculating hash of the incomplete backup.") - - hash = methods[method]() - + self.logger.warning(f"Backup {self.name} is not completed. "\ + "Calculating hash of the incomplete backup.") + + zip_hash = methods[method]() + with open(f"{backup_path}.zip", "rb") as handle: - hash.update(handle.read()) - - hash = hash.hexdigest() - - self.logger.debug(f"{method} hash of the compressed backup {self.name} is {hash}.") - return hash - + zip_hash.update(handle.read()) + + zip_hash = zip_hash.hexdigest() + + self.logger.debug(f"{method} hash of the compressed backup {self.name} is {zip_hash}.") + return zip_hash + def restore_backup(self, restore_path:str) -> bool: """Restores backup. @@ -619,41 +637,42 @@ def restore_backup(self, restore_path:str) -> bool: if restore_path is None or restore_path == "": self.logger.error(f"Restore path {restore_path} is not valid.") raise ValueError(f"Restore path {restore_path} is not valid.") - + if not exists(restore_path): self.logger.error(f"Restore path {restore_path} does not exist.") raise FileNotFoundError(f"Restore path {restore_path} does not exist.") - + if self.completed: self.logger.info(f"Restoring backup {self.name} from raw to {restore_path}.") self.restore_backup_from_raw(restore_path) - + elif self.compressed: self.logger.info(f"Restoring backup {self.name} from compressed to {restore_path}.") self.unpack_compressed() self.restore_backup_from_raw(restore_path) - + else: self.logger.error(f"Backup {self.name} is not available.") raise FileNotFoundError(f"Backup {self.name} is not available.") - - + backup_hash = self.calculate_raw_hash(method="sha256") restore_hash = sha256() - + for root, _, files in walk(restore_path): for file in files: with open(join(root, file), "rb") as handle: restore_hash.update(handle.read()) - + restore_hash = restore_hash.hexdigest() - + if backup_hash == restore_hash: self.logger.info(f"Backup {self.name} restored to {restore_path} successfully.") return True - else: - self.logger.warning(f"Backup {self.name} restored to {restore_path}, but hashes are different. Backup hash: {backup_hash}, restore hash: {restore_hash}.") - return False + + self.logger.warning( + f"Backup {self.name} restored to {restore_path}, but hashes are different. "\ + f"Backup hash: {backup_hash}, restore hash: {restore_hash}.") + return False def calculate_compression_ratio(self) -> float: """Calculates compression ratio of the backup. @@ -664,10 +683,10 @@ def calculate_compression_ratio(self) -> float: try: raw_size = self.get_raw_size() compressed_size = self.get_compressed_size() - + ratio = raw_size / compressed_size except FileNotFoundError: ratio = 0.0 - + self.logger.debug(f"Compression ratio of the backup {self.name} is {ratio}.") return ratio diff --git a/src/telegram_handler.py b/src/telegram_handler.py index d9396bb..f0f3341 100644 --- a/src/telegram_handler.py +++ b/src/telegram_handler.py @@ -1,51 +1,95 @@ +"""TelegramHandler class.""" import logging import logging.config -import requests from os.path import exists, isfile +import requests from singleton import Singleton class TelegramHandler(metaclass=Singleton): + """TelegramHandler class.""" def __init__(self, token:str, chat_id:str, logger:logging.Logger=None) -> None: - - self.logger = logger + """Initializes TelegramHandler class. + + Args: + token (str): Telegram bot token. + chat_id (str): Telegram chat id. + logger (logging.Logger, optional): Logger. Defaults to None. + """ + self.logger = logger self.token = token self.chat_id = chat_id self.logger.info("TelegramHandler initialized.") - + @property def token(self) -> str: + """Telegram bot token. + + Returns: + str: Telegram bot token. + """ return self._token - + @token.setter def token(self, token:str) -> None: + """Sets Telegram bot token. + + Args: + token (str): Telegram bot token. + + Raises: + ValueError: Empty token. + """ if token is None or token == "": self.logger.error(f"Telegram {token=} is not valid.") raise ValueError(f"Telegram {token=} is not valid.") self._token = token - + @property def chat_id(self) -> str: + """Telegram chat id. + + Returns: + str: Telegram chat id. + """ return self._chat_id - + @chat_id.setter def chat_id(self, chat_id:str) -> None: + """Sets Telegram chat id. + + Args: + chat_id (str): Telegram chat id. + + Raises: + ValueError: Empty chat id. + """ if chat_id is None or chat_id == "": self.logger.error(f"Telegram {chat_id=} is not valid.") raise ValueError(f"Telegram {chat_id=} is not valid.") self._chat_id = chat_id - + @property def logger(self) -> logging.Logger: - return self._logger - + """Logger. + + Returns: + logging.Logger: Logger. + """ + return self._logger + @logger.setter def logger(self, logger:logging.Logger) -> None: + """Sets logger. + + Args: + logger (logging.Logger): Logger. + """ if logger is None: logging.config.fileConfig("log_dev.conf") self._logger = logging.getLogger('pybackupper_logger') else: self._logger = logger - + def test_connection(self) -> bool: """Tests connection to Telegram bot. @@ -54,22 +98,30 @@ def test_connection(self) -> bool: """ url = f"https://api.telegram.org/bot{self.token}/getMe" try: - response = requests.get(url) - + response = requests.get(url, timeout=10) + if response.status_code != 200: - self.logger.error(f"Telegram connection test failed. Status code: {response.status_code}.") + self.logger.error( + f"Telegram connection test failed. Status code: {response.status_code}.") return False - elif response.json()['ok'] != True: - self.logger.error(f"Telegram connection test failed. Status code: {response.status_code}. Response: {response.json()}.") + + if not response.json()['ok']: + self.logger.error( + f"Telegram connection test failed. Status code: {response.status_code}. "\ + f"Response: {response.json()}.") return False - else: - self.logger.info("Telegram connection test successful.") - return True + + self.logger.info("Telegram connection test successful.") + return True except Exception as e: self.logger.error(f"Telegram connection test failed. Exception: {e}.") return False - - def send_message(self, message:str, silent:bool=False, markdown:bool=False, html:bool=False) -> None: + + def send_message(self, + message:str, + silent:bool=False, + markdown:bool=False, + html:bool=False) -> None: """Sends message to Telegram chat. Args: @@ -86,36 +138,40 @@ def send_message(self, message:str, silent:bool=False, markdown:bool=False, html if message is None or message == "": self.logger.error("Message is empty.") raise ValueError("Message is empty.") - + url = f"https://api.telegram.org/bot{self.token}/sendMessage" data = { "chat_id": self.chat_id, "text": message, "disable_notification": silent, } - + if markdown: data["parse_mode"] = "MarkdownV2" - + if html: data["parse_mode"] = "HTML" - + if markdown and html: self.logger.error("Message can't be parsed as markdown and html at the same time.") - raise ValueError("Message can't be parsed as markdown and html at the same time.") - + raise ValueError("Message can't be parsed as markdown and html at the same time.") + try: - response = requests.post(url, data=data) - if response.status_code != 200 or response.json()['ok'] != True: - self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") - raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") - - self.logger.debug(f"Message sent to Telegram chat.") + response = requests.post(url, data=data, timeout=10) + if not response.status_code != 200 or response.json()['ok']: + self.logger.error( + f"Failed to send message to Telegram chat. "\ + f"Status code: {response.status_code}. Response: {response.json()}.") + raise ConnectionError( + f"Failed to send message to Telegram chat. "\ + f"Status code: {response.status_code}. Response: {response.json()}.") + + self.logger.debug("Message sent to Telegram chat.") except Exception as e: self.logger.exception(e, exc_info=True) self.logger.exception("Failed to send message to Telegram chat.") raise e - + def send_file(self, file_path:str, caption:str=None, silent:bool=False) -> None: """Sends file to Telegram chat. @@ -133,33 +189,40 @@ def send_file(self, file_path:str, caption:str=None, silent:bool=False) -> None: if file_path is None or file_path == "": self.logger.error("File path is empty.") raise ValueError("File path is empty.") - elif not exists(file_path): + + if not exists(file_path): self.logger.error(f"File {file_path=} does not exist.") raise FileNotFoundError(f"File {file_path=} does not exist.") - elif not isfile(file_path): + + if not isfile(file_path): self.logger.error(f"File {file_path=} is not a file.") raise FileNotFoundError(f"File {file_path=} is not a file.") - + if caption is not None and caption == "": self.logger.error("Caption is provided, but is empty.") raise ValueError("Caption is provided, but is empty.") - + url = f"https://api.telegram.org/bot{self.token}/sendDocument" data = { "chat_id": self.chat_id, "disable_notification": silent, } - + if caption is not None: data["caption"] = caption - + try: - response = requests.post(url, data=data, files={"document": open(file_path, "rb")}) - if response.status_code != 200 or response.json()['ok'] != True: - self.logger.error(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") - raise Exception(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.json()}.") - - self.logger.debug(f"File sent to Telegram chat.") + with open(file_path, "rb") as file: + response = requests.post(url, data=data, files={"document": file}, timeout=10) + if response.status_code != 200 or not response.json()['ok']: + self.logger.error( + "Failed to send file to Telegram chat. "\ + f"Status code: {response.status_code}. Response: {response.json()}.") + raise ConnectionError( + "Failed to send file to Telegram chat. "\ + f"Status code: {response.status_code}. Response: {response.json()}.") + + self.logger.debug("File sent to Telegram chat.") except Exception as e: self.logger.exception(e, exc_info=True) self.logger.exception("Failed to send file to Telegram chat.") From a87f013af9c77971b09acd1249ceedbcb13af2de Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Sun, 12 Nov 2023 21:46:24 +0100 Subject: [PATCH 13/28] First backup manager --- src/backup.py | 52 +++-- src/backup_manager.py | 529 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 src/backup_manager.py diff --git a/src/backup.py b/src/backup.py index 717130d..38276b7 100644 --- a/src/backup.py +++ b/src/backup.py @@ -13,13 +13,13 @@ from hashlib import md5, sha256, sha512, sha1 from tools import size_to_human_readable -class Backup(): +class Backup(dict): """Backup class for pybackupper.""" def __init__(self, - name:str, - dest_path:str, - ignored:str = None, - logger:logging.Logger=None) -> None: + name:str, + dest_path:str, + ignored:str = None, + logger:logging.Logger=None) -> None: """Initializes Backup object. Args: @@ -32,6 +32,7 @@ def __init__(self, self.name = name self.dest_path = dest_path self.ignored = ignored + super().__init__(self.__dict__()) try: size = self.get_raw_size() @@ -40,8 +41,8 @@ def __init__(self, self.completed = False self.compressed = True if exists(f"{join(self.dest_path, self.name)}.zip") else False - - self.logger.info(f"Backup {self.name} initialized.\n{self}") + super().update(self.__dict__()) + self.logger.debug(f"Backup {self.name} initialized.\n{self}") def __str__(self) -> str: """Returns string representation of the backup. @@ -49,7 +50,6 @@ def __str__(self) -> str: Returns: str: String representation of the backup. """ - size = size_to_human_readable(self.get_size()) return f"Backup {self.name}:\n" \ @@ -59,6 +59,20 @@ def __str__(self) -> str: f" Completed: {self.completed}\n" \ f" Compressed: {self.compressed}\n" + def __dict__(self) -> dict: + """Returns dictionary representation of the backup. + + Returns: + dict: Dictionary representation of the backup. + """ + return { + "name": self.name, + "size": size_to_human_readable(self.get_size()), + "ignored": self.ignored, + "completed": self.completed, + "compressed": self.compressed, + } + @property def logger(self) -> logging.Logger: """Returns logger for the class. @@ -168,7 +182,10 @@ def completed(self) -> bool: Returns: bool: True if backup is completed, False otherwise. """ - return self._completed + try: + return self._completed + except AttributeError: + return False @completed.setter def completed(self, completed:bool) -> None: @@ -185,6 +202,7 @@ def completed(self, completed:bool) -> None: if caller_class == self.__class__.__name__: self.logger.debug(f"Setting completed property of the backup to {completed}.") self._completed = completed + super().__init__(self.__dict__()) else: self.logger.error(f"Change of `completed` property is not allowed for {caller_class}.") raise PermissionError( @@ -197,7 +215,10 @@ def ignored(self) -> str: Returns: str: Ignored patterns of the backup. """ - return self._ignored + try: + return self._ignored + except AttributeError: + return "*.sock, *.pid, *.lock" @ignored.setter def ignored(self, ignored:str) -> None: @@ -225,6 +246,7 @@ def ignored(self, ignored:str) -> None: raise PermissionError("Cannot change ignored files of the backup.") except AttributeError: self.logger.debug(f"Setting ignored files of the backup to {ignored}.") + super().__init__(self.__dict__()) self._ignored = ignored @property @@ -234,7 +256,10 @@ def compressed(self) -> bool: Returns: bool: True if backup is compressed, False otherwise. """ - return self._compressed + try: + return self._compressed + except AttributeError: + return False @compressed.setter def compressed(self, compressed:bool) -> None: @@ -251,6 +276,7 @@ def compressed(self, compressed:bool) -> None: if caller_class == self.__class__.__name__: self.logger.debug(f"Setting compressed property of the backup to {compressed}.") self._compressed = compressed + super().__init__(self.__dict__()) else: self.logger.error(f"Change of `compressed` property is not allowed for {caller_class}.") raise PermissionError( @@ -274,7 +300,7 @@ def get_raw_size(self) -> int: raise FileNotFoundError(f"Backup {backup_path} does not exist.") size = sum(getsize(join(root, file)) - for root, dirs, files in walk(backup_path) for file in files) + for root, dirs, files in walk(backup_path) for file in files) self.logger.debug(f"Raw size of the backup {self.name} is {size_to_human_readable(size)}.") return size @@ -373,7 +399,7 @@ def _add_to_zip(self, lock: Lock, handle: ZipFile, file_paths_batch: list) -> No with lock: for file_path in file_paths_batch: handle.write(file_path, - normpath(file_path).replace(backup_path, "").lstrip("\\").lstrip("/")) + normpath(file_path).replace(backup_path, "").lstrip("\\").lstrip("/")) def compress_raw_backup(self) -> None: """Compresses raw backup to the zip file. diff --git a/src/backup_manager.py b/src/backup_manager.py new file mode 100644 index 0000000..61db274 --- /dev/null +++ b/src/backup_manager.py @@ -0,0 +1,529 @@ +"""BackupManager class""" + +import logging +import logging.config +from singleton import Singleton +from os.path import exists, normpath, getsize, join +from os import makedirs, walk +from datetime import datetime +from psutil import disk_usage +from json import dump, load +from shutil import Error as shutilError +from backup import Backup +from s3_handler import S3Handler +from telegram_handler import TelegramHandler +from tools import size_to_human_readable, timestamp_to_file_name + +class BackupManager(metaclass=Singleton): + """BackupManager class""" + def __init__(self, + src_path:str, + dest_path:str, + raw_to_keep:int, + compressed_to_keep:int, + ignored:str=None, + s3_handler=None, + telegram_handler=None, + logger:logging.Logger=None) -> None: + self.logger = logger + self.src_path = src_path + self.dest_path = dest_path + self.ignored = ignored + self.raw_to_keep = raw_to_keep + self.compressed_to_keep = compressed_to_keep + self.s3_handler = s3_handler + self.telegram_handler = telegram_handler + self.backups = { + "local": [], + "s3": [], + } + self.logger.info("BackupManager initialized.") + + def __str__(self) -> str: + """Get the string representation of the object. + + Returns: + str: String representation of the object. + """ + local_size = 0 + s3_size = 0 + + for backup in self.backups["local"]: + local_size += backup.get_size() + + s3_size = self.s3_handler.get_bucket_size() if not self.s3_handler is None else 0 + + return f"BackupManager:\n" \ + f" src_path: {self.src_path}\n" \ + f" dest_path: {self.dest_path}\n" \ + f" ignored: {self.ignored}\n" \ + f" raw_to_keep: {self.raw_to_keep}\n" \ + f" compressed_to_keep: {self.compressed_to_keep}\n" \ + f" s3_handler: {True if not self.s3_handler is None else False}\n" \ + f" telegram_handler: {True if not self.telegram_handler is None else False}\n" \ + f" backups:\n" \ + f" local:\n" \ + f" count: {len(self.backups['local'])}\n" \ + f" size: {size_to_human_readable(local_size)}\n" \ + f" s3:\n" \ + f" count: {len(self.backups['s3'])}\n" \ + f" size: {size_to_human_readable(s3_size)}\n" \ + f" last:\n" \ + f" {self.backups['local'][-1].name if len(self.backups['local']) > 0 else None}" + + def __dict__(self) -> dict: + """Get the dictionary representation of the object. + + Returns: + dict: Dictionary representation of the object. + """ + return { + "src_path": self.src_path, + "dest_path": self.dest_path, + "ignored": self.ignored, + "raw_to_keep": self.raw_to_keep, + "compressed_to_keep": self.compressed_to_keep, + "local_size": size_to_human_readable(sum([backup.get_size() for backup in self.backups["local"]])), + "s3_size": size_to_human_readable(self.s3_handler.get_bucket_size() if not self.s3_handler is None else 0), + "backups": self.backups, + } + + @property + def logger(self) -> logging.Logger: + """Get the logger. + + Returns: + logging.Logger: Logger. + """ + return self._logger + + @logger.setter + def logger(self, logger:logging.Logger) -> None: + """Set the logger. + + Args: + logger (logging.Logger): Logger. + """ + if logger is None: + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') + else: + self._logger = logger + + @property + def src_path(self) -> str: + """Get the source path. + + Returns: + str: Source path. + """ + return self._src_path + + @src_path.setter + def src_path(self, src_path:str) -> None: + """ + + Args: + src_path (str): Source path. + + Raises: + ValueError: Source path cannot be None or empty. + FileNotFoundError: Source path does not exist. + """ + if src_path is None or src_path == "": + self.logger.error("src_path cannot be None or empty.") + raise ValueError("src_path cannot be None or empty.") + + src_path = normpath(src_path) + if not exists(src_path): + self.logger.error(f"src_path {src_path} does not exist.") + raise FileNotFoundError(f"src_path {src_path} does not exist.") + + self._src_path = src_path + + @property + def dest_path(self) -> str: + """Get the destination path. + + Returns: + str: Destination path. + """ + return self._dest_path + + @dest_path.setter + def dest_path(self, dest_path:str) -> None: + """ + + Args: + dest_path (str): Destination path. + + Raises: + ValueError: Destination path cannot be None or empty. + OSError: Destination path cannot be created. + """ + if dest_path is None or dest_path == "": + self.logger.error("dest_path cannot be None or empty.") + raise ValueError("dest_path cannot be None or empty.") + + dest_path = normpath(dest_path) + if not exists(dest_path): + self.logger.warning(f"dest_path {dest_path} does not exist. Creating it.") + try: + makedirs(dest_path) + except OSError as e: + self.logger.error(f"dest_path {dest_path} cannot be created. {e}") + raise OSError(f"dest_path {dest_path} cannot be created. {e}") + + self._dest_path = dest_path + + @property + def ignored(self) -> str: + """Get the ignored paths. + + Returns: + str: Ignored paths. + """ + return self._ignored + + @ignored.setter + def ignored(self, ignored:str) -> None: + """ + + Args: + ignored (str): Ignored paths. + Raises: + TypeError: ignored must be a string or None. + """ + if type(ignored) is str or ignored is None: + self._ignored = ignored + else: + self.logger.error("ignored must be a string or None.") + raise TypeError("ignored must be a string or None.") + + @property + def raw_to_keep(self) -> int: + """Get the number of raw backups to keep. + + Returns: + int: Number of raw backups to keep. + """ + return self._raw_to_keep + + @raw_to_keep.setter + def raw_to_keep(self, raw_to_keep:int) -> None: + """ + + Args: + raw_to_keep (int): Number of raw backups to keep. + + Raises: + TypeError: raw_to_keep must be an integer. + ValueError: raw_to_keep must be greater or equal to 0. + """ + if not type(raw_to_keep) is int: + self.logger.error("raw_to_keep must be an integer.") + raise TypeError("raw_to_keep must be an integer.") + + if raw_to_keep < 0: + self.logger.error("raw_to_keep must be greater or equal to 0.") + raise ValueError("raw_to_keep must be greater or equal to 0.") + + self._raw_to_keep = raw_to_keep + + @property + def compressed_to_keep(self) -> int: + """Get the number of compressed backups to keep. + + Returns: + int: Number of compressed backups to keep. + """ + return self._compressed_to_keep + + @compressed_to_keep.setter + def compressed_to_keep(self, compressed_to_keep:int) -> None: + """ + + Args: + compressed_to_keep (int): Number of compressed backups to keep. + + Raises: + TypeError: compressed_to_keep must be an integer. + ValueError: compressed_to_keep must be greater or equal to 0. + """ + if not type(compressed_to_keep) is int: + self.logger.error("compressed_to_keep must be an integer.") + raise TypeError("compressed_to_keep must be an integer.") + + if compressed_to_keep < 0: + self.logger.error("compressed_to_keep must be greater or equal to 0.") + raise ValueError("compressed_to_keep must be greater or equal to 0.") + + self._compressed_to_keep = compressed_to_keep + + @property + def s3_handler(self): + """Get the S3 handler. + + Returns: + S3Handler: S3 handler. + """ + return self._s3_handler + + @s3_handler.setter + def s3_handler(self, s3_handler) -> None: + """Set the S3 handler. + + Args: + s3_handler (S3Handler): S3 handler. + + Raises: + TypeError: s3_handler must be a S3Handler or None. + """ + if s3_handler is None: + self._s3_handler = None + return + + if not type(s3_handler) is S3Handler: + self.logger.error("s3_handler must be a S3Handler or None.") + raise TypeError("s3_handler must be a S3Handler or None.") + + if not s3_handler.test_connection(): + self.logger.error("S3 connection test failed. S3 handler will be set to None.") + self._s3_handler = None + else: + self._s3_handler = s3_handler + + @property + def telegram_handler(self): + """Get the Telegram handler. + + Returns: + TelegramHandler: Telegram handler. + """ + return self._telegram_handler + + @telegram_handler.setter + def telegram_handler(self, telegram_handler) -> None: + """Set the Telegram handler. + + Args: + telegram_handler (TelegramHandler): Telegram handler. + + Raises: + TypeError: telegram_handler must be a TelegramHandler or None. + """ + if telegram_handler is None: + self._telegram_handler = None + return + + if not type(telegram_handler) is TelegramHandler: + self.logger.error("telegram_handler must be a TelegramHandler or None.") + raise TypeError("telegram_handler must be a TelegramHandler or None.") + + if not telegram_handler.test_connection(): + self.logger.error("Telegram connection test failed. Telegram handler will be set to None.") + self._telegram_handler = None + else: + self._telegram_handler = telegram_handler + + def generate_backup_name(self) -> str: + """Generate a backup name. + + Returns: + str: Backup name. + """ + return timestamp_to_file_name(datetime.now().timestamp()) + + def get_src_size(self) -> int: + """Get the size of the source. + + Returns: + int: Size of the source. + """ + self.logger.debug(f"Getting size of {self.src_path}...") + size = 0 + + for root, _, files in walk(self.src_path): + for file in files: + size += getsize(join(root, file)) + + self.logger.debug(f"Size of {self.src_path} is {size_to_human_readable(size)}.") + return size + + def get_dest_space(self): + """Get the size of the destination. + + Returns: + int: Size of the destination. + """ + self.logger.debug(f"Getting available space of {self.dest_path}...") + + try: + disk = disk_usage(self.dest_path) + total = disk.total + space = disk.free + + if total == 0: + self.logger.error(f"Error getting available space of {self.dest_path}.") + return 0 + + if space < total * 0.1: + self.logger.warning(f"Available space of {self.dest_path} is less than 10% of total space.") + if self.telegram_handler is not None: + self.telegram_handler.send_message( + f"Available space of {self.dest_path} is less than 10% of total space.\n" + f"Space: {size_to_human_readable(space)} / {size_to_human_readable(total)}") + except OSError: + space = 0 + + self.logger.debug(f"Available space of {self.dest_path} is {size_to_human_readable(space)}.") + return space + + def check_available_space(self) -> bool: + """Check if there is enough space to create a backup. + + Returns: + bool: True if there is enough space, False otherwise. + """ + src_size = self.get_src_size() + dest_space = self.get_dest_space() + + if src_size > dest_space: + self.logger.error( + f"Not enough space to create a backup." + f" Source size is {size_to_human_readable(src_size)} " + f"and destination space is {size_to_human_readable(dest_space)}.") + return False + + if len(self.backups["local"]) > 0: + try: + compression_ratio = self.backups["local"][-1].calculate_compression_ratio() + except FileNotFoundError: + compression_ratio = 1 + + return True if ((src_size + (src_size / compression_ratio)) * 1.05) < dest_space else False + + elif self.compressed_to_keep > 0: + return True if (src_size * 2 * 1.05) < dest_space else False + + return True if (src_size * 1.05) < dest_space else False + + def save_backup_info(self, dest_path:str=None) -> None: + """Save the backup info to a file. + + Args: + dest_path (str): Path to save the backup info. + + Raises: + OSError: Path cannot be created. + """ + if dest_path is None or dest_path == "": + dest_path = self.dest_path + + self.logger.debug(f"Saving backup info to {dest_path}...") + dest_path = normpath(dest_path) + + if not exists(dest_path): + self.logger.warning(f"Path {dest_path} does not exist. Creating it.") + try: + makedirs(dest_path) + except OSError as e: + self.logger.error(f"Path {dest_path} cannot be created. {e}") + raise OSError(f"Path {dest_path} cannot be created. {e}") + + path = normpath(join(dest_path, "backup_info.json")) + with open(path, "w") as file: + dump(self.__dict__(), file, indent=4) + self.logger.debug(f"Backup info saved to {dest_path}.") + + def load_backup_info(self, src_path:str=None) -> None: + """Load the backup info from a file. + + Args: + src_path (str): Path to load the backup info. + + Raises: + FileNotFoundError: File does not exist. + """ + if src_path is None or src_path == "": + src_path = self.dest_path + + self.logger.debug(f"Loading backup info from {src_path}...") + src_path = normpath(src_path) + path = normpath(join(src_path, "backup_info.json")) + + if not exists(path): + self.logger.error(f"File {path} does not exist.") + raise FileNotFoundError(f"File {path} does not exist.") + + with open(path, "r") as file: + backup_info = load(file) + + # self.src_path = backup_info["src_path"] + # self.dest_path = backup_info["dest_path"] + # self.ignored = backup_info["ignored"] + # self.raw_to_keep = backup_info["raw_to_keep"] + # self.compressed_to_keep = backup_info["compressed_to_keep"] + + for backup in backup_info["backups"]["local"]: + self.backups["local"].append(Backup(backup["name"], self.dest_path, backup["ignored"], self.logger)) + + def create_backup(self) -> bool: + name = self.generate_backup_name() + self.logger.info(f"Creating backup {name}...") + + if not self.check_available_space(): + self.logger.error("Not enough space to create a backup.") + return False + + backup = Backup(name, self.dest_path, self.ignored, self.logger) + + try: + backup.create_raw_backup(self.src_path) + except FileExistsError: + self.logger.error(f"Backup {name} already exists.") + return False + except FileNotFoundError: + self.logger.error(f"Source path {self.src_path} does not exist.") + return False + except shutilError as e: + self.logger.error(f"Error creating backup {name}. {e}") + return False + try: + raw_hash = backup.calculate_raw_hash(method="sha256") + except FileNotFoundError: + self.logger.error(f"Error calculating raw hash of {backup.name}.") + return False + + self.logger.debug(f"Raw hash of {backup.name} is {raw_hash}.") + + if self.compressed_to_keep > 0: + try: + backup.compress_raw_backup() + compressed_hash = backup.calculate_compressed_hash(method="sha256") + except FileNotFoundError: + self.logger.error(f"Error compressing backup {backup.name}.") + return False + + self.logger.debug(f"Compressed hash of {backup.name} is {compressed_hash}.") + self.logger.debug(f"Compression ratio of {backup.name} is {backup.calculate_compression_ratio():.2f}.") + + self.backups["local"].append(backup) + self.logger.info(f"Backup {backup.name} created.") + self.logger.debug(f"Backup {backup.name} size is {size_to_human_readable(backup.get_size())}.") + return True + +backup_manager = BackupManager( src_path="../test-source", + dest_path="../test-target", + raw_to_keep=5, + compressed_to_keep=5, + ignored=None, + s3_handler=None, + telegram_handler=None) + + + +backup_manager.load_backup_info() +backup_manager.create_backup() +backup_manager.save_backup_info() +print(backup_manager) \ No newline at end of file From 1697cda3bcf4c2155e5c2f5625bd204207047766 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 15 Nov 2023 21:16:03 +0100 Subject: [PATCH 14/28] restore_backup_info --- src/backup.py | 2 + src/backup_manager.py | 105 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/src/backup.py b/src/backup.py index 38276b7..d3898f6 100644 --- a/src/backup.py +++ b/src/backup.py @@ -71,6 +71,8 @@ def __dict__(self) -> dict: "ignored": self.ignored, "completed": self.completed, "compressed": self.compressed, + "raw_hash": self.calculate_raw_hash(method="sha256") if self.completed else None, + "compressed_hash": self.calculate_compressed_hash(method="sha256") if self.compressed else None, } @property diff --git a/src/backup_manager.py b/src/backup_manager.py index 61db274..feec771 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -4,11 +4,13 @@ import logging.config from singleton import Singleton from os.path import exists, normpath, getsize, join -from os import makedirs, walk +from os import makedirs, walk, listdir from datetime import datetime from psutil import disk_usage from json import dump, load +from pprint import pformat from shutil import Error as shutilError +from concurrent.futures import ThreadPoolExecutor from backup import Backup from s3_handler import S3Handler from telegram_handler import TelegramHandler @@ -21,6 +23,7 @@ def __init__(self, dest_path:str, raw_to_keep:int, compressed_to_keep:int, + s3_to_keep:int, ignored:str=None, s3_handler=None, telegram_handler=None, @@ -31,6 +34,7 @@ def __init__(self, self.ignored = ignored self.raw_to_keep = raw_to_keep self.compressed_to_keep = compressed_to_keep + self.s3_to_keep = s3_to_keep self.s3_handler = s3_handler self.telegram_handler = telegram_handler self.backups = { @@ -260,6 +264,36 @@ def compressed_to_keep(self, compressed_to_keep:int) -> None: self._compressed_to_keep = compressed_to_keep + @property + def s3_to_keep(self) -> int: + """Get the number of S3 backups to keep. + + Returns: + int: Number of S3 backups to keep. + """ + return self._s3_to_keep + + @s3_to_keep.setter + def s3_to_keep(self, s3_to_keep:int) -> None: + """ + + Args: + s3_to_keep (int): Number of S3 backups to keep. + + Raises: + TypeError: s3_to_keep must be an integer. + ValueError: s3_to_keep must be greater or equal to 0. + """ + if not type(s3_to_keep) is int: + self.logger.error("s3_to_keep must be an integer.") + raise TypeError("s3_to_keep must be an integer.") + + if s3_to_keep < 0: + self.logger.error("s3_to_keep must be greater or equal to 0.") + raise ValueError("s3_to_keep must be greater or equal to 0.") + + self._s3_to_keep = s3_to_keep + @property def s3_handler(self): """Get the S3 handler. @@ -436,11 +470,12 @@ def save_backup_info(self, dest_path:str=None) -> None: dump(self.__dict__(), file, indent=4) self.logger.debug(f"Backup info saved to {dest_path}.") - def load_backup_info(self, src_path:str=None) -> None: + def load_backup_info(self, src_path:str=None, ignore_hash_mismatch:bool=True) -> None: """Load the backup info from a file. Args: src_path (str): Path to load the backup info. + ignore_hash_mismatch (bool): Ignore hash mismatch and load backup anyway. Raises: FileNotFoundError: File does not exist. @@ -459,14 +494,60 @@ def load_backup_info(self, src_path:str=None) -> None: with open(path, "r") as file: backup_info = load(file) - # self.src_path = backup_info["src_path"] - # self.dest_path = backup_info["dest_path"] - # self.ignored = backup_info["ignored"] - # self.raw_to_keep = backup_info["raw_to_keep"] - # self.compressed_to_keep = backup_info["compressed_to_keep"] - for backup in backup_info["backups"]["local"]: - self.backups["local"].append(Backup(backup["name"], self.dest_path, backup["ignored"], self.logger)) + tmp_backup = Backup(backup["name"], self.dest_path, self.ignored, self.logger) + if (backup["raw_hash"] != tmp_backup.calculate_raw_hash(method="sha256")) or \ + (backup["compressed_hash"] != tmp_backup.calculate_compressed_hash(method="sha256")): + if ignore_hash_mismatch: + self.logger.warning(f"Hash mismatch for backup {backup['name']}. Loading backup anyway.") + else: + self.logger.error(f"Hash mismatch for backup {backup['name']}. Skipping backup.") + continue + + self.backups["local"].append(tmp_backup) + + self.logger.debug(f"Backup info loaded from {src_path}.") + + def restore_backup_info(self, src_path:str=None) -> None: + """Restore the backup info file. + + Args: + src_path (str): Path to restore the backup info. + """ + if src_path is None or src_path == "": + src_path = self.dest_path + + self.logger.debug(f"Restoring backup info from {src_path}...") + src_path = normpath(src_path) + + backups_list = set() + for item in listdir(src_path): + if item.endswith(".zip"): + backups_list.add(item.split(".")[0]) + else: + backups_list.add(item) + + with ThreadPoolExecutor(len(backups_list)) as executor: + for backup in backups_list: + executor.submit(lambda: + self.backups["local"].append( + Backup( backup, + self.dest_path, + self.ignored, + self.logger))) + + self.backups["local"].sort(key=lambda x: x.name, reverse=False) + + if len(self.backups["local"]) > 0: + self.logger.debug(f"Restored backups {pformat(backups_list)} from {src_path}") + self.save_backup_info() + if self.telegram_handler is not None: + self.telegram_handler.send_message(f"Restored backups {pformat(backups_list)} from {src_path}") + return + + self.logger.warning(f"No backups found in {src_path}.") + if self.telegram_handler is not None: + self.telegram_handler.send_message(f"No backups found in {src_path}.") def create_backup(self) -> bool: name = self.generate_backup_name() @@ -523,7 +604,11 @@ def create_backup(self) -> bool: -backup_manager.load_backup_info() +try: + backup_manager.load_backup_info() +except FileNotFoundError: + print("No backup info found. Restoring it.") + backup_manager.restore_backup_info() backup_manager.create_backup() backup_manager.save_backup_info() print(backup_manager) \ No newline at end of file From 6ab59d045c75b5507610dc7a7f0e4249ddb567e6 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 16 Nov 2023 15:51:20 +0100 Subject: [PATCH 15/28] delete backups --- src/backup.py | 17 +++--- src/backup_manager.py | 128 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/backup.py b/src/backup.py index d3898f6..fb084d4 100644 --- a/src/backup.py +++ b/src/backup.py @@ -475,16 +475,16 @@ def delete_compressed_backup(self) -> None: """Deletes compressed backup. Raises: - FileNotFoundError: Backup is not completed. + FileNotFoundError: Backup is not compressed. """ - if not self.completed: - self.logger.error(f"Backup {self.name} is not completed.") - raise FileNotFoundError(f"Backup {self.name} is not completed.") + if not self.compressed: + self.logger.error(f"Backup {self.name} is not compressed.") + raise FileNotFoundError(f"Backup {self.name} is not compressed.") self.logger.debug(f"Deleting compressed backup {self.name}.") backup_path = join(self.dest_path, self.name) - shutil.rmtree(f"{backup_path}.zip") + remove(f"{backup_path}.zip") self.compressed = False self.logger.debug(f"Backup {self.name} deleted.") @@ -500,12 +500,9 @@ def delete_backup(self) -> None: raise FileNotFoundError(f"Backup {self.name} is not completed.") self.logger.debug(f"Deleting backup {self.name}.") - backup_path = normpath(join(self.dest_path, self.name)) - shutil.rmtree(backup_path, ignore_errors=True) - - if self.compressed: - remove(f"{backup_path}.zip") + self.delete_raw_backup() + self.delete_compressed_backup() self.completed = False self.compressed = False diff --git a/src/backup_manager.py b/src/backup_manager.py index feec771..454dea4 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -23,7 +23,7 @@ def __init__(self, dest_path:str, raw_to_keep:int, compressed_to_keep:int, - s3_to_keep:int, + s3_to_keep:int = 0, ignored:str=None, s3_handler=None, telegram_handler=None, @@ -35,8 +35,10 @@ def __init__(self, self.raw_to_keep = raw_to_keep self.compressed_to_keep = compressed_to_keep self.s3_to_keep = s3_to_keep + self.s3_handler = s3_handler self.telegram_handler = telegram_handler + self.backups = { "local": [], "s3": [], @@ -594,10 +596,130 @@ def create_backup(self) -> bool: self.logger.debug(f"Backup {backup.name} size is {size_to_human_readable(backup.get_size())}.") return True + def get_backup_index_by_name(self, backup_name:str) -> int: + """Get the index of a backup by its name. + + Args: + backup_name (str): Name of the backup. + + Returns: + int: Index of the backup. + """ + for index, backup in enumerate(self.backups["local"]): + if backup.name == backup_name: + return index + return -1 + + def delete_raw_backup(self, backup_name:str) -> bool: + """Delete a raw backup. + + Args: + backup_name (str): Name of the backup to delete. + + Returns: + bool: True if the backup has been deleted, False otherwise. + """ + self.logger.debug(f"Deleting raw backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.backups["local"][index].delete_raw_backup() + if not self.backups["local"][index].completed and \ + not self.backups["local"][index].compressed: + self.backups["local"].pop(index) + except FileNotFoundError: + self.logger.error(f"Backup {backup_name} not found.") + return False + + self.logger.debug(f"Raw backup {backup_name} deleted.") + return True + + def delete_compressed_backup(self, backup_name:str) -> bool: + """Delete a compressed backup. + + Args: + backup_name (str): Name of the backup to delete. + + Returns: + bool: True if the backup has been deleted, False otherwise. + """ + self.logger.debug(f"Deleting compressed backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.backups["local"][index].delete_compressed_backup() + if not self.backups["local"][index].completed and \ + not self.backups["local"][index].compressed: + self.backups["local"].pop(index) + except FileNotFoundError: + self.logger.error(f"Backup {backup_name} not found.") + return False + + self.logger.debug(f"Compressed backup {backup_name} deleted.") + return True + + def delete_s3_backup(self, backup_name:str) -> bool: + # TODO: Implement + pass + + def delete_backup(self, backup_name:str) -> bool: + """Delete a backup. + + Args: + backup_name (str): Name of the backup to delete. + + Returns: + bool: True if the backup has been deleted, False otherwise. + """ + self.logger.debug(f"Deleting backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.backups["local"][index].delete_backup() + # TODO: Add S3 backup deletion + if not self.backups["local"][index].completed and \ + not self.backups["local"][index].compressed: + self.backups["local"].pop(index) + except FileNotFoundError: + self.logger.error(f"Backup {backup_name} not found.") + return False + + self.logger.debug(f"Backup {backup_name} deleted.") + return True + + def delete_old_backups(self) -> None: + """Delete old backups.""" + self.logger.debug(f"Deleting old backups...") + if len(self.backups["local"]) > self.raw_to_keep: + for backup in self.backups["local"][0:len(self.backups["local"]) - self.raw_to_keep]: + self.delete_raw_backup(backup.name) + + if len(self.backups["local"]) > self.compressed_to_keep: + for backup in self.backups["local"][0:len(self.backups["local"]) - self.compressed_to_keep]: + self.delete_compressed_backup(backup.name) + + if len(self.backups["s3"]) > self.s3_to_keep: + for backup in self.backups["s3"][0:len(self.backups["s3"]) - self.s3_to_keep]: + self.delete_s3_backup(backup.name) + + self.logger.debug(f"Old backups deleted.") + backup_manager = BackupManager( src_path="../test-source", dest_path="../test-target", - raw_to_keep=5, - compressed_to_keep=5, + raw_to_keep=2, + compressed_to_keep=4, ignored=None, s3_handler=None, telegram_handler=None) From e4cf57df75e5c3f693832a55a764aa6a2488da25 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 16 Nov 2023 20:48:19 +0100 Subject: [PATCH 16/28] run_backup, delete_old... --- src/backup.py | 17 ++--- src/backup_manager.py | 155 +++++++++++++++++++++++++++++----------- src/log_dev.conf | 2 +- src/requirements.txt | 1 + src/telegram_handler.py | 9 ++- src/tools.py | 18 +++++ 6 files changed, 146 insertions(+), 56 deletions(-) diff --git a/src/backup.py b/src/backup.py index fb084d4..d68487e 100644 --- a/src/backup.py +++ b/src/backup.py @@ -170,7 +170,7 @@ def dest_path(self, dest_path:str) -> None: try: if exists(backup_path) and self.get_raw_size() > 0: - self.logger.warning(f"Backup {backup_path} already exists. "\ + self.logger.debug(f"Backup {backup_path} already exists. "\ "Marking it as completed.") self.completed = True except FileNotFoundError: @@ -290,19 +290,16 @@ def get_raw_size(self) -> int: Returns: int: Raw size of the backup. - - Raises: - FileNotFoundError: Backup does not exist. """ - backup_path = join(self.dest_path, self.name) + backup_path = normpath(join(self.dest_path, self.name)) self.logger.debug(f"Getting raw size of the backup {self.name}.") if not exists(backup_path): - self.logger.error(f"Backup {backup_path} does not exist.") - raise FileNotFoundError(f"Backup {backup_path} does not exist.") + self.logger.debug(f"Backup {backup_path} does not exist.") + return 0 size = sum(getsize(join(root, file)) - for root, dirs, files in walk(backup_path) for file in files) + for root, _, files in walk(backup_path) for file in files) self.logger.debug(f"Raw size of the backup {self.name} is {size_to_human_readable(size)}.") return size @@ -631,10 +628,6 @@ def calculate_compressed_hash(self, method) -> str: self.logger.error(f"Backup {self.name} is not completed.") raise FileNotFoundError(f"Backup {self.name} is not completed.") - if not self.completed: - self.logger.warning(f"Backup {self.name} is not completed. "\ - "Calculating hash of the incomplete backup.") - zip_hash = methods[method]() with open(f"{backup_path}.zip", "rb") as handle: diff --git a/src/backup_manager.py b/src/backup_manager.py index 454dea4..6b567a8 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -2,6 +2,7 @@ import logging import logging.config +from logging.handlers import TimedRotatingFileHandler from singleton import Singleton from os.path import exists, normpath, getsize, join from os import makedirs, walk, listdir @@ -11,10 +12,11 @@ from pprint import pformat from shutil import Error as shutilError from concurrent.futures import ThreadPoolExecutor +from re import fullmatch from backup import Backup from s3_handler import S3Handler from telegram_handler import TelegramHandler -from tools import size_to_human_readable, timestamp_to_file_name +from tools import * class BackupManager(metaclass=Singleton): """BackupManager class""" @@ -43,6 +45,11 @@ def __init__(self, "local": [], "s3": [], } + + try: + self.load_backup_info() + except FileNotFoundError: + self.restore_backup_info() self.logger.info("BackupManager initialized.") def __str__(self) -> str: @@ -65,8 +72,8 @@ def __str__(self) -> str: f" ignored: {self.ignored}\n" \ f" raw_to_keep: {self.raw_to_keep}\n" \ f" compressed_to_keep: {self.compressed_to_keep}\n" \ - f" s3_handler: {True if not self.s3_handler is None else False}\n" \ - f" telegram_handler: {True if not self.telegram_handler is None else False}\n" \ + f" s3_handler: {True if self.s3_handler else False}\n" \ + f" telegram_handler: {True if self.telegram_handler else False}\n" \ f" backups:\n" \ f" local:\n" \ f" count: {len(self.backups['local'])}\n" \ @@ -405,7 +412,7 @@ def get_dest_space(self): if space < total * 0.1: self.logger.warning(f"Available space of {self.dest_path} is less than 10% of total space.") - if self.telegram_handler is not None: + if self.telegram_handler: self.telegram_handler.send_message( f"Available space of {self.dest_path} is less than 10% of total space.\n" f"Space: {size_to_human_readable(space)} / {size_to_human_readable(total)}") @@ -434,6 +441,8 @@ def check_available_space(self) -> bool: if len(self.backups["local"]) > 0: try: compression_ratio = self.backups["local"][-1].calculate_compression_ratio() + if compression_ratio == 0: + compression_ratio = 1 except FileNotFoundError: compression_ratio = 1 @@ -485,9 +494,9 @@ def load_backup_info(self, src_path:str=None, ignore_hash_mismatch:bool=True) -> if src_path is None or src_path == "": src_path = self.dest_path - self.logger.debug(f"Loading backup info from {src_path}...") src_path = normpath(src_path) path = normpath(join(src_path, "backup_info.json")) + self.logger.debug(f"Loading backup info from {path}...") if not exists(path): self.logger.error(f"File {path} does not exist.") @@ -497,7 +506,11 @@ def load_backup_info(self, src_path:str=None, ignore_hash_mismatch:bool=True) -> backup_info = load(file) for backup in backup_info["backups"]["local"]: - tmp_backup = Backup(backup["name"], self.dest_path, self.ignored, self.logger) + try: + tmp_backup = Backup(backup["name"], self.dest_path, self.ignored, self.logger) + except FileNotFoundError: + self.logger.error(f"Backup {backup['name']} not found.") + continue if (backup["raw_hash"] != tmp_backup.calculate_raw_hash(method="sha256")) or \ (backup["compressed_hash"] != tmp_backup.calculate_compressed_hash(method="sha256")): if ignore_hash_mismatch: @@ -516,6 +529,8 @@ def restore_backup_info(self, src_path:str=None) -> None: Args: src_path (str): Path to restore the backup info. """ + pattern = r"\b\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}(?:\.zip)?\b" + if src_path is None or src_path == "": src_path = self.dest_path @@ -524,34 +539,40 @@ def restore_backup_info(self, src_path:str=None) -> None: backups_list = set() for item in listdir(src_path): - if item.endswith(".zip"): - backups_list.add(item.split(".")[0]) - else: - backups_list.add(item) - - with ThreadPoolExecutor(len(backups_list)) as executor: - for backup in backups_list: - executor.submit(lambda: - self.backups["local"].append( - Backup( backup, - self.dest_path, - self.ignored, - self.logger))) + if fullmatch(pattern, item): + if item.endswith(".zip"): + backups_list.add(item.split(".")[0]) + else: + backups_list.add(item) + + if len(backups_list) > 0: + with ThreadPoolExecutor(len(backups_list)) as executor: + for backup in backups_list: + executor.submit(lambda: + self.backups["local"].append( + Backup( backup, + self.dest_path, + self.ignored, + self.logger))) self.backups["local"].sort(key=lambda x: x.name, reverse=False) - if len(self.backups["local"]) > 0: - self.logger.debug(f"Restored backups {pformat(backups_list)} from {src_path}") + self.logger.debug(f"Restored backups\n{pformat(backups_list, sort_dicts=False, compact=True, indent=2)}\nfrom {src_path}") self.save_backup_info() - if self.telegram_handler is not None: - self.telegram_handler.send_message(f"Restored backups {pformat(backups_list)} from {src_path}") + if self.telegram_handler: + self.telegram_handler.send_message(f"Restored backups\n{pformat(backups_list, sort_dicts=False, compact=True, indent=2)}\nfrom {src_path}") return self.logger.warning(f"No backups found in {src_path}.") - if self.telegram_handler is not None: + if self.telegram_handler: self.telegram_handler.send_message(f"No backups found in {src_path}.") def create_backup(self) -> bool: + """Create a backup. + + Returns: + bool: True if the backup has been created, False otherwise. + """ name = self.generate_backup_name() self.logger.info(f"Creating backup {name}...") @@ -592,6 +613,8 @@ def create_backup(self) -> bool: self.logger.debug(f"Compression ratio of {backup.name} is {backup.calculate_compression_ratio():.2f}.") self.backups["local"].append(backup) + self.save_backup_info() + self.logger.info(f"Backup {backup.name} created.") self.logger.debug(f"Backup {backup.name} size is {size_to_human_readable(backup.get_size())}.") return True @@ -702,35 +725,87 @@ def delete_backup(self, backup_name:str) -> bool: def delete_old_backups(self) -> None: """Delete old backups.""" self.logger.debug(f"Deleting old backups...") + backups_to_delete = { + "raw": [], + "compressed": [], + "s3": [], + } if len(self.backups["local"]) > self.raw_to_keep: for backup in self.backups["local"][0:len(self.backups["local"]) - self.raw_to_keep]: - self.delete_raw_backup(backup.name) + if backup.completed: + backups_to_delete["raw"].append(backup.name) if len(self.backups["local"]) > self.compressed_to_keep: for backup in self.backups["local"][0:len(self.backups["local"]) - self.compressed_to_keep]: - self.delete_compressed_backup(backup.name) + if backup.compressed: + backups_to_delete["compressed"].append(backup.name) if len(self.backups["s3"]) > self.s3_to_keep: for backup in self.backups["s3"][0:len(self.backups["s3"]) - self.s3_to_keep]: - self.delete_s3_backup(backup.name) + backups_to_delete["s3"].append(backup.name) + + self.logger.info(f"Deleting backups\n{pformat(backups_to_delete, sort_dicts=False, compact=True, indent=2)}\n...") + + for backup in backups_to_delete["raw"]: + self.delete_raw_backup(backup) + for backup in backups_to_delete["compressed"]: + self.delete_compressed_backup(backup) + for backup in backups_to_delete["s3"]: + self.delete_s3_backup(backup) self.logger.debug(f"Old backups deleted.") + def run_backup(self) -> None: + """Run the backup.""" + start_time = datetime.now().timestamp() + self.logger.info(f"Running backup. Start time: {timestamp_to_human_readable(start_time)}.") + + if self.create_backup(): + # TODO: Add S3 backup + self.delete_old_backups() + end_time = datetime.now().timestamp() + self.logger.info(f"Backup completed. End time: {timestamp_to_human_readable(end_time)}.") + self.logger.info(f"Backup duration: {time_diff_to_human_readable(round(end_time - start_time))}.") + self.logger.info(f"""Backup info:\n{pformat(self.backups["local"][-1].__dict__(), sort_dicts=False, indent=2, compact=True)}.""") + + if self.telegram_handler: + self.telegram_handler.send_message( + f"*Backup completed*\\.\n" \ + f"""Start time: *{timestamp_to_human_readable(start_time).replace("-", "\\-")}*\\.\n""" \ + f"End time: *{timestamp_to_human_readable(end_time).replace("-", "\\-")}*\\.\n" \ + f"Backup duration: *{time_diff_to_human_readable(round(end_time - start_time))}*\\.\n" \ + f"Backup info:\n```json\n{pformat(self.backups['local'][-1].__dict__(), sort_dicts=False, indent=2, compact=True)}```\n", + markdown=True) + else: + self.logger.error(f"Backup failed.") + + if self.telegram_handler: + for index, handler in enumerate(self.logger.handlers): + if type(handler) is TimedRotatingFileHandler: + break + + path = normpath(self.logger.handlers[index].baseFilename) + self.telegram_handler.send_file(path, caption=f"Backup failed at {timestamp_to_human_readable(datetime.now().timestamp())}.") + + try: + self.save_backup_info() + except OSError: + self.logger.error(f"Backup info cannot be saved.") + self.logger.error(f"Printing backup info:\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}") + + if self.telegram_handler: + self.telegram_handler.send_message( + f"*Backup info cannot be saved\\.*\n" \ + f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", + markdown=True) + +telegram = ***REMOVED*** backup_manager = BackupManager( src_path="../test-source", dest_path="../test-target", - raw_to_keep=2, - compressed_to_keep=4, + raw_to_keep=1, + compressed_to_keep=1, ignored=None, s3_handler=None, - telegram_handler=None) - - + telegram_handler=telegram) -try: - backup_manager.load_backup_info() -except FileNotFoundError: - print("No backup info found. Restoring it.") - backup_manager.restore_backup_info() -backup_manager.create_backup() -backup_manager.save_backup_info() -print(backup_manager) \ No newline at end of file +backup_manager.run_backup() \ No newline at end of file diff --git a/src/log_dev.conf b/src/log_dev.conf index 27bea7d..1a46372 100644 --- a/src/log_dev.conf +++ b/src/log_dev.conf @@ -19,7 +19,7 @@ propagate=0 [handler_consoleHandler] class=StreamHandler -level=DEBUG +level=INFO formatter=consoleFormatter args=(sys.stdout,) diff --git a/src/requirements.txt b/src/requirements.txt index f7c94e5..acecbab 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,4 +1,5 @@ APScheduler==3.10.1 boto3==1.26.158 Flask==2.3.2 +psutil==5.9.6 Requests==2.31.0 diff --git a/src/telegram_handler.py b/src/telegram_handler.py index f0f3341..0b92595 100644 --- a/src/telegram_handler.py +++ b/src/telegram_handler.py @@ -18,7 +18,10 @@ def __init__(self, token:str, chat_id:str, logger:logging.Logger=None) -> None: self.logger = logger self.token = token self.chat_id = chat_id - self.logger.info("TelegramHandler initialized.") + if not self.test_connection(): + self.logger.error("TelegramHandler initialization failed.") + raise ConnectionError("TelegramHandler initialization failed.") + self.logger.debug("TelegramHandler initialized.") @property def token(self) -> str: @@ -111,7 +114,7 @@ def test_connection(self) -> bool: f"Response: {response.json()}.") return False - self.logger.info("Telegram connection test successful.") + self.logger.debug("Telegram connection test successful.") return True except Exception as e: self.logger.error(f"Telegram connection test failed. Exception: {e}.") @@ -158,7 +161,7 @@ def send_message(self, try: response = requests.post(url, data=data, timeout=10) - if not response.status_code != 200 or response.json()['ok']: + if response.status_code != 200 or not response.json()['ok']: self.logger.error( f"Failed to send message to Telegram chat. "\ f"Status code: {response.status_code}. Response: {response.json()}.") diff --git a/src/tools.py b/src/tools.py index f2e1aef..84f7731 100644 --- a/src/tools.py +++ b/src/tools.py @@ -45,6 +45,24 @@ def timestamp_to_file_name(timestamp: int) -> str: """ return datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M_%S") +def time_diff_to_human_readable(time_diff: int) -> str: + """Converts the time difference to a human readable format. + + Args: + time_diff (int): Time difference. + + Returns: + str: Human readable time difference. + """ + if time_diff < 60: + return f"{time_diff}s" + elif time_diff < 3600: + return f"{time_diff // 60}m {time_diff % 60}s" + elif time_diff < 86400: + return f"{time_diff // 3600}h {(time_diff % 3600) // 60}m {time_diff % 60}s" + else: + return f"{time_diff // 86400}d {(time_diff % 86400) // 3600}h {(time_diff % 3600) // 60}m {time_diff % 60}s" + def timeit(func): """Decorator to measure the execution time of a function. From 80e6352eda45a3bd885a65e6218db1f6d3c0c405 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Sat, 18 Nov 2023 11:43:34 +0100 Subject: [PATCH 17/28] s3handler rework --- src/backup.py | 3 +- src/backup_manager.py | 2 +- src/backups_manager_old.py | 924 ------------------------------------ src/s3_handler.py | 238 ++++++++-- src/telegram_handler_old.py | 176 ------- 5 files changed, 206 insertions(+), 1137 deletions(-) delete mode 100644 src/backups_manager_old.py delete mode 100644 src/telegram_handler_old.py diff --git a/src/backup.py b/src/backup.py index d68487e..3d635dc 100644 --- a/src/backup.py +++ b/src/backup.py @@ -4,12 +4,11 @@ import logging.config import inspect import shutil -from os import walk, remove +from os import walk, remove, cpu_count from os.path import exists, join, normpath, getsize from zipfile import ZipFile, ZIP_BZIP2 from threading import Lock from concurrent.futures import ThreadPoolExecutor -from multiprocessing import cpu_count from hashlib import md5, sha256, sha512, sha1 from tools import size_to_human_readable diff --git a/src/backup_manager.py b/src/backup_manager.py index 6b567a8..6910d36 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -799,7 +799,7 @@ def run_backup(self) -> None: f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", markdown=True) -telegram = ***REMOVED*** +telegram = TelegramHandler("***REMOVED***", "***REMOVED***", "***REMOVED***") backup_manager = BackupManager( src_path="../test-source", dest_path="../test-target", raw_to_keep=1, diff --git a/src/backups_manager_old.py b/src/backups_manager_old.py deleted file mode 100644 index 282c062..0000000 --- a/src/backups_manager_old.py +++ /dev/null @@ -1,924 +0,0 @@ -"""Module to manage backups - -Raises: - FileNotFoundError: Exception raised if source_path does not exist - FileNotFoundError: Exception raised if target_path does not exist - -Returns: - _type_: BackupManager object -""" -import logging -import logging.config -import json -import os -import shutil -from datetime import datetime -from time import perf_counter -import math -from multiprocessing import cpu_count - -class BackupManager(): - def __init__(self, - logger=None, - source_path: str = "/source", - target_path: str = "/target", - s3handler=None, - backup_info_file: str = None, - is_compression_enabled: bool = True, - archive_format: str = "tar.gz", - raw_backup_keep: int = 1, - compressed_backup_keep: int = 7, - s3_raw_keep: int = 1, - s3_compressed_keep: int = 3, - ignored_extensions: list = None, - puid: int = None, - pgid: int = None,): - """BackupManager class constructor - - Args: - logger (_type_, optional): Logger if available. Defaults to None. - source_path (str, optional): Custom absolute path from which backups will be created. Defaults to "/source". - target_path (str, optional): Custom absolute path where backups will be saved. Defaults to "/target". - s3handler (_type_, optional): Handle for S3Handler object. Defaults to None. - backup_info_file (str, optional): Custom backup_info.json file path. Defaults to None. - raw_backup_keep (int, optional): Number of raw backups to keep. Defaults to 1. - compressed_backup_keep (int, optional): Number of compressed backups to keep. Defaults to 7. - s3_raw_keep (int, optional): Number of raw backups to keep in S3. Defaults to 1. - s3_compressed_keep (int, optional): Number of compressed backups to keep in S3. Defaults to 3. - - Raises: - FileNotFoundError: Exception raised if source_path does not exist - FileNotFoundError: Exception raised if target_path does not exist - """ - if logger is None: - logging.config.fileConfig("log.conf") - self.logger = logging.getLogger('pybackupper_logger') - else: - self.logger = logger - - if not os.path.exists(source_path): - logger.error(f"Source path {source_path} does not exist") - raise FileNotFoundError(f"Source path {source_path} does not exist") - - self.source_path = source_path - - if not os.path.exists(target_path): - logger.error(f"Target path {target_path} does not exist") - raise FileNotFoundError(f"Target path {target_path} does not exist") - - self.target_path = target_path - - self.is_compression_enabled = is_compression_enabled - self.archive_format = archive_format - - if self.is_compression_enabled and self.map_archive_format(self.archive_format) is None: - self.logger.error(f"Archive format {self.archive_format} not supported") - raise ValueError(f"Archive format {self.archive_format} not supported") - - self.raw_backup_keep = raw_backup_keep - self.compressed_backup_keep = compressed_backup_keep - self.s3_raw_keep = s3_raw_keep - self.s3_compressed_keep = s3_compressed_keep - self.ignored_extensions = ignored_extensions - - self.s3handler = s3handler - - self.backups = self.load_backup_info_from_file(backup_info_file) - self.verify_backup_info() - - def save_backup_info_to_file(self, file_path: str=None, backup_info: dict = None) -> bool: - """Function to save the backup_info dictionary to a file - - Args: - file_path (str, optional): Absolute path to save the file. Defaults to None. - backup_info (dict, optional): Use custom backup_info instead of the generated one. Defaults to None. - - Returns: - bool: True if the file was saved successfully, False otherwise - """ - if file_path is None: - file_path = os.path.join(self.target_path, "backup_info.json") - - if backup_info is None: - backup_info = self.backups - - try: - with open(file_path, 'w') as file: - json.dump(backup_info, file, indent=4) - self.logger.debug(f"Backup info saved in file {file_path}") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def load_backup_info_from_file(self, file_path: str = None) -> dict: - """Function to load the backup_info dictionary from a file - Args: - file_path (str, optional): Custom absolute path to load the file from. Defaults to None. - - Returns: - dict: backup_info dictionary - """ - if file_path is None: - file_path = os.path.join(self.target_path, "backup_info.json") - - try: - with open(file_path, 'r') as file: - backup_info = json.load(file) - self.logger.debug(f"Backup info loaded from file {file_path}") - except Exception as e: - self.logger.warning(e) - self.logger.warning(f"Backup info file {file_path} not found, creating new one") - default_backup_info = { - "local_raw": [], - "local_compressed": [], - "s3_raw": [], - "s3_compressed": [] - } - self.save_backup_info_to_file(backup_info=default_backup_info) - return default_backup_info - return backup_info - - def verify_backup_info(self, target_path:str = None) -> bool: - """Function to verify that the backup info is correct and fix it if it is not - - Args: - target_path (str, optional): Custom absolute path were backups are saved. Defaults to None. - - Returns: - bool: True if the backup info is correct or was fixed, False otherwise - """ - if target_path is None: - target_path = self.target_path - - if not os.path.exists(target_path): - self.logger.error(f"Target path {target_path} does not exist") - return False - - directories = [f for f in os.listdir(target_path) if os.path.isdir(os.path.join(target_path, f))] - - for directory in directories: - if directory not in self.backups["local_raw"]: - self.logger.warning(f"Backup {directory} not listed in backup info") - self.backups["local_raw"].append(directory) - self.save_backup_info_to_file() - - for backup in self.backups["local_raw"]: - if backup not in directories: - self.logger.warning(f"Backup {backup} listed in backup info but not found in target path") - self.backups["local_raw"].remove(backup) - self.save_backup_info_to_file() - - if self.is_compression_enabled: - archives = [f for f in os.listdir(target_path) if os.path.isfile(os.path.join(target_path, f))] - try: - archives.remove("backup_info.json") - except ValueError: - pass - - for archive in archives: - if archive not in self.backups["local_compressed"]: - self.logger.warning(f"Backup {archive} not listed in backup info") - self.backups["local_compressed"].append(archive) - self.save_backup_info_to_file() - - for backup in self.backups["local_compressed"]: - if backup not in archives: - self.logger.warning(f"Backup {backup} listed in backup info but not found in target path") - self.backups["local_compressed"].remove(backup) - self.save_backup_info_to_file() - - if self.s3handler is not None: - s3_directories = self.s3handler.list_directories() - - for directory in s3_directories: - if directory not in self.backups["s3_raw"]: - self.logger.warning(f"Backup {directory} not listed in backup info") - self.backups["s3_raw"].append(directory) - self.save_backup_info_to_file() - - for backup in self.backups["s3_raw"]: - if backup not in s3_directories: - self.logger.warning(f"Backup {backup} listed in backup info but not found in s3") - self.backups["s3_raw"].remove(backup) - self.save_backup_info_to_file() - - if self.is_compression_enabled: - s3_archives = self.s3handler.list_files() - - for archive in s3_archives: - if archive not in self.backups["s3_compressed"]: - self.logger.warning(f"Backup {archive} not listed in backup info") - self.backups["s3_compressed"].append(archive) - self.save_backup_info_to_file() - - for backup in self.backups["s3_compressed"]: - if backup not in s3_archives: - self.logger.warning(f"Backup {backup} listed in backup info but not found in s3") - self.backups["s3_compressed"].remove(backup) - self.save_backup_info_to_file() - - return True - - - def create_raw_backup(self, backup_name: str = None, backup_path: str = None, source_path: str = None) -> bool: - """Function to create a raw backup - - Args: - backup_name (str, optional): Custom name for backup. Defaults to None. - backup_path (str, optional): Custom absolute path to save the backup. Defaults to None. - source_path (str, optional): Custom absolute path for backup source. Defaults to None. - - Returns: - bool: True if the backup was created, False otherwise - """ - if backup_name is None: - backup_name = datetime.today().strftime("%Y_%m_%d_%H_%M_%S") - - if backup_path is None: - backup_path = os.path.join(self.target_path, backup_name) - - if source_path is None: - source_path = self.source_path - - if not os.path.exists(source_path): - self.logger.error(f"Source path {source_path} does not exist") - return False - - if os.path.exists(backup_path): - logging.warning(f"Backup {backup_path} already exists") - if backup_name not in self.backups["local_raw"]: - self.backups["local_raw"].append(backup_name) - self.save_backup_info_to_file() - return True - - try: - shutil.copytree(source_path, backup_path, symlinks=True, ignore_dangling_symlinks=True, ignore=shutil.ignore_patterns(*self.ignored_extensions)) - self.logger.debug(f"Backup {backup_name} created") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - for root, dirs, files in os.walk(backup_path): - for file in files: - try: - source_file = os.path.join(root.replace(backup_path, self.source_path), file) - source_file_stat = os.stat(source_file) - target_file = os.path.join(root, file) - self.logger.debug(f"Copying stats from {source_file} to {target_file}") - shutil.copymode(source_file, target_file) - self.logger.debug(f"Chowning {target_file} to {source_file_stat.st_uid}:{source_file_stat.st_gid}") - shutil.chown(target_file, user=source_file_stat.st_uid, group=source_file_stat.st_gid) - except FileNotFoundError: - self.logger.error(f"File {source_file} not found") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - for dir in dirs: - try: - source_dir = os.path.join(root.replace(backup_path, self.source_path), dir) - source_dir_stat = os.stat(source_dir) - target_dir = os.path.join(root, dir) - self.logger.debug(f"Copying stats from {source_dir} to {target_dir}") - shutil.copymode(source_dir, target_dir) - self.logger.debug(f"Chowning {target_dir} to {source_dir_stat.st_uid}:{source_dir_stat.st_gid}") - shutil.chown(target_dir, user=source_dir_stat.st_uid, group=source_dir_stat.st_gid) - except FileNotFoundError: - self.logger.error(f"Directory {source_dir} not found") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - if backup_name not in self.backups["local_raw"]: - self.backups["local_raw"].append(backup_name) - self.save_backup_info_to_file() - - return True - - def map_archive_format(self, archive_format: str, reverse:bool= False) -> str: - """Function to map archive formatsf - - Args: - archive_format (str): Archive format to map - reverse (bool, optional): Map key to value accordingly. Defaults to False. - - Returns: - str: Mapped archive format - """ - if archive_format == "" or archive_format is None: - self.logger.error(f"Empty archive format") - return None - - mapping = { - "tar": "tar", - "tar.gz": "gztar", - "tar.bz2": "bztar", - "tar.xz": "xztar", - "zip": "zip" - } - - for k, v in mapping.items(): - if reverse: - if v == archive_format: - return k - if k == archive_format: - return v - return None - - def compress_backup(self, backup_path: str = None, archive_format: str = None) -> bool: - """Function to compress a backup - - Args: - backup_path (str, optional): Custom absolute path to read the backup from. Defaults to None. - archive_format (str, optional): Custom arichve format to compress to. Defaults to "tar.gz". - - Returns: - bool: True if the backup was compressed, False otherwise - """ - if backup_path is None: - if len(self.backups["local_raw"]) == 0: - self.logger.error(f"No local raw backups found") - return False - backup_path = os.path.join(self.target_path, self.backups["local_raw"][-1]) - - if not os.path.exists(backup_path): - self.logger.error(f"Backup path {backup_path} does not exist") - return False - - if archive_format is None: - archive_format = self.archive_format - if self.map_archive_format(archive_format) is None: - self.logger.error(f"Archive format {archive_format} not supported") - return False - - archive_path = backup_path + "." + archive_format - - if os.path.exists(archive_path): - logging.warning(f"Archive {archive_path} already exists") - if os.path.basename(archive_path) not in self.backups["local_compressed"]: - self.backups["local_compressed"].append(os.path.basename(archive_path)) - self.save_backup_info_to_file() - return True - - archive_name = os.path.basename(archive_path) - - try: - shutil_archive_format = self.map_archive_format(archive_format) - self.logger.debug(f"Compressing backup {backup_path} to {archive_path}") - # shutil.make_archive(backup_path, shutil_archive_format, backup_path) - - match archive_format: - case "tar": - command = f"tar -cf {archive_path} {backup_path}" - case "tar.gz": - command = f"""tar --use-compress-program="pigz -9 -N" -cf {archive_path} {backup_path}""" - case "tar.bz2": - command = f"tar -cjf {archive_path} {backup_path}" - case "tar.xz": - command = f"""tar --use-compress-program="pixz -9" -cf {archive_path} {backup_path}""" - case "zip": - command = f"""tar --use-compress-program="pigz -9 -N --zip" -cf {archive_path} {backup_path}""" - - os.system(command) - - self.logger.debug(f"Backup {backup_path} compressed to {archive_name}") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - self.backups["local_compressed"].append(os.path.basename(archive_name)) - self.save_backup_info_to_file() - - return True - - def send_raw_backup_to_s3(self, backup_path: str = None) -> bool: - """Function to send a raw backup to S3 - - Args: - backup_path (str, optional): Custom absolute path to read the backup from. Defaults to None. - - Returns: - bool: True if the backup was sent to S3, False otherwise - """ - if self.s3handler is None: - self.logger.error(f"No S3 handler found") - return False - - if self.s3_raw_keep == 0: - self.logger.error(f"S3 raw backups disabled") - return True - - if backup_path is None: - if len(self.backups["local_raw"]) == 0: - self.logger.error(f"No local raw backups found") - return False - backup_path = os.path.join(self.target_path, self.backups["local_raw"][-1]) - - if not os.path.exists(backup_path): - logging.error(f"Backup {backup_path} does not exist") - return False - - backup_name = os.path.basename(backup_path) - - if backup_name in self.backups["s3_raw"]: - logging.warning(f"Backup {backup_path} already exists in S3") - return True - - self.s3handler.upload_directory(backup_path, backup_name) - self.logger.debug(f"Backup {backup_path} sent to S3") - self.backups["s3_raw"].append(backup_name) - self.save_backup_info_to_file() - - return True - - def send_archive_to_s3(self, archive_path: str = None) -> bool: - """Function to send an archive to S3 - - Args: - archive_path (str, optional): Custom absolute path to read the backup archive from. Defaults to None. - - Returns: - bool: True if the archive was sent to S3, False otherwise - """ - if self.s3handler is None: - self.logger.error(f"No S3 handler found") - return False - - if self.s3_compressed_keep == 0: - self.logger.error(f"S3 compressed backups disabled") - return True - - if archive_path is None: - if len(self.backups["local_compressed"]) == 0: - self.logger.error(f"No local compressed backups found") - return False - archive_path = os.path.join(self.target_path, self.backups["local_compressed"][-1]) - - if not os.path.exists(archive_path): - logging.error(f"Archive {archive_path} does not exist") - return False - - archive_name = os.path.basename(archive_path) - - if archive_name in self.backups["s3_compressed"]: - logging.warning(f"Archive {archive_path} already exists in S3") - return True - - self.s3handler.upload_file(archive_path, archive_name) - self.logger.debug(f"Archive {archive_path} sent to S3") - self.backups["s3_compressed"].append(archive_name) - self.save_backup_info_to_file() - - return True - - def delete_raw_backup(self, backup_name: str = None, backup_path: str = None) -> bool: - """Function to delete a raw backup - - Args: - backup_name (str, optional): Custom backup name to be deleted. Defaults to None. - backup_path (str, optional): Custom absolute path were the backups are saved. Defaults to None. - - Returns: - bool: True if the backup was deleted, False otherwise - """ - if backup_name is None: - if len(self.backups["local_raw"]) == 0: - self.logger.error(f"No local raw backups found") - return False - backup_name = self.backups["local_raw"][0] - - if backup_path is None: - backup_path = os.path.join(self.target_path, backup_name) - - if not os.path.exists(backup_path): - logging.error(f"Backup {backup_path} does not exist") - return False - - try: - shutil.rmtree(backup_path) - self.logger.debug(f"Backup {backup_path} deleted") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - self.backups["local_raw"].remove(backup_name) - self.save_backup_info_to_file() - - return True - - def delete_compressed_backup(self, backup_name: str = None, backup_path: str = None) -> bool: - """Function to delete a compressed backup - - Args: - backup_name (str, optional): Custom archive name to be deleted. Defaults to None. - backup_path (str, optional): Custom absolute path were archives are saved. Defaults to None. - - Returns: - bool: True if the archive was deleted, False otherwise - """ - if backup_name is None: - if len(self.backups["local_compressed"]) == 0: - self.logger.error(f"No local compressed backups found") - return False - backup_name = self.backups["local_compressed"][0] - - if backup_path is None: - backup_path = os.path.join(self.target_path, backup_name) - - if not os.path.exists(backup_path): - logging.error(f"Backup {backup_path} does not exist") - return False - - try: - os.remove(backup_path) - self.logger.debug(f"Backup {backup_path} deleted") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - self.backups["local_compressed"].remove(backup_name) - self.save_backup_info_to_file() - - return True - - def delete_s3_raw_backup(self, backup_name: str = None) -> bool: - """Function to delete a raw backup from S3 - - Args: - backup_name (str, optional): Custom backup name to be deleted. Defaults to None. - - Returns: - bool: True if the backup was deleted, False otherwise - """ - if backup_name is None: - if len(self.backups["s3_raw"]) == 0: - self.logger.error(f"No S3 raw backups found") - return False - backup_name = self.backups["s3_raw"][0] - - try: - self.s3handler.delete_directory(backup_name) - self.logger.debug(f"S3 backup {backup_name} deleted") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - self.backups["s3_raw"].remove(backup_name) - self.save_backup_info_to_file() - - return True - - def delete_s3_compressed_backup(self, backup_name: str = None) -> bool: - """Function to delete a compressed backup from S3 - - Args: - backup_name (str, optional): Custom archive name to be deleted. Defaults to None. - - Returns: - bool: True if the archive was deleted, False otherwise - """ - if backup_name is None: - if len(self.backups["s3_compressed"]) == 0: - self.logger.error(f"No S3 compressed backups found") - return False - backup_name = self.backups["s3_compressed"][0] - - try: - self.s3handler.delete_file(backup_name) - self.logger.debug(f"S3 backup {backup_name} deleted") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - - self.backups["s3_compressed"].remove(backup_name) - self.save_backup_info_to_file() - - return True - - def delete_old_backups_from(self, backup_type: str, max_backups: int) -> bool: - """Function to delete old backups - - Args: - backup_type (str): Type of backup to delete (raw, compressed, s3_raw, s3_compressed) - max_backups (int): Maximum number of backups to keep - - Returns: - bool: True if the old backups were deleted, False otherwise - """ - if backup_type not in ["local_raw", "local_compressed", "s3_raw", "s3_compressed"]: - self.logger.error(f"Invalid backup type {backup_type}") - return False - - if max_backups < 0: - self.logger.error(f"Invalid max backups {max_backups}") - return False - - backups = self.backups[backup_type] - - if len(backups) <= max_backups: - self.logger.debug(f"No old backups to delete") - return True - - backups_to_delete = len(backups) - max_backups - self.logger.debug(f"Deleting {backups_to_delete} old backups from {backup_type}") - - for _ in range(backups_to_delete): - match backup_type: - case "local_raw": - self.delete_raw_backup() - case "local_compressed": - self.delete_compressed_backup() - case "s3_raw": - self.delete_s3_raw_backup() - case "s3_compressed": - self.delete_s3_compressed_backup() - - return True - - def delete_old_backups(self) -> bool: - """Function to delete old backups - - Returns: - bool: True if the old backups were deleted, False otherwise - """ - if not self.delete_old_backups_from("local_raw", self.raw_backup_keep): - return False - - if not self.delete_old_backups_from("local_compressed", self.compressed_backup_keep): - return False - - if self.s3handler is not None: - if not self.delete_old_backups_from("s3_raw", self.s3_raw_keep): - return False - - if not self.delete_old_backups_from("s3_compressed", self.s3_compressed_keep): - return False - - return True - - def convert_to_human_readable(self, size: int) -> str: - """Function to convert a size in bytes to human readable format - - Args: - size (int): Size in bytes - - Returns: - str: Size in human readable format - """ - if size == 0: - return "0B" - - power = int(math.log(size, 1024)) - units = ["B", "KB", "MB", "GB", "TB", "PB"] - converted_size = round(size / 1024**power, 2) - - return f"{converted_size}{units[power]}" - - def convert_time_to_human_readable(self, time: float) -> str: - """Function to convert time in seconds to human readable format - - Args: - time (float): Time in seconds - - Returns: - str: Time in human readable format - """ - if time == 0: - return "0s" - - power = int(math.log(time, 60)) - units = ["s", "m", "h"] - if power < 0: - power = 0 - if power > len(units) - 1: - power = len(units) - 1 - converted_time = round(time / 60**power, 2) - - return f"{converted_time}{units[power]}" - - def get_backup_dir_size(self, backup_dir: str=None) -> int: - """Function to get the size of a backup directory - - Args: - backup_dir (str, optional): Custom backup directory to get the size from. Defaults to None. - - Raises: - FileNotFoundError: Exception raised if the backup directory does not exist - - Returns: - int: Size of the backup directory in bytes - """ - if backup_dir is None: - backup_dir = self.target_path - - if not os.path.exists(backup_dir): - self.logger.error(f"Backup directory {backup_dir} does not exist") - raise FileNotFoundError(f"Backup directory {backup_dir} does not exist") - - backup_dir_size = 0 - - backup_dir_size = shutil.disk_usage(backup_dir).used - - # for root, _, files in os.walk(backup_dir): - # for file in files: - # try: - # backup_dir_size += os.path.getsize(os.path.join(root, file)) - # except FileNotFoundError as e: - # self.logger.error(f"File {os.path.join(root, file)} not found") - # except Exception as e: - # self.logger.error(e, exc_info=True) - # raise e - - self.logger.debug(f"Backup directory {backup_dir} size: {self.convert_to_human_readable(backup_dir_size)}") - - return backup_dir_size - - def get_last_backup_size(self) -> dict: - """Function to get the size of the last backup - - Returns: - dict: Dictionary with the size of the last backup in raw and compressed format - """ - backup_size = {} - backup_size["raw"] = self.convert_to_human_readable(self.get_backup_dir_size(os.path.join(self.target_path, self.backups["local_raw"][-1]))) - - if self.backups["local_compressed"][-1].startswith(self.backups["local_raw"][-1]): - backup_size["compressed"] = self.convert_to_human_readable(os.path.getsize(os.path.join(self.target_path, self.backups["local_compressed"][-1]))) - - return backup_size - - def get_backups_size(self) -> dict: - """Function to get the size of the backup directory and the S3 bucket - - Returns: - dict: Dictionary with the size of the backup directory and the S3 bucket - """ - backup_size = {} - - backup_size["local"] = self.convert_to_human_readable(self.get_backup_dir_size()) - - if self.s3handler is not None: - backup_size["s3"] = self.convert_to_human_readable(self.s3handler.get_bucket_size()) - - return backup_size - - def get_backup_size(self, backup:str) -> int: - """Function to get the size of a backup - - Args: - backup (str): Name of the backup - - Returns: - int: Size of the backup in bytes - """ - if backup in self.backups["local_raw"]: - return self.get_backup_dir_size(os.path.join(self.target_path, backup)) - elif backup in self.backups["local_compressed"]: - return os.path.getsize(os.path.join(self.target_path, backup)) - - return 0 - - def get_backup_dir_free_space(self, backup_dir: str=None) -> int: - """Function to get the free space in the backup directory - - Args: - backup_dir (str, optional): Path to the backup directory. Defaults to None. - - Raises: - FileNotFoundError: Exception raised if the backup directory does not exist - - Returns: - int: Free space in the backup directory - """ - if backup_dir is None: - backup_dir = self.target_path - - if not os.path.exists(backup_dir): - self.logger.error(f"Backup directory {backup_dir} does not exist") - raise FileNotFoundError(f"Backup directory {backup_dir} does not exist") - - backup_dir_free_space = shutil.disk_usage(backup_dir).free - - self.logger.debug(f"Backup directory free space: {self.convert_to_human_readable(backup_dir_free_space)}") - - return backup_dir_free_space - - def get_source_dir_size(self, source_path:str=None) -> int: - """Function to get the size of the source directory - - Args: - source_path (str, optional): Path to the source directory. Defaults to None. - - Raises: - FileNotFoundError: Exception raised if the source directory does not exist - - Returns: - int: Size of the source directory in bytes - """ - if source_path is None: - source_path = self.source_path - - if not os.path.exists(source_path): - self.logger.error(f"Source directory {source_path} does not exist") - raise FileNotFoundError(f"Source directory {source_path} does not exist") - - source_dir_size = 0 - for root, _, files in os.walk(source_path): - for file in files: - source_dir_size += os.path.getsize(os.path.join(root, file)) - - self.logger.debug(f"Source directory: {source_path} size: {self.convert_to_human_readable(source_dir_size)}") - - return source_dir_size - - def check_if_enough_free_space(self, with_archive:bool=True) -> bool: - """Function to check if there is enough free space in the backup directory - - Args: - with_archive (bool, optional): If True, the size of the archive will be checked. Defaults to True. - - Returns: - bool: True if there is enough free space, False otherwise - """ - source_dir_size = self.get_source_dir_size() - target_dir_free_space = self.get_backup_dir_free_space() - - if with_archive: - return source_dir_size * 2 < target_dir_free_space - return source_dir_size < target_dir_free_space - - def get_backup_info(self) -> dict: - """Function to get information about the backup - - Returns: - dict: Dictionary with information about the backup - """ - backup_info = {} - backup_info["backup_dir_free_space"] = self.convert_to_human_readable(self.get_backup_dir_free_space()) - backup_info["last_backup"] = self.backups["local_raw"][-1] - backup_info["last_backup_size"] = self.get_last_backup_size() - backup_info["backup_size"] = self.get_backups_size() - - if self.s3handler is not None: - backup_info["s3_bucket_size"] = self.convert_to_human_readable(self.s3handler.get_bucket_size()) - - try: - if self.s3handler.check_directory_exists(self.backups["s3_raw"][-1]): - backup_info["last_s3_backup"] = self.backups["s3_raw"][-1] - except Exception: - try: - if self.s3handler.check_file_exists(self.backups["s3_compressed"][-1]): - backup_info["last_s3_backup"] = self.backups["s3_compressed"][-1] - except Exception: - pass - finally: - if backup_info.get("last_s3_backup", None) is not None: - backup_info["last_s3_backup_size"] = "ERROR" - - - return backup_info - - def perform_backup(self)-> str: - """Function to perform a backup - - Returns: - str: If the backup was successful, returns string with statistics. If the backup failed, returns None - - Raises: - Exception: Exception raised if any of the backup steps fails - """ - self.logger.info(f"Performing backup. Starting at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - backup_start_time = perf_counter() - if not self.create_raw_backup(): - raise Exception("Failed to create raw backup") - - compress_start_time = perf_counter() - if self.is_compression_enabled: - if not self.compress_backup(): - raise Exception("Failed to compress backup") - compress_end_time = perf_counter() - - upload_start_time = perf_counter() - if self.s3handler is not None: - if not self.send_raw_backup_to_s3(): - raise Exception("Failed to send raw backup to S3") - if self.is_compression_enabled: - if not self.send_archive_to_s3(): - raise Exception("Failed to send archive to S3") - upload_end_time = perf_counter() - - if not self.delete_old_backups(): - raise Exception("Failed to delete old backups") - backup_end_time = perf_counter() - - response = f"\ -Backup performed in {self.convert_time_to_human_readable(backup_end_time - backup_start_time)}, \ -raw backup took {self.convert_time_to_human_readable(compress_start_time - backup_start_time)}, \ -compression took {self.convert_time_to_human_readable(compress_end_time - compress_start_time)}, \ -upload to S3 took {self.convert_time_to_human_readable(upload_end_time - upload_start_time)}, \ -deletion took {self.convert_time_to_human_readable(backup_end_time - upload_end_time)}" - - self.logger.info(response) - - return response \ No newline at end of file diff --git a/src/s3_handler.py b/src/s3_handler.py index 6ba3ae9..0752689 100644 --- a/src/s3_handler.py +++ b/src/s3_handler.py @@ -1,11 +1,40 @@ -import boto3 import logging import logging.config -import os -import concurrent.futures +import boto3 +from botocore.exceptions import ClientError +from os import walk, cpu_count +from os.path import basename, exists, join, normpath +from concurrent.futures import ThreadPoolExecutor +from time import sleep class S3Handler: - def __init__(self, bucket_name, access_key, secret_key, acl='public-read', region='us-east-1', url='https://s3.amazonaws.com', logger: logging.Logger = None): + def __init__(self, + bucket_name:str, + access_key:str, + secret_key:str, + acl:str='public-read', + region:str='us-east-1', + url:str='https://s3.amazonaws.com', + logger:logging.Logger = None): + """Initialize the S3Handler class. + + Args: + bucket_name (str): Bucket name. + access_key (str): Access key. + secret_key (str): Secret key. + acl (str, optional): ACL. Defaults to 'public-read'. + region (str, optional): Region. Defaults to 'us-east-1'. + url (_type_, optional): URL. Defaults to 'https://s3.amazonaws.com'. + logger (logging.Logger, optional): Logger. Defaults to None. + + Raises: + ConnectionError: If the connection to the bucket fails. + """ + + self.logger = logger + self.bucket_name = bucket_name + self.acl = acl + self.client = boto3.client( 's3', aws_access_key_id=access_key, @@ -13,46 +42,182 @@ def __init__(self, bucket_name, access_key, secret_key, acl='public-read', regio region_name=region, endpoint_url=url ) - self.bucket_name = bucket_name - self.acl = acl - + + if not self.test_connection(): + raise ConnectionError(f"Could not connect to bucket {self.bucket_name}") + + self.logger.debug(f"Connected to bucket {self.bucket_name}") + + @property + def bucket_name(self) -> str: + """Get the bucket name. + + Returns: + str: The bucket name. + """ + return self._bucket_name + + @bucket_name.setter + def bucket_name(self, bucket_name:str) -> None: + """Set the bucket name. + + Args: + bucket_name (str): The bucket name. + + Raises: + ValueError: If the bucket name is None or empty. + TypeError: If the bucket name is not a string. + """ + if bucket_name is None: + raise ValueError("bucket_name cannot be None") + + if not isinstance(bucket_name, str): + raise TypeError("bucket_name must be a string") + + if bucket_name == "": + raise ValueError("bucket_name cannot be empty") + + self._bucket_name = bucket_name + + @property + def acl(self) -> str: + """Get the acl. + + Returns: + str: The acl. + """ + return self._acl + + @acl.setter + def acl(self, acl:str) -> None: + """Set the acl. + + Args: + acl (str): The acl. + + Raises: + ValueError: If the acl is None or empty. + TypeError: If the acl is not a string. + """ + if acl is None: + raise ValueError("acl cannot be None") + + if not isinstance(acl, str): + raise TypeError("acl must be a string") + + if acl == "": + raise ValueError("acl cannot be empty") + + self._acl = acl + + @property + def logger(self) -> logging.Logger: + """Get the logger. + + Returns: + logging.Logger: The logger. + """ + return self._logger + + @logger.setter + def logger(self, logger:logging.Logger) -> None: + """Set the logger. + + Args: + logger (logging.Logger): The logger. + + Raises: + ValueError: If the logger is None. + TypeError: If the logger is not a logging.Logger. + """ if logger is None: - logging.config.fileConfig("log.conf") - self.logger = logging.getLogger('pybackupper_logger') + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') else: - self.logger = logger - - def upload_file(self, file_name, object_name=None): - if object_name is None: - object_name = os.path.basename(file_name) - - self.logger.debug(f"Uploading file {file_name} to {object_name}") + if not isinstance(logger, logging.Logger): + raise TypeError("logger must be a logging.Logger") + self._logger = logger + + def test_connection(self) -> bool: + """Test the connection to the bucket. + + Returns: + bool: True if the connection is successful, False otherwise. + """ try: - _ = self.client.upload_file(file_name, self.bucket_name, object_name, ExtraArgs={'ACL': self.acl}) - self.logger.debug(f"File {file_name} uploaded successfully") - except Exception as e: + _ = self.client.head_bucket(Bucket=self.bucket_name) + except ClientError as e: self.logger.error(e, exc_info=True) return False return True - - def upload_directory(self, directory_path, object_name=None): + + def upload_file(self, file_path:str, object_name:str=None): + """Upload a file to the bucket. + + Args: + file_path (str): The file path. + object_name (str, optional): The object name. Defaults to None. + + Raises: + FileNotFoundError: If the file does not exist. + error: botocore.exceptions.ClientError: If the upload fails. + """ + + if not exists(file_path): + raise FileNotFoundError(f"File {file_path} does not exist") + if object_name is None: - object_name = os.path.basename(directory_path) + object_name = basename(file_path) + object_name = object_name.replace('\\', '/') + + self.logger.debug(f"Uploading file {file_path} to {object_name}") + try: + _ = self.client.upload_file(file_path, self.bucket_name, object_name, ExtraArgs={'ACL': self.acl}) + self.logger.debug(f"File {file_path} uploaded successfully") + except ClientError as error: + if error.response['Error']['Code'] == 'LimitExceededException': + self.logger.warn('API call limit exceeded; backing off and retrying in 5 seconds...') + sleep(5) + self.upload_file(file_path, object_name) + else: + self.logger.exception(error, exc_info=True) + raise error + + def upload_directory(self, directory_path:str, object_name:str=None): + """Upload a directory to the bucket. + + Args: + directory_path (str): The directory path. + object_name (str, optional): The object name. Defaults to None. + + Raises: + FileNotFoundError: If the directory does not exist. + error: botocore.exceptions.ClientError: If the upload fails. + """ + if not exists(directory_path): + raise FileNotFoundError(f"Directory {directory_path} does not exist") + + if object_name is None: + object_name = basename(directory_path) self.logger.debug(f"Uploading directory {directory_path} to {object_name}") + files_to_upload = [] + for path, _, files in walk(directory_path): + dest_path = path.replace(directory_path, "") + for file in files: + files_to_upload.append((join(path, file), normpath(object_name + '/' + dest_path + '/' + file))) + + n_workers = cpu_count() * 2 + self.logger.debug(f"Uploading {len(files_to_upload)} files with {n_workers} workers") + try: - with concurrent.futures.ThreadPoolExecutor(max_workers=2*os.cpu_count()) as executor: - for path, _, files in os.walk(directory_path): - dest_path = path.replace(directory_path, "") - for file in files: - s3file = os.path.normpath(object_name + '/' + dest_path + '/' + file) - local_file = os.path.join(path, file) - self.logger.debug(f"upload : {local_file} to target: {s3file}") - executor.submit(self.upload_file, local_file, s3file) - except Exception as e: - self.logger.error(e, exc_info=True) - raise e + with ThreadPoolExecutor(max_workers=n_workers) as executor: + for file_path, object_name in files_to_upload: + executor.submit(self.upload_file, file_path, object_name) + except ClientError as error: + self.logger.exception(error, exc_info=True) + raise error def delete_file(self, file_name): try: @@ -208,4 +373,9 @@ def test_connection(self) -> bool: except Exception as e: self.logger.error(e, exc_info=True) return False - return True \ No newline at end of file + return True + +s3handler = S3Handler("***REMOVED***", "***REMOVED***", "***REMOVED***") + +s3handler.upload_file("../test-target/backup_info.json") +s3handler.upload_directory("../test-target/2023_11_16_20_46_54") \ No newline at end of file diff --git a/src/telegram_handler_old.py b/src/telegram_handler_old.py deleted file mode 100644 index e917245..0000000 --- a/src/telegram_handler_old.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Module for handling Telegram bot commands. -""" -import logging -import logging.config -import requests -import os -from pprint import pformat - -class TelegramHandler(): - """Class for handling Telegram bot commands. - """ - def __init__(self, token:str, chat_id:str, logger:logging.Logger=None): - """_summary_ - - Args: - token (str): Telegram bot token. - chat_id (str): Telegram chat id. - logger (logging.Logger, optional): Logger to use. Defaults to None. - - Raises: - ValueError: Exception raised when required argument has invalid value. - """ - if logger is None: - logging.config.fileConfig("log.conf") - self.logger = logging.getLogger('pybackupper_logger') - else: - self.logger = logger - - if token is None or token == "": - self.logger.error("Telegram token is not set.") - raise ValueError("Telegram token is not set.") - - if chat_id is None or chat_id == "": - self.logger.error("Telegram chat_id is not set.") - raise ValueError("Telegram chat_id is not set.") - - self.token = token - self.chat_id = chat_id - self.logger.info("TelegramHandler initialized.") - - def send_message(self, message:str): - """Sends message to Telegram chat. - - Args: - message (str): Message to send. - - Raises: - ValueError: Empty message. - Exception: Failed to send message to Telegram chat. - e: Exception raised when failed to send message to Telegram chat. - """ - if message is None or message == "": - self.logger.error("Message is empty.") - raise ValueError("Message is empty.") - - url = f"https://api.telegram.org/bot{self.token}/sendMessage" - data = { - "chat_id": self.chat_id, - "text": message, - "parse_mode": "markdown" - } - - try: - response = requests.post(url, data=data) - if response.status_code != 200: - self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") - self.logger.debug("Message sent to Telegram chat.") - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send message to Telegram chat.") - raise e - - def send_file(self, file_path:str): - """Sends file to Telegram chat. - - Args: - file_path (str): Path to file to send. - - Raises: - ValueError: File path is empty. - FileNotFoundError: File does not exist. - Exception: Failed to send file to Telegram chat. - e: Exception raised when failed to send file to Telegram chat. - """ - if file_path is None or file_path == "": - self.logger.error("File path is empty.") - raise ValueError("File path is empty.") - - if not os.path.exists(file_path): - self.logger.error(f"File {file_path} does not exist.") - raise FileNotFoundError(f"File {file_path} does not exist.") - - url = f"https://api.telegram.org/bot{self.token}/sendDocument" - data = { - "chat_id": self.chat_id, - } - files = { - "document": open(file_path, "rb"), - } - - try: - response = requests.post(url, data=data, files=files) - if response.status_code != 200: - self.logger.error(f"Failed to send file to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send file to Telegram chat. Status code: {response.status_code}") - self.logger.debug("File sent to Telegram chat.") - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send file to Telegram chat.") - raise e - - def send_backup_info(self, hostname:str, response:str, backup_info:dict): - """Sends backup info to Telegram chat. - - Args: - hostname (str): Hostname. - backup_info (dict): Backup info. - - Raises: - ValueError: Hostname or backup info is empty. - Exception: Failed to send message to Telegram chat. - e: Exception raised when failed to send message to Telegram chat. - """ - - if hostname is None or hostname == "": - self.logger.error("Hostname is empty.") - raise ValueError("Hostname is empty.") - - if response is None or response == "": - self.logger.error("Response is empty.") - raise ValueError("Response is empty.") - - if backup_info is None or backup_info == {}: - self.logger.error("Backup info is empty.") - raise ValueError("Backup info is empty.") - - url = f"https://api.telegram.org/bot{self.token}/sendMessage" - data = { - "chat_id": self.chat_id, - "text": f"""*PyBackUpper*\n*Hostname: {hostname}*\n\nOutput: {response}\n\nBackup info:\n`{pformat(backup_info)}`""", - "parse_mode": "markdown", - } - - try: - response = requests.post(url, data=data) - if response.status_code != 200: - self.logger.error(f"Failed to send message to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - raise Exception(f"Failed to send message to Telegram chat. Status code: {response.status_code}") - self.logger.debug("Message sent to Telegram chat.") - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to send message to Telegram chat.") - raise e - - def test_connection(self) -> bool: - """Tests connection to Telegram chat. - - Returns: - bool: True if connection is successful. - """ - url = f"https://api.telegram.org/bot{self.token}/getMe" - - try: - response = requests.post(url) - if response.status_code != 200: - self.logger.error(f"Failed to test connection to Telegram chat. Status code: {response.status_code}. Response: {response.text}") - return False - self.logger.debug("Connection to Telegram chat successful.") - return True - except Exception as e: - self.logger.error(e, exc_info=True) - self.logger.error("Failed to test connection to Telegram chat.") - return False - - From e6bf6b2eae523d739ac29af178320930733e88e8 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Tue, 21 Nov 2023 22:12:25 +0100 Subject: [PATCH 18/28] S3Handler part rework --- src/backup_manager.py | 11 - src/s3_handler.py | 459 ++++++++++++++++++++++++++++++------------ 2 files changed, 328 insertions(+), 142 deletions(-) diff --git a/src/backup_manager.py b/src/backup_manager.py index 6910d36..78eea0a 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -798,14 +798,3 @@ def run_backup(self) -> None: f"*Backup info cannot be saved\\.*\n" \ f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", markdown=True) - -telegram = TelegramHandler("***REMOVED***", "***REMOVED***", "***REMOVED***") -backup_manager = BackupManager( src_path="../test-source", - dest_path="../test-target", - raw_to_keep=1, - compressed_to_keep=1, - ignored=None, - s3_handler=None, - telegram_handler=telegram) - -backup_manager.run_backup() \ No newline at end of file diff --git a/src/s3_handler.py b/src/s3_handler.py index 0752689..a788d0e 100644 --- a/src/s3_handler.py +++ b/src/s3_handler.py @@ -1,11 +1,12 @@ import logging import logging.config import boto3 -from botocore.exceptions import ClientError -from os import walk, cpu_count -from os.path import basename, exists, join, normpath +from botocore.exceptions import ClientError +from os import walk, cpu_count, makedirs +from os.path import basename, exists, join, normpath, dirname from concurrent.futures import ThreadPoolExecutor from time import sleep +from tools import size_to_human_readable class S3Handler: def __init__(self, @@ -35,13 +36,13 @@ def __init__(self, self.bucket_name = bucket_name self.acl = acl - self.client = boto3.client( + self.bucket = boto3.resource( 's3', aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region, endpoint_url=url - ) + ).Bucket(self.bucket_name) if not self.test_connection(): raise ConnectionError(f"Could not connect to bucket {self.bucket_name}") @@ -144,10 +145,11 @@ def test_connection(self) -> bool: Returns: bool: True if the connection is successful, False otherwise. """ + self.logger.debug(f"Testing connection to bucket {self.bucket_name}") try: - _ = self.client.head_bucket(Bucket=self.bucket_name) + _ = self.bucket.meta.client.head_bucket(Bucket=self.bucket_name) except ClientError as e: - self.logger.error(e, exc_info=True) + self.logger.exception(e, exc_info=True) return False return True @@ -160,10 +162,11 @@ def upload_file(self, file_path:str, object_name:str=None): Raises: FileNotFoundError: If the file does not exist. - error: botocore.exceptions.ClientError: If the upload fails. + error: botocore.exceptions: If the upload fails. """ if not exists(file_path): + self.logger.error(f"File {file_path} does not exist") raise FileNotFoundError(f"File {file_path} does not exist") if object_name is None: @@ -172,7 +175,7 @@ def upload_file(self, file_path:str, object_name:str=None): self.logger.debug(f"Uploading file {file_path} to {object_name}") try: - _ = self.client.upload_file(file_path, self.bucket_name, object_name, ExtraArgs={'ACL': self.acl}) + _ = self.bucket.upload_file(file_path, object_name, ExtraArgs={'ACL': self.acl}) self.logger.debug(f"File {file_path} uploaded successfully") except ClientError as error: if error.response['Error']['Code'] == 'LimitExceededException': @@ -192,9 +195,10 @@ def upload_directory(self, directory_path:str, object_name:str=None): Raises: FileNotFoundError: If the directory does not exist. - error: botocore.exceptions.ClientError: If the upload fails. + error: botocore.exceptions: If the upload fails. """ if not exists(directory_path): + self.logger.error(f"Directory {directory_path} does not exist") raise FileNotFoundError(f"Directory {directory_path} does not exist") if object_name is None: @@ -219,46 +223,107 @@ def upload_directory(self, directory_path:str, object_name:str=None): self.logger.exception(error, exc_info=True) raise error - def delete_file(self, file_name): + def delete_file(self, file_name:str) -> None: + """Delete a file from the bucket. + + Args: + file_name (str): The file name. + + Raises: + ValueError: If the file name is None or empty. + TypeError: If the file name is not a string. + e: botocore.exceptions: If the delete fails. + """ + if file_name is None: + self.logger.error("file_name cannot be None") + raise ValueError("file_name cannot be None") + + if not isinstance(file_name, str): + self.logger.error("file_name must be a string") + raise TypeError("file_name must be a string") + + if file_name == "": + self.logger.error("file_name cannot be empty") + raise ValueError("file_name cannot be empty") + try: - _ = self.client.delete_object(Bucket=self.bucket_name, Key=file_name) + _ = self.bucket.delete_objects(Delete={'Objects': [{'Key': file_name}]}) self.logger.debug(f"File {file_name} deleted successfully") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def delete_directory(self, directory_path): + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e + + def delete_directory(self, directory_path:str) -> None: + """Delete a directory from the bucket. + + Args: + directory_path (str): The directory path. + + Raises: + ValueError: If the directory path is None or empty. + TypeError: If the directory path is not a string. + e: botocore.exceptions: If the delete fails. + """ + if directory_path is None: + self.logger.error("directory_path cannot be None") + raise ValueError("directory_path cannot be None") + + if not isinstance(directory_path, str): + self.logger.error("directory_path must be a string") + raise TypeError("directory_path must be a string") + + if directory_path == "": + self.logger.error("directory_path cannot be empty") + raise ValueError("directory_path cannot be empty") + + if directory_path[-1] != '/': + self.logger.debug(f"Adding '/' to directory_path {directory_path}") + directory_path += '/' + try: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=directory_path) - with concurrent.futures.ThreadPoolExecutor(max_workers=2*os.cpu_count()) as executor: - for content in response['Contents']: - if content['Key'].find('/') != -1: - self.logger.debug(f"Deleting file {content['Key']}") - executor.submit(self.delete_file, content['Key']) - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def list_buckets(self): + _ = self.bucket.objects.filter(Prefix=directory_path).delete() + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e + + def list_buckets(self) -> list: + """List all the buckets. + + Returns: + list: The list of buckets. + + Raises: + e: botocore.exceptions: If the list fails. + """ + self.logger.debug("Listing buckets") try: - response = self.client.list_buckets() - print(response) - for bucket in response['Buckets']: - self.logger.debug(f"Bucket: {bucket['Name']}") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def list_files(self, prefix=None) -> list: + return [bucket["Name"] for bucket in self.bucket.meta.client.list_buckets()['Buckets']] + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e + + def list_files(self, prefix:str=None) -> list: + """List all the files in the bucket. + + Args: + prefix (str, optional): The prefix. Defaults to None. + + Returns: + list: The list of files. + + Raises: + e: botocore.exceptions: If the list fails. + """ + self.logger.debug(f"Listing files in bucket {self.bucket_name}") files = [] + if prefix is not None and prefix[-1] != '/': + self.logger.debug(f"Adding '/' to prefix {prefix}") + prefix += '/' try: if prefix is None: - response = self.client.list_objects_v2(Bucket=self.bucket_name) + response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name) else: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) + response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) for content in response['Contents']: if prefix is not None: content['Key'] = content['Key'].replace(prefix, '') @@ -266,116 +331,248 @@ def list_files(self, prefix=None) -> list: files.append(content['Key']) except KeyError: return [] - except Exception as e: - self.logger.error(e, exc_info=True) - return [] + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e return files - - def list_directories(self, prefix=None) -> list: + + def list_directories(self, prefix:str=None) -> list: + """List all the directories in the bucket. + + Args: + prefix (str, optional): The prefix. Defaults to None. + + Returns: + list: The list of directories. + + Raises: + e: botocore.exceptions: If the list fails. + """ + self.logger.debug(f"Listing directories in bucket {self.bucket_name}") directories = [] + if prefix is not None and prefix[-1] != '/': + self.logger.debug(f"Adding '/' to prefix {prefix}") + prefix += '/' try: if prefix is None: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Delimiter='/') + response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Delimiter='/') else: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix, Delimiter='/') + response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix, Delimiter='/') for content in response.get('CommonPrefixes', []): if prefix is not None: content['Prefix'] = content['Prefix'].replace(prefix, '') directories.append(content['Prefix'].replace('/', '')) except KeyError: return [] - except Exception as e: - self.logger.error(e, exc_info=True) - return [] + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e return directories - - def list_tree(self, prefix=None) -> list: - tree = [] - try: - if prefix is None: - response = self.client.list_objects_v2(Bucket=self.bucket_name) - else: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) - for content in response['Contents']: - if prefix is not None: - content['Key'] = content['Key'].replace(prefix, '') - tree.append(content['Key']) - except Exception as e: - self.logger.error(e, exc_info=True) - return [] - return tree - - def download_file(self, file_path, object_name=None) -> bool: + + # TODO: Add list_tree method + + def download_file(self, object_name:str, save_path:str) -> None: + """Download a file from the bucket. + + Args: + object_name (str): The object name. + save_path (str): The save path. + + Raises: + ValueError: If the object name or save path is None or empty. + TypeError: If the object name or save path is not a string. + error: botocore.exceptions: If the download fails. + """ if object_name is None: - object_name = os.path.basename(file_path) + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") + + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") + + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + + if save_path is None: + self.logger.error("save_path cannot be None") + raise ValueError("save_path cannot be None") + + if not isinstance(save_path, str): + self.logger.error("save_path must be a string") + raise TypeError("save_path must be a string") + + if save_path == "": + self.logger.error("save_path cannot be empty") + raise ValueError("save_path cannot be empty") + + save_path = normpath(save_path) + + if not exists(dirname(save_path)): + self.logger.debug(f"Creating directory {dirname(save_path)}") + makedirs(dirname(save_path)) + self.logger.debug(f"Downloading file {object_name} to {save_path}") try: - _ = self.client.download_file(self.bucket_name, object_name, file_path) - self.logger.debug(f"File {file_path} downloaded successfully") - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def download_directory(self, directory_path, object_name=None) -> bool: + with open(save_path, 'wb') as f: + self.bucket.download_fileobj(object_name, f) + except ClientError as error: + self.logger.exception(error, exc_info=True) + raise error + + self.logger.debug(f"File {object_name} downloaded successfully") + + def download_directory(self, object_name:str, save_path:str) -> None: + """Download a directory from the bucket. + + Args: + object_name (str): The object name. + save_path (str): The save path. + + Raises: + ValueError: If the object name or save path is None or empty. + TypeError: If the object name or save path is not a string. + error: botocore.exceptions: If the download fails. + """ if object_name is None: - object_name = os.path.basename(directory_path) - - try: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=object_name) - for content in response['Contents']: - path = os.path.join(directory_path, os.path.dirname(content['Key'])) - if not os.path.exists(path): - os.makedirs(path) - self.download_file(os.path.join(path, os.path.basename(content['Key'])), content['Key']) - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") + + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") - def get_bucket_size(self): - # TODO: Fix this, now it is only getting the size of the files in the root of the bucket and not the size of the bucket + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + + if save_path is None: + self.logger.error("save_path cannot be None") + raise ValueError("save_path cannot be None") + + if not isinstance(save_path, str): + self.logger.error("save_path must be a string") + raise TypeError("save_path must be a string") + + if save_path == "": + self.logger.error("save_path cannot be empty") + raise ValueError("save_path cannot be empty") + + save_path = normpath(save_path) + + if not exists(save_path): + self.logger.debug(f"Creating directory {save_path}") + makedirs(save_path) + + self.logger.debug(f"Downloading directory {object_name} to {save_path}") + + n_workers = cpu_count() * 2 + self.logger.debug(f"Downloading with {n_workers} workers") + + for file in self.list_files(object_name): + try: + self.download_file(object_name + '/' + file, join(save_path, file)) + except ClientError as error: + self.logger.exception(error, exc_info=True) + raise error + + with ThreadPoolExecutor(max_workers=n_workers) as executor: + for directory in self.list_directories(object_name): + try: + executor.submit(self.download_directory, object_name + '/' + directory, join(save_path, directory)) + except ClientError as error: + self.logger.exception(error, exc_info=True) + raise error + + self.logger.debug(f"Directory {object_name} downloaded successfully") + + def get_bucket_size(self) -> int: + """Get the bucket size. + + Returns: + int: The bucket size. + + Raises: + e: botocore.exceptions: If the size cannot be calculated. + """ + self.logger.debug(f"Calculating size of bucket {self.bucket_name}") try: - # Get size of whole bucket - response = self.client.list_objects_v2(Bucket=self.bucket_name) - size = sum([content['Size'] for content in response['Contents']]) - except KeyError: - return 0 - except Exception as e: - self.logger.error(e, exc_info=True) + total_size = 0 + for key in self.bucket.objects.all(): + total_size += key.size + except ClientError as e: + self.logger.exception(e, exc_info=True) raise e - return size - - def check_file_exists(self, file_name): - try: - _ = self.client.head_object(Bucket=self.bucket_name, Key=file_name) - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True - - def check_directory_exists(self, directory_path): + self.logger.debug(f"Size of bucket {self.bucket_name} is {size_to_human_readable(total_size)}") + return total_size + + def check_object_exists(self, object_name:str) -> bool: + """Check if an object exists. + + Args: + object_name (str): The object name. + + Returns: + bool: True if the object exists, False otherwise. + + Raises: + ValueError: If the object name is None or empty. + TypeError: If the object name is not a string. + e: botocore.exceptions: If the check fails. + """ + if object_name is None: + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") + + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") + + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + try: - response = self.client.list_objects_v2(Bucket=self.bucket_name, Prefix=directory_path) - for content in response['Contents']: - if content['Key'].find('/') != -1: + for key in self.bucket.objects.all(): + if key.key.find(object_name) != -1: return True - except KeyError: - return False - except Exception as e: - self.logger.error(e, exc_info=True) - return False + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e return False - - def test_connection(self) -> bool: - try: - _ = self.client.head_bucket(Bucket=self.bucket_name) - except Exception as e: - self.logger.error(e, exc_info=True) - return False - return True -s3handler = S3Handler("***REMOVED***", "***REMOVED***", "***REMOVED***") + def get_object_path(self, object_name:str) -> str: + """Get the object path. + + Args: + object_name (str): The object name. + + Returns: + str: The object path. + + Raises: + ValueError: If the object name is None or empty. + TypeError: If the object name is not a string. + e: botocore.exceptions: If the check fails. + """ + if object_name is None: + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") -s3handler.upload_file("../test-target/backup_info.json") -s3handler.upload_directory("../test-target/2023_11_16_20_46_54") \ No newline at end of file + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") + + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + + try: + for key in self.bucket.objects.all(): + if key.key.find(object_name) != -1: + return key.key + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e + return None From 452270aebc2497172d8bfb443f73b12740479c7b Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 23 Nov 2023 18:45:30 +0100 Subject: [PATCH 19/28] S3Handler pylint --- src/s3_handler.py | 85 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/src/s3_handler.py b/src/s3_handler.py index a788d0e..6181cae 100644 --- a/src/s3_handler.py +++ b/src/s3_handler.py @@ -1,14 +1,16 @@ +"""S3Handler class.""" import logging import logging.config -import boto3 -from botocore.exceptions import ClientError from os import walk, cpu_count, makedirs from os.path import basename, exists, join, normpath, dirname from concurrent.futures import ThreadPoolExecutor from time import sleep +import boto3 +from botocore.exceptions import ClientError from tools import size_to_human_readable class S3Handler: + """S3Handler class.""" def __init__(self, bucket_name:str, access_key:str, @@ -179,7 +181,8 @@ def upload_file(self, file_path:str, object_name:str=None): self.logger.debug(f"File {file_path} uploaded successfully") except ClientError as error: if error.response['Error']['Code'] == 'LimitExceededException': - self.logger.warn('API call limit exceeded; backing off and retrying in 5 seconds...') + self.logger.warn( + 'API call limit exceeded; backing off and retrying in 5 seconds...') sleep(5) self.upload_file(file_path, object_name) else: @@ -203,26 +206,28 @@ def upload_directory(self, directory_path:str, object_name:str=None): if object_name is None: object_name = basename(directory_path) - + self.logger.debug(f"Uploading directory {directory_path} to {object_name}") files_to_upload = [] for path, _, files in walk(directory_path): dest_path = path.replace(directory_path, "") for file in files: - files_to_upload.append((join(path, file), normpath(object_name + '/' + dest_path + '/' + file))) + files_to_upload.append((join(path, file), + normpath(object_name + '/' + + dest_path + '/' + file))) n_workers = cpu_count() * 2 self.logger.debug(f"Uploading {len(files_to_upload)} files with {n_workers} workers") - + try: with ThreadPoolExecutor(max_workers=n_workers) as executor: - for file_path, object_name in files_to_upload: - executor.submit(self.upload_file, file_path, object_name) + for file_path, file_name in files_to_upload: + executor.submit(self.upload_file, file_path, file_name) except ClientError as error: self.logger.exception(error, exc_info=True) raise error - + def delete_file(self, file_name:str) -> None: """Delete a file from the bucket. @@ -321,9 +326,11 @@ def list_files(self, prefix:str=None) -> list: prefix += '/' try: if prefix is None: - response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name) + response = self.bucket.meta.client.list_objects_v2( + Bucket=self.bucket_name) else: - response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix) + response = self.bucket.meta.client.list_objects_v2( + Bucket=self.bucket_name, Prefix=prefix) for content in response['Contents']: if prefix is not None: content['Key'] = content['Key'].replace(prefix, '') @@ -355,9 +362,11 @@ def list_directories(self, prefix:str=None) -> list: prefix += '/' try: if prefix is None: - response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Delimiter='/') + response = self.bucket.meta.client.list_objects_v2( + Bucket=self.bucket_name, Delimiter='/') else: - response = self.bucket.meta.client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix, Delimiter='/') + response = self.bucket.meta.client.list_objects_v2( + Bucket=self.bucket_name, Prefix=prefix, Delimiter='/') for content in response.get('CommonPrefixes', []): if prefix is not None: content['Prefix'] = content['Prefix'].replace(prefix, '') @@ -369,8 +378,6 @@ def list_directories(self, prefix:str=None) -> list: raise e return directories - # TODO: Add list_tree method - def download_file(self, object_name:str, save_path:str) -> None: """Download a file from the bucket. @@ -412,7 +419,7 @@ def download_file(self, object_name:str, save_path:str) -> None: if not exists(dirname(save_path)): self.logger.debug(f"Creating directory {dirname(save_path)}") makedirs(dirname(save_path)) - + self.logger.debug(f"Downloading file {object_name} to {save_path}") try: with open(save_path, 'wb') as f: @@ -480,7 +487,10 @@ def download_directory(self, object_name:str, save_path:str) -> None: with ThreadPoolExecutor(max_workers=n_workers) as executor: for directory in self.list_directories(object_name): try: - executor.submit(self.download_directory, object_name + '/' + directory, join(save_path, directory)) + executor.submit( + self.download_directory, + object_name + '/' + directory, + join(save_path, directory)) except ClientError as error: self.logger.exception(error, exc_info=True) raise error @@ -504,7 +514,46 @@ def get_bucket_size(self) -> int: except ClientError as e: self.logger.exception(e, exc_info=True) raise e - self.logger.debug(f"Size of bucket {self.bucket_name} is {size_to_human_readable(total_size)}") + self.logger.debug( + f"Size of bucket {self.bucket_name} is {size_to_human_readable(total_size)}") + return total_size + + def get_object_size(self, object_name:str) -> int: + """Get the object size. + + Args: + object_name (str): The object name. + + Returns: + int: The object size. + + Raises: + ValueError: If the object name is None or empty. + TypeError: If the object name is not a string. + e: botocore.exceptions: If the size cannot be calculated. + """ + if object_name is None: + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") + + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") + + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + + self.logger.debug(f"Calculating size of object {object_name}") + try: + total_size = 0 + for key in self.bucket.objects.all(): + if key.key.find(object_name) != -1: + total_size += key.size + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e + self.logger.debug(f"Size of object {object_name} is {size_to_human_readable(total_size)}") return total_size def check_object_exists(self, object_name:str) -> bool: From 5749d138684a3d0edc7de1e4a9bfb2c063375609 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Thu, 23 Nov 2023 21:01:13 +0100 Subject: [PATCH 20/28] backup manager fixes --- src/backup_manager.py | 229 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 209 insertions(+), 20 deletions(-) diff --git a/src/backup_manager.py b/src/backup_manager.py index 78eea0a..1497984 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -13,6 +13,7 @@ from shutil import Error as shutilError from concurrent.futures import ThreadPoolExecutor from re import fullmatch +from botocore.exceptions import ClientError as botocoreClientError from backup import Backup from s3_handler import S3Handler from telegram_handler import TelegramHandler @@ -49,6 +50,8 @@ def __init__(self, try: self.load_backup_info() except FileNotFoundError: + print(listdir(self.dest_path)) + self.logger.warning(f"Backup info not found. Creating it.") self.restore_backup_info() self.logger.info("BackupManager initialized.") @@ -461,6 +464,7 @@ def save_backup_info(self, dest_path:str=None) -> None: Raises: OSError: Path cannot be created. + botocoreClientError: Error uploading backup info to S3. """ if dest_path is None or dest_path == "": dest_path = self.dest_path @@ -479,7 +483,16 @@ def save_backup_info(self, dest_path:str=None) -> None: path = normpath(join(dest_path, "backup_info.json")) with open(path, "w") as file: dump(self.__dict__(), file, indent=4) - self.logger.debug(f"Backup info saved to {dest_path}.") + self.logger.debug(f"Backup info saved locally to {dest_path}.") + + if self.s3_handler: + try: + self.s3_handler.upload_file(path, "backup_info.json") + except botocoreClientError as e: + self.logger.exception(f"Error uploading backup info to S3. {e}", exc_info=True) + raise botocoreClientError(f"Error uploading backup info to S3. {e}") + + self.logger.debug(f"Backup info saved to S3.") def load_backup_info(self, src_path:str=None, ignore_hash_mismatch:bool=True) -> None: """Load the backup info from a file. @@ -511,16 +524,36 @@ def load_backup_info(self, src_path:str=None, ignore_hash_mismatch:bool=True) -> except FileNotFoundError: self.logger.error(f"Backup {backup['name']} not found.") continue - if (backup["raw_hash"] != tmp_backup.calculate_raw_hash(method="sha256")) or \ - (backup["compressed_hash"] != tmp_backup.calculate_compressed_hash(method="sha256")): - if ignore_hash_mismatch: - self.logger.warning(f"Hash mismatch for backup {backup['name']}. Loading backup anyway.") - else: - self.logger.error(f"Hash mismatch for backup {backup['name']}. Skipping backup.") - continue + + if tmp_backup.completed and tmp_backup.compressed: + if (backup["raw_hash"] != tmp_backup.calculate_raw_hash(method="sha256")) or \ + (backup["compressed_hash"] != tmp_backup.calculate_compressed_hash(method="sha256")): + if ignore_hash_mismatch: + self.logger.warning(f"Hash mismatch for backup {backup['name']}. Loading backup anyway.") + else: + self.logger.error(f"Hash mismatch for backup {backup['name']}. Skipping backup.") + continue + + elif tmp_backup.compressed: + if (backup["compressed_hash"] != tmp_backup.calculate_compressed_hash(method="sha256")): + if ignore_hash_mismatch: + self.logger.warning(f"Hash mismatch for backup {backup['name']}. Loading backup anyway.") + else: + self.logger.error(f"Hash mismatch for backup {backup['name']}. Skipping backup.") + continue self.backups["local"].append(tmp_backup) + for backup in backup_info["backups"]["s3"]: + if backup in self.backups["local"]: + self.backups["s3"].append(backup) + else: + try: + self.backups["s3"].append(Backup(backup["name"], self.dest_path, self.ignored, self.logger)) + except FileNotFoundError: + self.logger.error(f"Backup {backup} not found.") + continue + self.logger.debug(f"Backup info loaded from {src_path}.") def restore_backup_info(self, src_path:str=None) -> None: @@ -613,13 +646,84 @@ def create_backup(self) -> bool: self.logger.debug(f"Compression ratio of {backup.name} is {backup.calculate_compression_ratio():.2f}.") self.backups["local"].append(backup) - self.save_backup_info() self.logger.info(f"Backup {backup.name} created.") self.logger.debug(f"Backup {backup.name} size is {size_to_human_readable(backup.get_size())}.") return True - def get_backup_index_by_name(self, backup_name:str) -> int: + def upload_backup_to_s3(self, backup_name:str) -> bool: + """Upload a backup to S3. + + Args: + backup_name (str): Name of the backup to upload. + + Returns: + bool: True if the backup has been uploaded, False otherwise. + """ + if self.s3_handler is None: + self.logger.error("S3 handler is not set.") + return False + + self.logger.debug(f"Uploading backup {backup_name} to S3...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + if not self.backups["local"][index].completed or \ + not self.backups["local"][index].compressed: + self.logger.error(f"Backup {backup_name} is not completed or compressed.") + return False + + try: + self.s3_handler.upload_file(self.backups["local"][index].dest_path + "/" + + backup_name + ".zip", backup_name + ".zip") + except FileNotFoundError: + self.logger.error(f"Backup {backup_name} not found.") + return False + except botocoreClientError as e: + self.logger.exception(f"Error uploading backup {backup_name} to S3. {e}", exc_info=True) + return False + + self.backups["s3"].append(self.backups["local"][index]) + self.logger.debug(f"Backup {backup_name} uploaded to S3.") + return True + + def download_backup_from_s3(self, backup_name:str) -> bool: + """Download a backup from S3. + + Args: + backup_name (str): Name of the backup to download. + + Returns: + bool: True if the backup has been downloaded, False otherwise. + """ + if self.s3_handler is None: + self.logger.error("S3 handler is not set.") + return False + + self.logger.debug(f"Downloading backup {backup_name} from S3...") + + try: + self.s3_handler.download_file(backup_name + ".zip", + self.dest_path + backup_name + ".zip") + except botocoreClientError as e: + self.logger.exception(f"Error downloading backup {backup_name} from S3. {e}", exc_info=True) + return False + + if not exists(self.dest_path + backup_name + ".zip"): + self.logger.error(f"Downloaded backup {backup_name} not found.") + return False + + self.logger.debug(f"Backup {backup_name} downloaded from S3.") + + self.backups["local"].append(Backup(backup_name, self.dest_path, self.ignored, self.logger)) + self.logger.debug(f"Backup {backup_name} added to local backups.") + self.save_backup_info() + return True + + def get_backup_index_by_name(self, backup_name:str, from_s3:bool=False) -> int: """Get the index of a backup by its name. Args: @@ -628,7 +732,12 @@ def get_backup_index_by_name(self, backup_name:str) -> int: Returns: int: Index of the backup. """ - for index, backup in enumerate(self.backups["local"]): + if from_s3 and self.s3_handler is None: + self.logger.error("S3 handler is not set.") + return -1 + + for index, backup in enumerate( + self.backups["local"] if not from_s3 else self.backups["s3"]): if backup.name == backup_name: return index return -1 @@ -690,8 +799,34 @@ def delete_compressed_backup(self, backup_name:str) -> bool: return True def delete_s3_backup(self, backup_name:str) -> bool: - # TODO: Implement - pass + """Delete a S3 backup. + + Args: + backup_name (str): Name of the backup to delete. + + Returns: + bool: True if the backup has been deleted, False otherwise. + """ + if self.s3_handler is None: + self.logger.error("S3 handler is not set.") + return False + + self.logger.debug(f"Deleting S3 backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name, from_s3=True) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.s3_handler.delete_file(backup_name + ".zip") + self.backups["s3"].pop(index) + except botocoreClientError as e: + self.logger.exception(f"Error deleting backup {backup_name} from S3. {e}", exc_info=True) + return False + + self.logger.debug(f"S3 backup {backup_name} deleted.") + return True def delete_backup(self, backup_name:str) -> bool: """Delete a backup. @@ -711,7 +846,6 @@ def delete_backup(self, backup_name:str) -> bool: try: self.backups["local"][index].delete_backup() - # TODO: Add S3 backup deletion if not self.backups["local"][index].completed and \ not self.backups["local"][index].compressed: self.backups["local"].pop(index) @@ -719,6 +853,14 @@ def delete_backup(self, backup_name:str) -> bool: self.logger.error(f"Backup {backup_name} not found.") return False + if self.s3_handler: + try: + self.s3_handler.delete_file(backup_name + ".zip") + self.backups["s3"].pop(index) + except botocoreClientError as e: + self.logger.exception(f"Error deleting backup {backup_name} from S3. {e}", exc_info=True) + return False + self.logger.debug(f"Backup {backup_name} deleted.") return True @@ -755,26 +897,61 @@ def delete_old_backups(self) -> None: self.logger.debug(f"Old backups deleted.") - def run_backup(self) -> None: - """Run the backup.""" + def run_backup(self) -> str: + """Run a backup. + + Returns: + str: Backup name. + """ start_time = datetime.now().timestamp() self.logger.info(f"Running backup. Start time: {timestamp_to_human_readable(start_time)}.") if self.create_backup(): - # TODO: Add S3 backup - self.delete_old_backups() end_time = datetime.now().timestamp() + + if self.s3_handler: + s3_result = self.upload_backup_to_s3(self.backups["local"][-1].name) + upload_end_time = datetime.now().timestamp() + + self.delete_old_backups() + self.logger.info(f"Backup completed. End time: {timestamp_to_human_readable(end_time)}.") self.logger.info(f"Backup duration: {time_diff_to_human_readable(round(end_time - start_time))}.") self.logger.info(f"""Backup info:\n{pformat(self.backups["local"][-1].__dict__(), sort_dicts=False, indent=2, compact=True)}.""") + if self.s3_handler and s3_result: + self.logger.info(f"Backup uploaded to S3. Upload duration: {time_diff_to_human_readable(round(upload_end_time - end_time))}.") + elif self.s3_handler and not s3_result: + self.logger.error(f"Backup upload to S3 failed.") if self.telegram_handler: - self.telegram_handler.send_message( + telegram_message = \ f"*Backup completed*\\.\n" \ f"""Start time: *{timestamp_to_human_readable(start_time).replace("-", "\\-")}*\\.\n""" \ f"End time: *{timestamp_to_human_readable(end_time).replace("-", "\\-")}*\\.\n" \ f"Backup duration: *{time_diff_to_human_readable(round(end_time - start_time))}*\\.\n" \ - f"Backup info:\n```json\n{pformat(self.backups['local'][-1].__dict__(), sort_dicts=False, indent=2, compact=True)}```\n", + f"Backup info:\n```json\n{pformat(self.backups['local'][-1].__dict__(), sort_dicts=False, indent=2, compact=True)}```\n" + + if self.s3_handler: + if s3_result: + telegram_message += \ + f"Backup uploaded to S3: *{True if self.s3_handler and s3_result else False}*\\.\n" \ + f"Upload duration: *{time_diff_to_human_readable(round(upload_end_time - end_time)) if s3_result else 0}*\\.\n" + + try: + s3_size = self.s3_handler.get_bucket_size() + telegram_message += \ + f"S3 size: *{size_to_human_readable(s3_size).replace(".", "\\.")}*\\.\n" + except botocoreClientError: + self.logger.error(f"Error getting S3 size.") + telegram_message += \ + f"*Error getting S3 size\\.*\n" + + else: + telegram_message += \ + f"*Backup uploaded to S3 failed\\.*\n" + + self.telegram_handler.send_message( + telegram_message, markdown=True) else: self.logger.error(f"Backup failed.") @@ -798,3 +975,15 @@ def run_backup(self) -> None: f"*Backup info cannot be saved\\.*\n" \ f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", markdown=True) + except botocoreClientError: + self.logger.error(f"Backup info cannot be uploaded to S3.") + self.logger.error(f"Printing backup info:\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}") + + if self.telegram_handler: + self.telegram_handler.send_message( + f"*Backup info cannot be uploaded to S3\\.*\n" \ + f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", + markdown=True) + + return self.backups["local"][-1].name + From 0db7b2f71423208aa34f110853095164052299e7 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Fri, 24 Nov 2023 17:01:05 +0100 Subject: [PATCH 21/28] test flask --- src/{main.py => main_old.py} | 0 src/server.py | 15 +++++ src/static/style.css | 37 +----------- src/templates/index.html | 110 ++++++++++++++++++++++++----------- 4 files changed, 94 insertions(+), 68 deletions(-) rename src/{main.py => main_old.py} (100%) create mode 100644 src/server.py diff --git a/src/main.py b/src/main_old.py similarity index 100% rename from src/main.py rename to src/main_old.py diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..173b0b6 --- /dev/null +++ b/src/server.py @@ -0,0 +1,15 @@ +import logging +import logging.config +from flask import Flask, render_template + +def Server(): + app = Flask(__name__) + + @app.route('/') + def index(): + return render_template('index.html') + + return app + +server = Server() +server.run(debug=True) \ No newline at end of file diff --git a/src/static/style.css b/src/static/style.css index 7304f96..8d195ae 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -1,35 +1,4 @@ -table, th, td { - border: 1px solid black; - border-collapse: collapse; - text-align: center; - width: 100%; - } - - th, td { - padding: 15px; - } - -tr:nth-child(odd) { background-color:#eee; } -tr:nth-child(even) { background-color:#fff; } - - button { - padding: 15px 20px; - margin: 8px 0; - border: 1px solid #ccc; - border-radius: 4px; - box-sizing: border-box; - cursor: pointer; - } - -.inline { - display: inline; -} - -.align-right { - /* make element input align right */ - float: right; -} - -button:hover{ - background: #383; +.actions-column { + width: 1%; + white-space: nowrap; } \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index 13ed02e..520d47e 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -5,43 +5,85 @@ PyBackUpper {% block styles %} - + + {% endblock %} - + {% block body %} -

PyBackUpper

- - - -

Hostname: {{hostname}}

- -

Last backup: {{last_backup}}

-

Next backup: {{next_backup}}

- -

Local size: {{local_size}}

-

Free space: {{free_space}}

-

S3 size: {{s3_size}}

- - - - - - - - - {% for backup in backups %} - - - - - - - {% endfor %} -
BackupSizeLocalS3
{{ backup.name }}{{ backup.size }}{% if backup.local == True %}✅{% else %}❌{% endif %}{% if backup.s3 == True %}✅{% else %}❌{% endif %}
+
+
+
+
+

Backups

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSizeRAWZIPS3Actions
2023_11_24_16_04_221.2GB + Download + Upload + Zip + Unzip + Restore + Delete +
2023_11_24_16_04_221.2GB + Upload + Zip + Restore + Delete +
2023_11_24_16_04_221.2GB + Download + Unzip + Restore + Delete +
+
+
+
{% endblock %} - + - - \ No newline at end of file + Date: Thu, 30 Nov 2023 21:05:29 +0100 Subject: [PATCH 22/28] basic flask server --- src/backup_manager.py | 42 ++++++++++-- src/log_dev.conf | 6 +- src/{main.py => main_old.py} | 0 src/s3_handler.py | 13 ++++ src/server.py | 128 ++++++++++++++++++++++++++++++++++ src/static/style.css | 37 +--------- src/templates/index.html | 129 +++++++++++++++++++++++++---------- 7 files changed, 277 insertions(+), 78 deletions(-) rename src/{main.py => main_old.py} (100%) create mode 100644 src/server.py diff --git a/src/backup_manager.py b/src/backup_manager.py index 1497984..5814d53 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -4,17 +4,20 @@ import logging.config from logging.handlers import TimedRotatingFileHandler from singleton import Singleton -from os.path import exists, normpath, getsize, join -from os import makedirs, walk, listdir +from os.path import exists, normpath, getsize, join, isfile, isdir +from os import makedirs, walk, listdir, remove, rmdir from datetime import datetime from psutil import disk_usage from json import dump, load from pprint import pformat from shutil import Error as shutilError +from shutil import rmtree from concurrent.futures import ThreadPoolExecutor +from threading import Thread from re import fullmatch from botocore.exceptions import ClientError as botocoreClientError from backup import Backup +from time import sleep from s3_handler import S3Handler from telegram_handler import TelegramHandler from tools import * @@ -589,6 +592,7 @@ def restore_backup_info(self, src_path:str=None) -> None: self.logger))) self.backups["local"].sort(key=lambda x: x.name, reverse=False) + self.backups["s3"].sort(key=lambda x: x.name, reverse=False) if len(self.backups["local"]) > 0: self.logger.debug(f"Restored backups\n{pformat(backups_list, sort_dicts=False, compact=True, indent=2)}\nfrom {src_path}") self.save_backup_info() @@ -736,8 +740,7 @@ def get_backup_index_by_name(self, backup_name:str, from_s3:bool=False) -> int: self.logger.error("S3 handler is not set.") return -1 - for index, backup in enumerate( - self.backups["local"] if not from_s3 else self.backups["s3"]): + for index, backup in enumerate(self.backups["local"] if not from_s3 else self.backups["s3"]): if backup.name == backup_name: return index return -1 @@ -759,13 +762,20 @@ def delete_raw_backup(self, backup_name:str) -> bool: return False try: + backup = self.backups["local"][index] self.backups["local"][index].delete_raw_backup() if not self.backups["local"][index].completed and \ not self.backups["local"][index].compressed: self.backups["local"].pop(index) + if self.s3_handler: + index_s3 = self.get_backup_index_by_name(backup.name, from_s3=True) + self.backups["s3"][index_s3].update(backup) except FileNotFoundError: self.logger.error(f"Backup {backup_name} not found.") return False + except botocoreClientError as e: + self.logger.exception(f"Error updating backup {backup_name} in S3. {e}", exc_info=True) + pass self.logger.debug(f"Raw backup {backup_name} deleted.") return True @@ -787,13 +797,20 @@ def delete_compressed_backup(self, backup_name:str) -> bool: return False try: + backup = self.backups["local"][index] self.backups["local"][index].delete_compressed_backup() if not self.backups["local"][index].completed and \ not self.backups["local"][index].compressed: self.backups["local"].pop(index) + if self.s3_handler: + index_s3 = self.get_backup_index_by_name(backup.name, from_s3=True) + self.backups["s3"][index_s3].update(backup) except FileNotFoundError: self.logger.error(f"Backup {backup_name} not found.") return False + except botocoreClientError as e: + self.logger.exception(f"Error updating backup {backup_name} in S3. {e}", exc_info=True) + pass self.logger.debug(f"Compressed backup {backup_name} deleted.") return True @@ -897,7 +914,18 @@ def delete_old_backups(self) -> None: self.logger.debug(f"Old backups deleted.") - def run_backup(self) -> str: + def clear_dest_path(self) -> None: + # dont remove dest_path itself + self.logger.debug(f"Clearing {self.dest_path}...") + for item in listdir(self.dest_path): + item_path = join(self.dest_path, item) + if isfile(item_path): + remove(item_path) + elif isdir(item_path): + rmtree(item_path) + self.logger.debug(f"{self.dest_path} cleared.") + + def run_backup(self, callback=None) -> str: """Run a backup. Returns: @@ -913,6 +941,10 @@ def run_backup(self) -> str: s3_result = self.upload_backup_to_s3(self.backups["local"][-1].name) upload_end_time = datetime.now().timestamp() + if callback: + sleep(10) + callback(True, "Backup completed.") + self.delete_old_backups() self.logger.info(f"Backup completed. End time: {timestamp_to_human_readable(end_time)}.") diff --git a/src/log_dev.conf b/src/log_dev.conf index 1a46372..0e502fd 100644 --- a/src/log_dev.conf +++ b/src/log_dev.conf @@ -21,7 +21,7 @@ propagate=0 class=StreamHandler level=INFO formatter=consoleFormatter -args=(sys.stdout,) +args=(sys.stdout, ) [handler_fileHandler] class=handlers.TimedRotatingFileHandler @@ -30,7 +30,7 @@ formatter=fileFormater args=('../test-logs/log.log', "D", 7, 10) [formatter_consoleFormatter] -format=%(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s +format=%(levelname)10s()s - %(module)20s() - %(funcName)30s() - %(message)s [formatter_fileFormater] -format=%(asctime)s - %(levelname)s - %(module)20s() - %(funcName)30s() - %(message)s \ No newline at end of file +format=%(asctime)s - %(levelname)10s()s - %(module)20s() - %(funcName)30s() - %(message)s \ No newline at end of file diff --git a/src/main.py b/src/main_old.py similarity index 100% rename from src/main.py rename to src/main_old.py diff --git a/src/s3_handler.py b/src/s3_handler.py index 6181cae..1bd91d3 100644 --- a/src/s3_handler.py +++ b/src/s3_handler.py @@ -625,3 +625,16 @@ def get_object_path(self, object_name:str) -> str: self.logger.exception(e, exc_info=True) raise e return None + + def clear_bucket(self) -> None: + """Clear the bucket. + + Raises: + e: botocore.exceptions: If the clear fails. + """ + self.logger.debug(f"Clearing bucket {self.bucket_name}") + try: + _ = self.bucket.objects.all().delete() + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..8a815f8 --- /dev/null +++ b/src/server.py @@ -0,0 +1,128 @@ +import logging +import logging.config +from secrets import token_hex +from flask import Flask, render_template, redirect, url_for, session +from flask.logging import default_handler +from threading import Thread +from backup_manager import BackupManager +from s3_handler import S3Handler +from tools import * + +class Message(): + def __init__(self, message: str, level: str): + self.message = message if isinstance(message, str) else str(message) + self.level = level if level in ["primary", + "secondary", + "success", + "danger", + "warning", + "info", + "light", + "dark"] else "info" + + def __str__(self): + return f"{self.level}: {self.message}" + + def __repr__(self): + return f"{self.level}: {self.message}" + + def __dict__(self): + return { + "message": self.message, + "level": self.level + } + + def __eq__(self, other): + if isinstance(other, Message): + return self.message == other.message and self.level == other.level + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash((self.message, self.level)) + +class Server(Flask): + def __init__(self, backupper: BackupManager, logger: logging.Logger=None): + super().__init__(__name__) + self.backupper = backupper + self.logger = logger + self.pending_backup = False + + super().logger.removeHandler(default_handler) + super().logger.addHandler(x for x in self.logger.handlers) + self.add_url_rule('/', view_func=self.index) + self.add_url_rule('/backup_info', view_func=self.backup_info, methods=['GET']) + self.add_url_rule('/backup_now', view_func=self.backup_now, methods=['POST']) + self.config["SERVER_NAME"] = "127.0.0.1:5000" + self.secret_key = token_hex(16) + + @property + def logger(self): + return self._logger + + @logger.setter + def logger(self, logger: logging.Logger): + if logger is None: + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') + else: + self._logger = logger + + def index(self): + message = session.pop("message", None) + backups_dict = self.backupper.__dict__() + local_size = backups_dict["local_size"] if "local_size" in backups_dict else 0 + s3_size = backups_dict["s3_size"] if "s3_size" in backups_dict else 0 + + backups = [] + for backup in backups_dict["backups"]["local"]: + backups.append({ + "name": backup["name"], + "size": backup["size"], + "raw": True if backup["completed"] else False, + "zip": True if backup["compressed"] else False, + "s3": True if backup in backups_dict["backups"]["s3"] else False, + }) + + for backup in backups_dict["backups"]["s3"]: + if backup not in backups_dict["backups"]["local"]: + backups.append({ + "name": backup["name"], + "size": size_to_human_readable(self.backupper.s3_handler.get_object_size(backup["name"])), + "raw": False, + "zip": False, + "s3": True, + }) + + backups.sort(key=lambda x: x["name"], reverse=True) + + return render_template('index.html', + pending_backup=self.pending_backup, + message=message, + backups=backups, + local_size=local_size, + s3_size=s3_size) + + def backup_info(self): + return self.backupper.__dict__() + + def backup_now(self): + self.logger.info("Backup requested") + message = Message("Backup requested", "info") + session["message"] = message.__dict__() + + self.pending_backup = True + Thread(target=self.backupper.run_backup, args=(self.backup_callback,)).start() + + with self.app_context(): + return redirect(url_for('index')) + + def backup_callback(self, success: bool, message: str): + message = Message(message, "success" if success else "danger") + self.pending_backup = False + with self.app_context(): + return redirect(url_for('index')) + + diff --git a/src/static/style.css b/src/static/style.css index 7304f96..33e6206 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -1,35 +1,4 @@ -table, th, td { - border: 1px solid black; - border-collapse: collapse; - text-align: center; - width: 100%; - } - - th, td { - padding: 15px; - } - -tr:nth-child(odd) { background-color:#eee; } -tr:nth-child(even) { background-color:#fff; } - - button { - padding: 15px 20px; - margin: 8px 0; - border: 1px solid #ccc; - border-radius: 4px; - box-sizing: border-box; - cursor: pointer; - } - -.inline { - display: inline; +.actions-column { + width: 1%; + white-space: nowrap; } - -.align-right { - /* make element input align right */ - float: right; -} - -button:hover{ - background: #383; -} \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index 13ed02e..5b692e1 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,47 +1,104 @@ - PyBackUpper {% block styles %} - + + {% endblock %} - - + {% block body %} -

PyBackUpper

- - - -

Hostname: {{hostname}}

- -

Last backup: {{last_backup}}

-

Next backup: {{next_backup}}

- -

Local size: {{local_size}}

-

Free space: {{free_space}}

-

S3 size: {{s3_size}}

- - - - - - - - - {% for backup in backups %} - - - - - - - {% endfor %} -
BackupSizeLocalS3
{{ backup.name }}{{ backup.size }}{% if backup.local == True %}✅{% else %}❌{% endif %}{% if backup.s3 == True %}✅{% else %}❌{% endif %}
+ +
+
+
+ {% if message %} +

{{ message.message }}

+ {% endif %} +

Backups

+ {% if backups | length == 0 %} +

No backups found.

+ {% else %} +

Found {{ backups|length }} backups.

+

Local size: {{ local_size }}, S3 size: {{ s3_size }}

+ + + + + + + + + + + + + {% for backup in backups %} + + + + {% if backup.raw %} + + {% else %} + + {% endif %} + {% if backup.zip %} + + {% else %} + + {% endif %} + {% if backup.s3 %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
NameSizeRAWZIPS3Actions
{{ backup.name }}{{ backup.size }} + {% if backup.raw %} +
+ +
+ {% endif %} + {% if backup.zip and not backup.raw %} +
+ +
+ {% endif %} + {% if backup.s3 and not backup.zip %} +
+ +
+ {% endif %} +
+ +
+
+ {% endif %} +
+
+
{% endblock %} - + - - \ No newline at end of file + Date: Tue, 5 Dec 2023 17:44:39 +0100 Subject: [PATCH 23/28] basic schedulers --- src/backup_manager.py | 5 +- src/requirements.txt | 1 + src/scheduler.py | 181 ++++++++++++++++++++++++++++++++++ src/server.py | 33 ++++++- src/templates/base.html | 56 +++++++++++ src/templates/home.html | 68 +++++++++++++ src/templates/index.html | 104 ------------------- src/templates/schedulers.html | 125 +++++++++++++++++++++++ 8 files changed, 463 insertions(+), 110 deletions(-) create mode 100644 src/scheduler.py create mode 100644 src/templates/base.html create mode 100644 src/templates/home.html delete mode 100644 src/templates/index.html create mode 100644 src/templates/schedulers.html diff --git a/src/backup_manager.py b/src/backup_manager.py index 5814d53..3bdc3ce 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -5,7 +5,7 @@ from logging.handlers import TimedRotatingFileHandler from singleton import Singleton from os.path import exists, normpath, getsize, join, isfile, isdir -from os import makedirs, walk, listdir, remove, rmdir +from os import makedirs, walk, listdir, remove from datetime import datetime from psutil import disk_usage from json import dump, load @@ -17,7 +17,6 @@ from re import fullmatch from botocore.exceptions import ClientError as botocoreClientError from backup import Backup -from time import sleep from s3_handler import S3Handler from telegram_handler import TelegramHandler from tools import * @@ -104,6 +103,7 @@ def __dict__(self) -> dict: "compressed_to_keep": self.compressed_to_keep, "local_size": size_to_human_readable(sum([backup.get_size() for backup in self.backups["local"]])), "s3_size": size_to_human_readable(self.s3_handler.get_bucket_size() if not self.s3_handler is None else 0), + "s3_to_keep": self.s3_to_keep, "backups": self.backups, } @@ -942,7 +942,6 @@ def run_backup(self, callback=None) -> str: upload_end_time = datetime.now().timestamp() if callback: - sleep(10) callback(True, "Backup completed.") self.delete_old_backups() diff --git a/src/requirements.txt b/src/requirements.txt index acecbab..7a26c30 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,5 +1,6 @@ APScheduler==3.10.1 boto3==1.26.158 +botocore==1.29.165 Flask==2.3.2 psutil==5.9.6 Requests==2.31.0 diff --git a/src/scheduler.py b/src/scheduler.py new file mode 100644 index 0000000..2b8909c --- /dev/null +++ b/src/scheduler.py @@ -0,0 +1,181 @@ +import logging +import logging.config +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger +from re import fullmatch +from datetime import datetime +from backup_manager import BackupManager +from tools import * +from threading import Thread +from time import sleep + +class Scheduler(BlockingScheduler): + def __init__(self, backupper:BackupManager, logger:logging.Logger=None, cron:str="0 0 * * *", timezone:str="UTC"): + super().__init__() + self.logger = logger + self.backupper = backupper + if not isinstance(cron, str) or not isinstance(timezone, str): + self.logger.error("Invalid cron or timezone") + raise ValueError("Invalid cron or timezone") + if not cron or not timezone: + self.logger.error("Invalid cron or timezone") + raise ValueError("Invalid cron or timezone") + if not fullmatch(r'^(([1-5]?[0-9]|\*)\s){4}([1-5]?[0-9]|\*)$', cron): + self.logger.error("Invalid cron") + raise ValueError("Invalid cron") + self.cron = cron + if not fullmatch(r'^[\w\/\-]+$', timezone): + self.logger.error("Invalid timezone") + raise ValueError("Invalid timezone") + self.cron = CronTrigger.from_crontab(cron, timezone=timezone) + self.sched_job = self.add_job( + self.backupper.run_backup, + trigger=self.cron, + id=f"backup_job_{cron.replace(' ', '_')}", + name=f"Backup job with cron: {cron} and timezone: {timezone}") + self.logger.info(f"Scheduler configured with cron: {cron} and timezone: {timezone}") + + def __del__(self)->None: + self.shutdown() + self.logger.info("Scheduler stopped") + + def __str__(self)->str: + return f"Scheduler with cron: {self.cron} and timezone: {self.timezone}. Next run: {timestamp_to_human_readable(self.cron.get_next_fire_time(datetime.now(), datetime.now()).timestamp())}" + + def __dict__(self)->dict: + return { + "id": self.sched_job.id, + "cron": self.cron, + "timezone": self.timezone, + "next_run": timestamp_to_human_readable(self.cron.get_next_fire_time(datetime.now(), datetime.now()).timestamp()) + } + + @property + def logger(self)->logging.Logger: + """The logger property. + + Returns: + logging.Logger: The logger instance + """ + return self._logger + + @logger.setter + def logger(self, logger:logging.Logger) -> None: + """Set the logger. + + Args: + logger (logging.Logger): Logger. + """ + if logger is None: + logging.config.fileConfig("log_dev.conf") + self._logger = logging.getLogger('pybackupper_logger') + else: + self._logger = logger + + @property + def backupper(self)->BackupManager: + """The backupper property. + + Returns: + BackupManager: The backupper instance + """ + return self._backupper + + @backupper.setter + def backupper(self, backupper:BackupManager) -> None: + """Set the backupper. + + Raises: + ValueError: If the backupper is invalid. + + Args: + backupper (BackupManager): Backupper. + """ + if backupper is None or not isinstance(backupper, BackupManager): + self.logger.error("Invalid backupper") + raise ValueError("Invalid backupper") + self._backupper = backupper + + @staticmethod + def to_cron(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str="*", month:str="*")->str: + """Convert the given parameters to a cron expression. + + Args: + minute (str, optional): Minute. Defaults to "0". + hour (str, optional): Hour. Defaults to "0". + day_of_week (str, optional): Day of week. Defaults to "*". For example: "0,1,2,3,4,5,6" or "MON,TUE,WED,THU,FRI,SAT,SUN". + day_of_month (str, optional): Day of month. Defaults to "*". + month (str, optional): Month. Defaults to "*". + + Raises: + ValueError: If one of the parameters is invalid. + + Returns: + str: The cron expression. + """ + + if minute is None or not isinstance(minute, str): + raise ValueError("Invalid minute") + elif minute != "*": + try: + i_minute = int(minute) + except ValueError: + raise ValueError("Invalid minute") + else: + if i_minute < 0 or i_minute > 59: + raise ValueError("Invalid minute") + + if hour is None or not isinstance(hour, str): + raise ValueError("Invalid hour") + elif hour != "*": + try: + i_hour = int(hour) + except ValueError: + raise ValueError("Invalid hour") + else: + if i_hour < 0 or i_hour > 23: + raise ValueError("Invalid hour") + + if day_of_week is None or not isinstance(day_of_week, str): + raise ValueError("Invalid day of week") + + if day_of_month is None or not isinstance(day_of_month, str): + raise ValueError("Invalid day of month") + elif day_of_month != "*": + try: + i_day_of_month = int(day_of_month) + except ValueError: + raise ValueError("Invalid day of month") + else: + if i_day_of_month < 0 or i_day_of_month > 31: + raise ValueError("Invalid day of month") + + if month is None or not isinstance(month, str): + raise ValueError("Invalid month") + elif month != "*": + try: + i_month = int(month) + except ValueError: + raise ValueError("Invalid month") + else: + if i_month < 0 or i_month > 12: + raise ValueError("Invalid month") + + if day_of_week == "*": + return f"0 {minute} {hour} {day_of_month} {month} *" + + day_of_week = day_of_week.lower().split(",") + + for i, day in enumerate(day_of_week): + if day not in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]: + try: + i_day = int(day) + except ValueError: + raise ValueError("Invalid days of week") + else: + if i_day < 0 or i_day > 6: + raise ValueError("Invalid days of week") + + day_of_week[i] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][i_day] + + return f"0 {minute} {hour} {day_of_month} {month} {','.join(day_of_week)}" diff --git a/src/server.py b/src/server.py index 8a815f8..74fa335 100644 --- a/src/server.py +++ b/src/server.py @@ -1,11 +1,13 @@ import logging import logging.config from secrets import token_hex -from flask import Flask, render_template, redirect, url_for, session +from flask import Flask, render_template, redirect, url_for, session, request +from flask_wtf.csrf import CSRFProtect from flask.logging import default_handler from threading import Thread from backup_manager import BackupManager from s3_handler import S3Handler +from scheduler import Scheduler from tools import * class Message(): @@ -49,14 +51,19 @@ def __init__(self, backupper: BackupManager, logger: logging.Logger=None): self.backupper = backupper self.logger = logger self.pending_backup = False + self.schedulers_list = [] super().logger.removeHandler(default_handler) super().logger.addHandler(x for x in self.logger.handlers) self.add_url_rule('/', view_func=self.index) self.add_url_rule('/backup_info', view_func=self.backup_info, methods=['GET']) self.add_url_rule('/backup_now', view_func=self.backup_now, methods=['POST']) + + self.add_url_rule('/schedulers', view_func=self.schedulers) + self.add_url_rule('/add_scheduler', view_func=self.add_scheduler, methods=['POST']) self.config["SERVER_NAME"] = "127.0.0.1:5000" - self.secret_key = token_hex(16) + self.config["SECRET_KEY"] = token_hex(16) + self.csrf = CSRFProtect(self) @property def logger(self): @@ -98,7 +105,7 @@ def index(self): backups.sort(key=lambda x: x["name"], reverse=True) - return render_template('index.html', + return render_template('home.html', pending_backup=self.pending_backup, message=message, backups=backups, @@ -125,4 +132,24 @@ def backup_callback(self, success: bool, message: str): with self.app_context(): return redirect(url_for('index')) + def schedulers(self): + tmp_schedulers = [scheduler.__dict__() for scheduler in self.schedulers_list] + return render_template('schedulers.html', schedulers=tmp_schedulers, pending_backup=self.pending_backup) + + def add_scheduler(self): + form = {} + for key in request.form: + form[key] = request.form[key] + + cron = Scheduler.to_cron( + form["minute1"], + form["hour1"], + form["dow1"], + form["day1"], + form["month1"]) + + print(cron) + + self.schedulers_list.append(Scheduler(self.backupper, cron=cron)) + return redirect(url_for('schedulers')) diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..e3897c8 --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,56 @@ + + + + PyBackUpper - {% block title %}{% endblock %} + {% block styles %} + + + {% endblock %} + + +{% block body %} + +
+
+
+ {% if message %} +

{{ message.message }}

+ {% endif %} + {% block content %} + {% endblock %} +
+
+
+{% endblock %} + + +Backups + {% if backups | length == 0 %} +

No backups found.

+ {% else %} +

Found {{ backups|length }} backups.

+

Local size: {{ local_size }}, S3 size: {{ s3_size }}

+ + + + + + + + + + + + + {% for backup in backups %} + + + + {% if backup.raw %} + + {% else %} + + {% endif %} + {% if backup.zip %} + + {% else %} + + {% endif %} + {% if backup.s3 %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
NameSizeRAWZIPS3Actions
{{ backup.name }}{{ backup.size }} + {% if backup.raw %} +
+ +
+ {% endif %} + {% if backup.zip and not backup.raw %} +
+ +
+ {% endif %} + {% if backup.s3 and not backup.zip %} +
+ +
+ {% endif %} +
+ +
+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html deleted file mode 100644 index 5b692e1..0000000 --- a/src/templates/index.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - PyBackUpper - {% block styles %} - - - {% endblock %} - - -{% block body %} - -
-
-
- {% if message %} -

{{ message.message }}

- {% endif %} -

Backups

- {% if backups | length == 0 %} -

No backups found.

- {% else %} -

Found {{ backups|length }} backups.

-

Local size: {{ local_size }}, S3 size: {{ s3_size }}

- - - - - - - - - - - - - {% for backup in backups %} - - - - {% if backup.raw %} - - {% else %} - - {% endif %} - {% if backup.zip %} - - {% else %} - - {% endif %} - {% if backup.s3 %} - - {% else %} - - {% endif %} - - - {% endfor %} - -
NameSizeRAWZIPS3Actions
{{ backup.name }}{{ backup.size }} - {% if backup.raw %} -
- -
- {% endif %} - {% if backup.zip and not backup.raw %} -
- -
- {% endif %} - {% if backup.s3 and not backup.zip %} -
- -
- {% endif %} -
- -
-
- {% endif %} -
-
-
-{% endblock %} - - - + + +
+

Schedulers

+ +
+ + {% if schedulers %} +

Found {{ schedulers|length }} schedulers.

+ + + + + + + + + + + {% for scheduler in schedulers %} + + + + + + {% endfor %} + +
IDCronNext runAction
{{ scheduler.id }}{{ scheduler.cron }}{{ scheduler.next_run }} +
+ +
+
+ {% else %} +

No schedulers found.

+ {% endif %} + + +{% endblock %} \ No newline at end of file From a469ae5ebcef027a15fa9c8d66e7e2ce31e713c9 Mon Sep 17 00:00:00 2001 From: MorganMLGman Date: Wed, 6 Dec 2023 19:14:12 +0100 Subject: [PATCH 24/28] Implement backup restore functionality and logs This commit includes several changes: - Added a restore functionality to the backup system. - Implemented log coloring based on log levels. - Introduced a new logs.html template. - Made various modifications to the backup, scheduler, and server modules. These changes aim to improve the user experience and functionality of the system. --- src/backup.py | 30 +---- src/backup_manager.py | 172 +++++++++++++++++++++++---- src/s3_handler.py | 36 ++++++ src/scheduler.py | 63 +++++----- src/server.py | 217 ++++++++++++++++++++++++++++++---- src/templates/base.html | 15 +-- src/templates/home.html | 55 +++++++-- src/templates/logs.html | 27 +++++ src/templates/schedulers.html | 4 +- 9 files changed, 508 insertions(+), 111 deletions(-) create mode 100644 src/templates/logs.html diff --git a/src/backup.py b/src/backup.py index 3d635dc..1b397e5 100644 --- a/src/backup.py +++ b/src/backup.py @@ -451,50 +451,32 @@ def compress_raw_backup(self) -> None: def delete_raw_backup(self) -> None: """Deletes raw backup. - - Raises: - FileNotFoundError: Backup is not completed. """ - if not self.completed: - self.logger.error(f"Backup {self.name} is not completed.") - raise FileNotFoundError(f"Backup {self.name} is not completed.") - self.logger.debug(f"Deleting raw backup {self.name}.") backup_path = join(self.dest_path, self.name) - shutil.rmtree(backup_path) + shutil.rmtree(backup_path, ignore_errors=True) self.completed = False self.logger.debug(f"Backup {self.name} deleted.") def delete_compressed_backup(self) -> None: """Deletes compressed backup. - - Raises: - FileNotFoundError: Backup is not compressed. """ - if not self.compressed: - self.logger.error(f"Backup {self.name} is not compressed.") - raise FileNotFoundError(f"Backup {self.name} is not compressed.") - self.logger.debug(f"Deleting compressed backup {self.name}.") backup_path = join(self.dest_path, self.name) - remove(f"{backup_path}.zip") + try: + remove(f"{backup_path}.zip") + except FileNotFoundError: + pass self.compressed = False self.logger.debug(f"Backup {self.name} deleted.") def delete_backup(self) -> None: """Deletes backup. - - Raises: - FileNotFoundError: Backup is not completed. """ - if not self.completed: - self.logger.error(f"Backup {self.name} is not completed.") - raise FileNotFoundError(f"Backup {self.name} is not completed.") - self.logger.debug(f"Deleting backup {self.name}.") self.delete_raw_backup() @@ -511,7 +493,7 @@ def restore_backup_from_raw(self, restore_path:str) -> None: restore_path (str): Destination path of the backup. Raises: - FileExistsError: Backup is already completed. + FileExistsError: Backup is not completed. FileNotFoundError: Backup does not exist. shutil.Error: Backup failed. """ diff --git a/src/backup_manager.py b/src/backup_manager.py index 3bdc3ce..005ce5d 100644 --- a/src/backup_manager.py +++ b/src/backup_manager.py @@ -13,9 +13,9 @@ from shutil import Error as shutilError from shutil import rmtree from concurrent.futures import ThreadPoolExecutor -from threading import Thread from re import fullmatch from botocore.exceptions import ClientError as botocoreClientError +from filecmp import dircmp from backup import Backup from s3_handler import S3Handler from telegram_handler import TelegramHandler @@ -34,6 +34,7 @@ def __init__(self, telegram_handler=None, logger:logging.Logger=None) -> None: self.logger = logger + self.pending_backup = False self.src_path = src_path self.dest_path = dest_path self.ignored = ignored @@ -375,6 +376,28 @@ def telegram_handler(self, telegram_handler) -> None: else: self._telegram_handler = telegram_handler + @property + def pending_backup(self) -> bool: + """Get the pending backup flag. + + Returns: + bool: Pending backup flag. + """ + return self._pending_backup + + @pending_backup.setter + def pending_backup(self, pending_backup:bool) -> None: + """Set the pending backup flag. + + Args: + pending_backup (bool): Pending backup flag. + """ + if not type(pending_backup) is bool: + self.logger.error("pending_backup must be a boolean.") + raise TypeError("pending_backup must be a boolean.") + + self._pending_backup = pending_backup + def generate_backup_name(self) -> str: """Generate a backup name. @@ -703,28 +726,45 @@ def download_backup_from_s3(self, backup_name:str) -> bool: Returns: bool: True if the backup has been downloaded, False otherwise. """ + if self.pending_backup: + self.logger.error("Backup task is running, need to wait for it to finish.") + return False + self.pending_backup = True if self.s3_handler is None: self.logger.error("S3 handler is not set.") return False self.logger.debug(f"Downloading backup {backup_name} from S3...") + for backup in self.backups["local"]: + if backup.name == backup_name and backup.compressed: + self.logger.debug(f"Backup {backup_name} already exists.") + self.pending_backup = False + return True + try: self.s3_handler.download_file(backup_name + ".zip", - self.dest_path + backup_name + ".zip") + self.dest_path + "/" + backup_name + ".zip") except botocoreClientError as e: self.logger.exception(f"Error downloading backup {backup_name} from S3. {e}", exc_info=True) + self.pending_backup = False return False - if not exists(self.dest_path + backup_name + ".zip"): + if not exists(self.dest_path + "/" + backup_name + ".zip"): self.logger.error(f"Downloaded backup {backup_name} not found.") + self.pending_backup = False return False self.logger.debug(f"Backup {backup_name} downloaded from S3.") - - self.backups["local"].append(Backup(backup_name, self.dest_path, self.ignored, self.logger)) + backup = Backup(backup_name, self.dest_path, self.ignored, self.logger) + self.backups["local"].append(backup) + self.backups["s3"][self.get_backup_index_by_name(backup_name, from_s3=True)].update(backup) self.logger.debug(f"Backup {backup_name} added to local backups.") - self.save_backup_info() + try: + self.save_backup_info() + except Exception as e: + self.logger.exception(f"Error saving backup info. {e}", exc_info=True) + self.pending_backup = False return True def get_backup_index_by_name(self, backup_name:str, from_s3:bool=False) -> int: @@ -766,7 +806,7 @@ def delete_raw_backup(self, backup_name:str) -> bool: self.backups["local"][index].delete_raw_backup() if not self.backups["local"][index].completed and \ not self.backups["local"][index].compressed: - self.backups["local"].pop(index) + _ = self.backups["local"].pop(index) if self.s3_handler: index_s3 = self.get_backup_index_by_name(backup.name, from_s3=True) self.backups["s3"][index_s3].update(backup) @@ -801,7 +841,7 @@ def delete_compressed_backup(self, backup_name:str) -> bool: self.backups["local"][index].delete_compressed_backup() if not self.backups["local"][index].completed and \ not self.backups["local"][index].compressed: - self.backups["local"].pop(index) + _ = self.backups["local"].pop(index) if self.s3_handler: index_s3 = self.get_backup_index_by_name(backup.name, from_s3=True) self.backups["s3"][index_s3].update(backup) @@ -837,7 +877,7 @@ def delete_s3_backup(self, backup_name:str) -> bool: try: self.s3_handler.delete_file(backup_name + ".zip") - self.backups["s3"].pop(index) + _ = self.backups["s3"].pop(index) except botocoreClientError as e: self.logger.exception(f"Error deleting backup {backup_name} from S3. {e}", exc_info=True) return False @@ -854,31 +894,35 @@ def delete_backup(self, backup_name:str) -> bool: Returns: bool: True if the backup has been deleted, False otherwise. """ + if self.pending_backup: + self.logger.error("Backup task is running, need to wait for it to finish.") + return False + self.pending_backup = True self.logger.debug(f"Deleting backup {backup_name}...") index = self.get_backup_index_by_name(backup_name) if index == -1: self.logger.error(f"Backup {backup_name} not found.") - return False - - try: - self.backups["local"][index].delete_backup() - if not self.backups["local"][index].completed and \ - not self.backups["local"][index].compressed: - self.backups["local"].pop(index) - except FileNotFoundError: - self.logger.error(f"Backup {backup_name} not found.") - return False + else: + try: + self.backups["local"][index].delete_backup() + except FileNotFoundError: + pass + _ = self.backups["local"].pop(index) if self.s3_handler: try: self.s3_handler.delete_file(backup_name + ".zip") - self.backups["s3"].pop(index) + _ = self.backups["s3"].pop(self.get_backup_index_by_name(backup_name, from_s3=True)) except botocoreClientError as e: self.logger.exception(f"Error deleting backup {backup_name} from S3. {e}", exc_info=True) - return False self.logger.debug(f"Backup {backup_name} deleted.") + try: + self.save_backup_info() + except Exception as e: + self.logger.exception(f"Error saving backup info. {e}", exc_info=True) + self.pending_backup = False return True def delete_old_backups(self) -> None: @@ -915,7 +959,6 @@ def delete_old_backups(self) -> None: self.logger.debug(f"Old backups deleted.") def clear_dest_path(self) -> None: - # dont remove dest_path itself self.logger.debug(f"Clearing {self.dest_path}...") for item in listdir(self.dest_path): item_path = join(self.dest_path, item) @@ -925,12 +968,96 @@ def clear_dest_path(self) -> None: rmtree(item_path) self.logger.debug(f"{self.dest_path} cleared.") + def unzip_backup(self, backup_name:str) -> bool: + """Unzip a backup. + + Args: + backup_name (str): Name of the backup to unzip. + + Returns: + bool: True if the backup has been unzipped, False otherwise. + """ + if self.pending_backup: + self.logger.error("Backup task is running, need to wait for it to finish.") + return False + self.pending_backup = True + self.logger.debug(f"Unzipping backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.backups["local"][index].unpack_compressed() + except FileNotFoundError: + self.logger.error(f"Backup {backup_name} not found.") + return False + + if not exists(join(self.dest_path, backup_name)): + self.logger.error(f"Unzipped backup {backup_name} not found.") + return False + + self.logger.debug(f"Backup {backup_name} unzipped.") + + if self.s3_handler: + self.backups["s3"][self.get_backup_index_by_name(backup_name, from_s3=True)].update(self.backups["local"][index]) + + self.pending_backup = False + return True + + def restore_backup(self, backup_name:str, restore_path:str) -> bool: + """Restore a backup. + + Args: + backup_name (str): Name of the backup to restore. + restore_path (str): Path to restore the backup. + + Returns: + bool: True if the backup has been restored, False otherwise. + """ + if self.pending_backup: + self.logger.error("Backup task is running, need to wait for it to finish.") + return False + self.pending_backup = True + self.logger.debug(f"Restoring backup {backup_name}...") + index = self.get_backup_index_by_name(backup_name) + + if index == -1: + self.logger.error(f"Backup {backup_name} not found.") + return False + + try: + self.backups["local"][index].restore_backup_from_raw(restore_path) + except FileNotFoundError as e: + self.logger.exception(f"Error restoring backup {backup_name}. {e}", exc_info=True) + return False + except shutilError as e: + self.logger.exception(f"Error restoring backup {backup_name}. {e}", exc_info=True) + return False + + if dircmp(self.src_path, join(self.dest_path, backup_name)).diff_files != []: + self.logger.error(f"Error restoring backup {backup_name}.") + return False + + self.logger.debug(f"Backup {backup_name} restored.") + + if self.s3_handler: + self.backups["s3"][self.get_backup_index_by_name(backup_name, from_s3=True)].update(self.backups["local"][index]) + + self.pending_backup = False + return True + def run_backup(self, callback=None) -> str: """Run a backup. Returns: str: Backup name. """ + if self.pending_backup: + self.logger.error("A backup is already running.") + return None + self.pending_backup = True start_time = datetime.now().timestamp() self.logger.info(f"Running backup. Start time: {timestamp_to_human_readable(start_time)}.") @@ -1016,5 +1143,6 @@ def run_backup(self, callback=None) -> str: f"Printing backup info:\n```json\n{pformat(self.__dict__(), sort_dicts=False, compact=True, indent=2)}```\n", markdown=True) + self.pending_backup = False return self.backups["local"][-1].name diff --git a/src/s3_handler.py b/src/s3_handler.py index 1bd91d3..b80f160 100644 --- a/src/s3_handler.py +++ b/src/s3_handler.py @@ -638,3 +638,39 @@ def clear_bucket(self) -> None: except ClientError as e: self.logger.exception(e, exc_info=True) raise e + + def create_download_link(self, object_name:str, expiration:int=3600) -> str: + """Create a download link for an object. + + Args: + object_name (str): The object name. + expiration (int, optional): The expiration time in seconds. Defaults to 3600. + + Returns: + str: The download link. + + Raises: + ValueError: If the object name is None or empty. + TypeError: If the object name is not a string. + e: botocore.exceptions: If the link cannot be created. + """ + if object_name is None: + self.logger.error("object_name cannot be None") + raise ValueError("object_name cannot be None") + + if not isinstance(object_name, str): + self.logger.error("object_name must be a string") + raise TypeError("object_name must be a string") + + if object_name == "": + self.logger.error("object_name cannot be empty") + raise ValueError("object_name cannot be empty") + + try: + return self.bucket.meta.client.generate_presigned_url( + 'get_object', + Params={'Bucket': self.bucket_name, 'Key': object_name}, + ExpiresIn=expiration) + except ClientError as e: + self.logger.exception(e, exc_info=True) + raise e \ No newline at end of file diff --git a/src/scheduler.py b/src/scheduler.py index 2b8909c..f59d84b 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -10,44 +10,36 @@ from time import sleep class Scheduler(BlockingScheduler): - def __init__(self, backupper:BackupManager, logger:logging.Logger=None, cron:str="0 0 * * *", timezone:str="UTC"): + def __init__(self, backupper:BackupManager, trigger:CronTrigger, logger:logging.Logger=None): super().__init__() self.logger = logger self.backupper = backupper - if not isinstance(cron, str) or not isinstance(timezone, str): - self.logger.error("Invalid cron or timezone") - raise ValueError("Invalid cron or timezone") - if not cron or not timezone: - self.logger.error("Invalid cron or timezone") - raise ValueError("Invalid cron or timezone") - if not fullmatch(r'^(([1-5]?[0-9]|\*)\s){4}([1-5]?[0-9]|\*)$', cron): - self.logger.error("Invalid cron") - raise ValueError("Invalid cron") - self.cron = cron - if not fullmatch(r'^[\w\/\-]+$', timezone): - self.logger.error("Invalid timezone") - raise ValueError("Invalid timezone") - self.cron = CronTrigger.from_crontab(cron, timezone=timezone) + self.trigger = trigger self.sched_job = self.add_job( self.backupper.run_backup, - trigger=self.cron, - id=f"backup_job_{cron.replace(' ', '_')}", - name=f"Backup job with cron: {cron} and timezone: {timezone}") - self.logger.info(f"Scheduler configured with cron: {cron} and timezone: {timezone}") + trigger=self.trigger, + id="backup_job_" + "_".join(str(x) for x in self.trigger.fields), + name=f"Backup job with cron: {self.trigger.fields} and timezone: {self.trigger.timezone}") + self.logger.info(f"Scheduler configured with cron: {self.trigger.fields} and timezone: {self.trigger.timezone}") def __del__(self)->None: self.shutdown() self.logger.info("Scheduler stopped") def __str__(self)->str: - return f"Scheduler with cron: {self.cron} and timezone: {self.timezone}. Next run: {timestamp_to_human_readable(self.cron.get_next_fire_time(datetime.now(), datetime.now()).timestamp())}" + return f"Scheduler with cron: {self.trigger.fields} \ + and timezone: {self.trigger.timezone}. \ + Next run: {timestamp_to_human_readable( + self.trigger.get_next_fire_time( + datetime.now(), + datetime.now()).timestamp())}" def __dict__(self)->dict: return { "id": self.sched_job.id, - "cron": self.cron, - "timezone": self.timezone, - "next_run": timestamp_to_human_readable(self.cron.get_next_fire_time(datetime.now(), datetime.now()).timestamp()) + "cron": ", ".join(f"{x.name}: {str(x)}" for x in self.trigger.fields), + "timezone": self.trigger.timezone, + "next_run": timestamp_to_human_readable(self.trigger.get_next_fire_time(datetime.now(), datetime.now()).timestamp()) } @property @@ -97,7 +89,7 @@ def backupper(self, backupper:BackupManager) -> None: self._backupper = backupper @staticmethod - def to_cron(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str="*", month:str="*")->str: + def to_CronTrigger(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str="*", month:str="*", timezone:str="UTC")->CronTrigger: """Convert the given parameters to a cron expression. Args: @@ -106,12 +98,13 @@ def to_cron(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str= day_of_week (str, optional): Day of week. Defaults to "*". For example: "0,1,2,3,4,5,6" or "MON,TUE,WED,THU,FRI,SAT,SUN". day_of_month (str, optional): Day of month. Defaults to "*". month (str, optional): Month. Defaults to "*". + timezone (str, optional): Timezone. Defaults to "UTC". Raises: ValueError: If one of the parameters is invalid. Returns: - str: The cron expression. + CronTrigger: The CronTrigger instance. """ if minute is None or not isinstance(minute, str): @@ -161,8 +154,18 @@ def to_cron(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str= if i_month < 0 or i_month > 12: raise ValueError("Invalid month") + if timezone is None or not isinstance(timezone, str): + raise ValueError("Invalid timezone") + if not fullmatch(r'^[\w\/\-]+$', timezone): + raise ValueError("Invalid timezone") + if day_of_week == "*": - return f"0 {minute} {hour} {day_of_month} {month} *" + return CronTrigger( + minute=minute, + hour=hour, + day=day_of_month, + month=month, + timezone=timezone) day_of_week = day_of_week.lower().split(",") @@ -178,4 +181,10 @@ def to_cron(minute:str="0", hour:str="0", day_of_week:str="*", day_of_month:str= day_of_week[i] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][i_day] - return f"0 {minute} {hour} {day_of_month} {month} {','.join(day_of_week)}" + return CronTrigger( + minute=minute, + hour=hour, + day=day_of_month, + day_of_week=",".join(day_of_week), + month=month, + timezone=timezone) diff --git a/src/server.py b/src/server.py index 74fa335..096dfb7 100644 --- a/src/server.py +++ b/src/server.py @@ -1,14 +1,17 @@ import logging import logging.config from secrets import token_hex -from flask import Flask, render_template, redirect, url_for, session, request +from flask import ( + Flask, render_template, redirect, url_for, session, request, send_file) from flask_wtf.csrf import CSRFProtect from flask.logging import default_handler from threading import Thread +from tzlocal import get_localzone from backup_manager import BackupManager from s3_handler import S3Handler from scheduler import Scheduler from tools import * +from time import sleep class Message(): def __init__(self, message: str, level: str): @@ -50,17 +53,27 @@ def __init__(self, backupper: BackupManager, logger: logging.Logger=None): super().__init__(__name__) self.backupper = backupper self.logger = logger - self.pending_backup = False self.schedulers_list = [] super().logger.removeHandler(default_handler) super().logger.addHandler(x for x in self.logger.handlers) self.add_url_rule('/', view_func=self.index) self.add_url_rule('/backup_info', view_func=self.backup_info, methods=['GET']) + self.add_url_rule('/info/', view_func=self.single_backup_info, methods=['GET']) self.add_url_rule('/backup_now', view_func=self.backup_now, methods=['POST']) + self.add_url_rule('/download/', view_func=self.download_backup, methods=['GET']) + + self.add_url_rule('/restore_backup', view_func=self.restore_backup, methods=['POST']) + self.add_url_rule('/unzip_backup', view_func=self.unzip_backup, methods=['POST']) + self.add_url_rule('/download_backup', view_func=self.download_from_s3, methods=['POST']) + self.add_url_rule('/delete_backup', view_func=self.delete_backup, methods=['POST']) self.add_url_rule('/schedulers', view_func=self.schedulers) self.add_url_rule('/add_scheduler', view_func=self.add_scheduler, methods=['POST']) + self.add_url_rule('/delete_scheduler', view_func=self.delete_scheduler, methods=['POST']) + + self.add_url_rule('/logs', view_func=self.logs) + self.config["SERVER_NAME"] = "127.0.0.1:5000" self.config["SECRET_KEY"] = token_hex(16) self.csrf = CSRFProtect(self) @@ -78,7 +91,6 @@ def logger(self, logger: logging.Logger): self._logger = logger def index(self): - message = session.pop("message", None) backups_dict = self.backupper.__dict__() local_size = backups_dict["local_size"] if "local_size" in backups_dict else 0 s3_size = backups_dict["s3_size"] if "s3_size" in backups_dict else 0 @@ -106,8 +118,8 @@ def index(self): backups.sort(key=lambda x: x["name"], reverse=True) return render_template('home.html', - pending_backup=self.pending_backup, - message=message, + pending_backup=self.backupper.pending_backup, + message=session.pop("message", None), backups=backups, local_size=local_size, s3_size=s3_size) @@ -115,41 +127,202 @@ def index(self): def backup_info(self): return self.backupper.__dict__() - def backup_now(self): - self.logger.info("Backup requested") - message = Message("Backup requested", "info") - session["message"] = message.__dict__() + def single_backup_info(self, name: str): + try: + for backup in self.backupper.backups["local"]: + if backup["name"] == name: + return backup.__dict__() + except FileNotFoundError: + session["message"] = Message(f"Backup with name {name} not found", "danger").__dict__() + return redirect(url_for('index')) + + try: + for backup in self.backupper.backups["s3"]: + if backup["name"] == name: + info = backup.__dict__() + try: + info["size"] = size_to_human_readable(self.backupper.s3_handler.get_object_size(name + ".zip")) + except Exception: + info["size"] = "Unknown" + return info + except Exception: + session["message"] = Message(f"Backup with name {name} not found", "danger").__dict__() + return redirect(url_for('index')) + + session["message"] = Message(f"Backup with name {name} not found", "danger").__dict__() + return redirect(url_for('index')) + + def download_backup(self, name: str): + try: + for backup in self.backupper.backups["local"]: + if backup["name"] == name: + return send_file(f"{self.backupper.dest_path}/{backup["name"]}.zip", as_attachment=True) + except FileNotFoundError: + pass + + try: + for backup in self.backupper.backups["s3"]: + if backup["name"] == name: + return redirect(self.backupper.s3_handler.create_download_link(name+".zip")) + except Exception: + pass + + session["message"] = Message(f"Backup with name {name} not found", "danger").__dict__() + return redirect(url_for('index')) + + def restore_backup(self): + name = request.form.get("backup_name", None) + file_path = request.form.get("file_path", None) - self.pending_backup = True - Thread(target=self.backupper.run_backup, args=(self.backup_callback,)).start() + if name is None or file_path is None: + session["message"] = Message("No backup name or file path provided", "danger").__dict__() + self.logger.error("No backup name or file path provided") + return redirect(url_for('index')) + + if not self.backupper.pending_backup: + Thread(target=self.backupper.restore_backup, args=(name, file_path)).start() + session["message"] = Message(f"Backup {name} restore requested", "info").__dict__() + return redirect(url_for('index')) + + session["message"] = Message("Backup task is running, need to wait", "warning").__dict__() + return redirect(url_for('index')) + + def unzip_backup(self): + name = request.form.get("name", None) + if name is None: + session["message"] = Message("No backup name provided", "danger").__dict__() + self.logger.error("No backup name provided") + return redirect(url_for('index')) + + if not self.backupper.pending_backup: + Thread(target=self.backupper.unzip_backup, args=(name,)).start() + session["message"] = Message(f"Backup {name} unzip requested", "info").__dict__() + return redirect(url_for('index')) + # if self.backupper.unzip_backup(name): + # session["message"] = Message(f"Backup {name} unzipped", "success").__dict__() + # self.logger.info(f"Backup {name} unzipped") + # else: + # session["message"] = Message(f"Error while unzipping backup {name}", "danger").__dict__() + # self.logger.error(f"Error while unzipping backup {name}") + # return redirect(url_for('index')) + + session["message"] = Message("Backup task is running, need to wait", "warning").__dict__() + return redirect(url_for('index')) + + def download_from_s3(self): + name = request.form.get("name", None) + if name is None: + session["message"] = Message("No backup name provided", "danger").__dict__() + self.logger.error("No backup name provided") + return redirect(url_for('index')) + + if not self.backupper.pending_backup: + Thread(target=self.backupper.download_backup_from_s3, args=(name,)).start() + session["message"] = Message(f"Backup {name} download requested", "info").__dict__() + return redirect(url_for('index')) + + session["message"] = Message("Backup task is running, need to wait", "warning").__dict__() + return redirect(url_for('index')) + + def delete_backup(self): + name = request.form.get("name", None) + if name is None: + session["message"] = Message("No backup name provided", "danger").__dict__() + self.logger.error("No backup name provided") + return redirect(url_for('index')) + + if not self.backupper.pending_backup: + if self.backupper.delete_backup(name): + session["message"] = Message(f"Backup {name} deleted", "success").__dict__() + self.logger.info(f"Backup {name} deleted") + return redirect(url_for('index')) + + session["message"] = Message(f"Error while deleting backup {name}", "danger").__dict__() + return redirect(url_for('index')) + + session["message"] = Message("Backup task is running, need to wait", "warning").__dict__() + return redirect(url_for('index')) + + def backup_now(self): + if not self.backupper.pending_backup: + self.logger.info("Backup requested") + message = Message("Backup requested", "info") + session["message"] = message.__dict__() + Thread(target=self.backupper.run_backup, args=(self.backup_callback,)).start() + else: + self.logger.info("Backup already running") + message = Message("Backup already running", "warning") + session["message"] = message.__dict__() with self.app_context(): return redirect(url_for('index')) def backup_callback(self, success: bool, message: str): message = Message(message, "success" if success else "danger") - self.pending_backup = False with self.app_context(): return redirect(url_for('index')) def schedulers(self): tmp_schedulers = [scheduler.__dict__() for scheduler in self.schedulers_list] - return render_template('schedulers.html', schedulers=tmp_schedulers, pending_backup=self.pending_backup) + return render_template( + 'schedulers.html', + schedulers=tmp_schedulers, + pending_backup=self.backupper.pending_backup, + message=session.pop("message", None)) def add_scheduler(self): form = {} for key in request.form: form[key] = request.form[key] - cron = Scheduler.to_cron( - form["minute1"], - form["hour1"], - form["dow1"], - form["day1"], - form["month1"]) - - print(cron) + try: + cron = Scheduler.to_CronTrigger( + form["minute1"], + form["hour1"], + form["dow1"], + form["day1"], + form["month1"], + timezone=str(get_localzone())) + except ValueError as e: + self.logger.exception("Failed to create cron trigger", exc_info=e) + session["message"] = Message("Failed to create cron trigger", "danger").__dict__() + return redirect(url_for('schedulers')) + else: + scheduler = Scheduler(self.backupper, trigger=cron) + self.schedulers_list.append(scheduler) + Thread(target=scheduler.start).start() + return redirect(url_for('schedulers')) - self.schedulers_list.append(Scheduler(self.backupper, cron=cron)) + def delete_scheduler(self): + id = request.form.get("sched_id", None) + if id is None: + session["message"] = Message("No scheduler id provided", "danger").__dict__() + self.logger.error("No scheduler id provided") + return redirect(url_for('schedulers')) + for scheduler in self.schedulers_list: + if scheduler.sched_job.id == id: + session["message"] = Message(f"Scheduler with id {id} found and stopped", "success").__dict__() + self.logger.info(f"Scheduler with id {id} found and stopped") + scheduler.shutdown() + self.schedulers_list.remove(scheduler) + break + else: + session["message"] = Message(f"Scheduler with id {id} not found", "danger").__dict__() + self.logger.error(f"Scheduler with id {id} not found") return redirect(url_for('schedulers')) + def logs(self): + for handler in self.logger.handlers: + if isinstance(handler, logging.FileHandler): + log_file = handler.baseFilename + break + else: + log = "No log file found" + return render_template('logs.html', log=log) + with open(log_file, "r") as f: + log = f.read() + if log == "": + log = "No logs yet" + return render_template('logs.html', log=log) + + diff --git a/src/templates/base.html b/src/templates/base.html index e3897c8..6e7dadb 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -3,8 +3,7 @@ PyBackUpper - {% block title %}{% endblock %} {% block styles %} - - + {% endblock %} @@ -23,13 +22,15 @@ - -
- Loading... -
- +