import os.path import docker import restic import yaml import configparser import argparse bindingIncludePattern = None bindingExcludePattern = None remoteHost = None remoteUser = None repository = None passwordFile = None def arguments_parser(): ''' récupère les arguments de la commande :return: ''' parser = argparse.ArgumentParser( prog='BackandUp', description='Parse your docker-compose.yml file to automatically shut the containers, backup their volumes ' 'and restart them. Dependencies between containers are respected.', epilog='BackandUp 2023') parser.add_argument('-c', '--config', action='store', default='./configuration.ini', help='configuration file for BackandUp. By default it looks for a configuration.ini ' 'in its folder. It must contains the configuration for the backup service ' 'and can also contains the BackandUp own configuration.') parser.add_argument('--compose', action='store', default='./docker-compose.yml', help='compose file to parse. By default it looks for a docker-compose.yml in its folder.') parser.add_argument('--sshfolder', action='store', default='~/.ssh', help='ssh folder for sftp connection. By default it uses ~/.ssh.') parser.add_argument('-x', '--bindexclude', action='store', default=None, help='Patterns of volume or bind to exclude from the backup.') parser.add_argument('-i', '--bindinclude', action='store', default=None, help='Patterns of volume or bind to include from the backup. Using this argument, ' 'only matching volumes and binds will be backed up.') args = parser.parse_args() print(args) print(vars(args)) bindcludepattern = None bindexcludepattern = None configfile = None composefile = "/root/compose/core/docker-compose.yml" sshfolder = "/root/.ssh" remotehost = None repository = None passwordfile = None def read_config_file(filepath: str = "configuration.ini"): parser = configparser.ConfigParser() parser.read(filepath) global bindingIncludePattern global bindingExcludePattern global remoteHost global repository global passwordFile bindingIncludePattern = parser["backandup"]["bindincludepattern"] bindingExcludePattern = parser["backandup"]["bindexcludepattern"] remoteHost = parser["restic"]["remotehost"] remoteUser = parser["restic"]["remoteuser"] repository = parser["restic"]["repository"] passwordFile = parser["restic"]["passwordfile"] def do_backup(dirpath: str, tags: str = None): restic.repository = f"sftp:{remoteUser}@{remoteHost}:/{repository}" restic.password_file = passwordFile if tags is None: restic.backup(paths=[dirpath]) else: restic.backup(paths=[dirpath], tags=[]) def backup_ct_binds(ct: docker.models.containers.Container, includepattern: str | list = None, excludepattern: str | list = None): if ct.attrs['HostConfig']['Binds'] is None: print(" Nothing to backup") return 0 if type(includepattern) is str: includepattern = [includepattern] if type(excludepattern) is str: excludepattern = [excludepattern] if includepattern is None: includepattern = ["NOPATTERNTOINCLUDEXXXXXX"] if excludepattern is None: excludepattern = ["NOPATTERNTOEXCLUDEXXXXXX"] for BindMount in ct.attrs['HostConfig']['Binds']: BindPath = BindMount.split(":")[0] print(f" {BindPath}") # Si on matche au moins 1 pattern d'inclusion ET aucun pattern d'exclusion if any(x in BindPath for x in includepattern) and not any(x in BindPath for x in excludepattern): do_backup(BindPath) else: print(" not a directory to backup") return 0 def stop_backup_restart_container(ct: docker.models.containers.Container): print(ct.name) dorestart = False if ct.attrs['State']['Status'] == 'running': print(" stop the container") dorestart = True ct.stop() backup_ct_binds(ct, bindingIncludePattern, bindingExcludePattern) if dorestart: print(" start the container") ct.start() return 0 def yaml_to_services_list(filepath: str) -> dict|None: ''' Retourne la liste des services définis dans un fichier docker-compose.yaml sous forme de liste :param filepath: str :return: list|None ''' print(f"sorting services from {filepath}") serviceslist = dict() if not os.path.isfile(filepath): raise OSError(2, "No such file", filepath) ycontent = None with open(filepath, 'r') as yfile: try: ycontent = yaml.safe_load(yfile) except yaml.YAMLError as yerror: print(f"{filepath} doesn't seems to be a yaml file.") raise yerror if ycontent is None: return None if "services" not in ycontent: raise Exception("The file doesn't seems to be a correct docker-compose yaml") # on liste les services et ceux dont ils dépendent for servicename in ycontent["services"]: print(f"appending {servicename}") mustbefore = [] if "depends_on" in ycontent["services"][servicename]: for dependencie in ycontent["services"][servicename]["depends_on"]: mustbefore.append(dependencie) serviceslist[servicename] = {"must_before": mustbefore, "must_after": [], "backupdone": False} # on reparcourt une seconde fois pour définir de quels services ilssont les dépendances for service in serviceslist: if len(serviceslist[service]["must_before"]): for dependencie in serviceslist[service]["must_before"]: if service not in serviceslist[dependencie]["must_after"]: serviceslist[dependencie]["must_after"].append(service) return serviceslist def order_services_by_dependencies(servicesdict: dict) -> list: ''' Génère une liste avec les noms de services pour ordonner leurs coupures par dépendances. Les containers étant requis seront placés avant ceux les requérant. :param servicesdict: dict :return: list ''' serviceslist = [] for service in servicesdict: if len(servicesdict[service]["must_after"]) and len(serviceslist): depindex = len(serviceslist) -1 for dependencieof in servicesdict[service]["must_after"]: if dependencieof in serviceslist and serviceslist.index(dependencieof) < depindex: depindex = serviceslist.index(dependencieof) serviceslist.insert(depindex, service) else: serviceslist.append(service) return serviceslist def backup_services(servicename: str, servicesdict: dict, composefilepath: str) -> bool: ''' Pause les containers, lance la backup des dépendances si il y en a, lance sa propre backup, puis redémarre les containers. :param servicename: str :param servicesdict: dict :param composefilepath: str :return: bool ''' if servicesdict[servicename]["backupdone"]: print(f"backup is already done for {servicename}, ignoring") return True dockerclient = docker.from_env() containersid = os.popen(f"docker-compose --file {composefilepath} ps --quiet {servicename}").read().splitlines() containerrunning = [] for containerid in containersid: container = dockerclient.containers.get(containerid) containerrunning.append(container.status == "running") print(f"stopping {container.name}") container.stop() if len(servicesdict[servicename]["must_before"]): for dependencie in servicesdict[servicename]["must_before"]: backup_service(dependencie, servicesdict, composefilepath) # TODO: faire la sauvegarde print(f"doing backup of {servicename}") for containerindex in range(0, len(containersid)): if containerrunning[containerindex]: container = dockerclient.containers.get(containersid[containerindex]) print(f"restarting {container.name}") container.start() servicesdict[servicename]["backupdone"] = True return True def volume_backup(itemtobackup: str) -> bool: ''' Exécute la sauvegarde du dossier/fichier bind ou volume du container. :param itemtobackup: str :return: bool ''' # il faut lancer l'équivalent de ceci : # docker run --rm -v $(itemtobackup):/$(itemtobackup) -v $passwordfile:/$passwordfile -v $sshfolder:/root/.ssh \ # --host $host restic/restic restic --password-file /$passwordfile -r $remotehost backup /$(itemtobackup) volumes = {sshfolder: {'bind': '/root/.ssh', 'mode': 'ro'}, passwordfile: {'bind': '/.resticpass', 'mode': 'ro'}} ctpath = itemtobackup if itemtobackup.find("/") == -1 : ctpath = f"/{itemtobackup}" dockerclient = docker.from_env() backuplog = dockerclient.containers.run("restic/restic", command="Lol") # Press the green button in the gutter to run the script. if __name__ == '__main__': dockerhost = docker.from_env() bindcludepattern = None bindexcludepattern = None configfile = None composefile = "/root/compose/core/docker-compose.yml" sshfolder = "/root/.ssh" remotehost = None repository = None passwordfile = None arguments_parser() # servicesDict = yaml_to_services_list(composefile) # servicesList = order_services_by_dependencies(servicesDict) # print("Backup process is starting") # for servicetobackup in reversed(servicesList): # print(f" - {servicetobackup}") # backup_services(servicetobackup, servicesDict, composefile)