[fix] Hook backup method

This commit is contained in:
ljf 2022-10-28 09:36:54 +02:00
parent ffc0c8d22e
commit 1b25e123ae
No known key found for this signature in database
8 changed files with 250 additions and 255 deletions

View file

@ -88,9 +88,9 @@
"backup_archive_system_part_not_available": "System part '{part}' unavailable in this backup", "backup_archive_system_part_not_available": "System part '{part}' unavailable in this backup",
"backup_archive_writing_error": "Could not add the files '{source}' (named in the archive '{dest}') to be backed up into the compressed archive '{archive}'", "backup_archive_writing_error": "Could not add the files '{source}' (named in the archive '{dest}') to be backed up into the compressed archive '{archive}'",
"backup_ask_for_copying_if_needed": "Do you want to perform the backup using {size}MB temporarily? (This way is used since some files could not be prepared using a more efficient method.)", "backup_ask_for_copying_if_needed": "Do you want to perform the backup using {size}MB temporarily? (This way is used since some files could not be prepared using a more efficient method.)",
"backup_borg_init_error": "Unable initialize the borg repository: {error}", "backup_init_error": "Unable initialize the borg repository: {error}",
"backup_borg_list_archive_error": "Unable to list files in the archive", "backup_list_archives_names_error": "Unable to list files in the archive",
"backup_borg_mount_archive_error": "Unable to mount the archive here: {error}", "backup_mount_error": "Unable to mount the archive here: {error}",
"backup_cant_mount_uncompress_archive": "Could not mount the uncompressed archive as write protected", "backup_cant_mount_uncompress_archive": "Could not mount the uncompressed archive as write protected",
"backup_cleaning_failed": "Could not clean up the temporary backup folder", "backup_cleaning_failed": "Could not clean up the temporary backup folder",
"backup_copying_to_organize_the_archive": "Copying {size}MB to organize the archive", "backup_copying_to_organize_the_archive": "Copying {size}MB to organize the archive",

View file

@ -1104,6 +1104,10 @@ backup:
full: --repositories full: --repositories
help: List archives in these repositories help: List archives in these repositories
nargs: "*" nargs: "*"
-p:
full: --prefix
help: List only archives starting with this prefix
default: ""
-i: -i:
full: --with-info full: --with-info
help: Show backup information for each archive help: Show backup information for each archive
@ -1144,6 +1148,27 @@ backup:
full: --repository full: --repository
help: Repository in which we can found the archive help: Repository in which we can found the archive
### backup_extract()
extract:
action_help: Extract data from a backup archive
api: POST /backups/<name>/extract
arguments:
name:
help: Name of the backup archive
paths:
help: Path to extract
nargs: "*"
-r:
full: --repository
help: Repository in which we can found the archive
-t:
full: --target
help: Destination path for extracted data
-e:
full: --exclude
help: Paths to exclude
nargs: "*"
### backup_mount() ### backup_mount()
mount: mount:
action_help: Mount a backup archive if possible action_help: Mount a backup archive if possible
@ -1286,7 +1311,6 @@ backup:
pattern: *pattern_backup_repository_shortname pattern: *pattern_backup_repository_shortname
--prefix: --prefix:
help: Prefix on which we prune help: Prefix on which we prune
nargs: "?"
-H: -H:
full: --keep-hourly full: --keep-hourly
help: Number of hourly archives to keep help: Number of hourly archives to keep

View file

@ -80,7 +80,6 @@ name.en = ""
choices.borg = "BorgBackup (recommended)" choices.borg = "BorgBackup (recommended)"
choices.tar = "Legacy tar archive mechanism" choices.tar = "Legacy tar archive mechanism"
default = "borg" default = "borg"
visible = "!is_remote"
[main.main.path] [main.main.path]
type = "path" type = "path"

View file

@ -56,7 +56,7 @@ from yunohost.tools import (
) )
from yunohost.regenconf import regen_conf from yunohost.regenconf import regen_conf
from yunohost.log import OperationLogger, is_unit_operation from yunohost.log import OperationLogger, is_unit_operation
from yunohost.repository import BackupRepository, BackupArchive from yunohost.repository import BackupRepository, BackupArchive, BACKUP_PATH
from yunohost.utils.config import ConfigPanel from yunohost.utils.config import ConfigPanel
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.system import ( from yunohost.utils.system import (
@ -67,8 +67,6 @@ from yunohost.utils.system import (
) )
from yunohost.settings import settings_get from yunohost.settings import settings_get
BACKUP_PATH = "/home/yunohost.backup"
ARCHIVES_PATH = f"{BACKUP_PATH}/archives"
APP_MARGIN_SPACE_SIZE = 100 # In MB APP_MARGIN_SPACE_SIZE = 100 # In MB
CONF_MARGIN_SPACE_SIZE = 10 # IN MB CONF_MARGIN_SPACE_SIZE = 10 # IN MB
POSTINSTALL_ESTIMATE_SPACE_SIZE = 5 # In MB POSTINSTALL_ESTIMATE_SPACE_SIZE = 5 # In MB
@ -1860,7 +1858,7 @@ def backup_restore(name, repository=None, system=[], apps=[], force=False):
return restore_manager.targets.results return restore_manager.targets.results
def backup_list(name=None, repositories=[], with_info=False, human_readable=False): def backup_list(name=None, repositories=[], prefix="", with_info=False, human_readable=False):
""" """
List available local backup archives List available local backup archives
@ -1879,7 +1877,7 @@ def backup_list(name=None, repositories=[], with_info=False, human_readable=Fals
return archive.list(with_info) return archive.list(with_info)
return { return {
name: BackupRepository(name).list_archives(with_info) name: BackupRepository(name).list_archives(with_info=with_info, prefix=prefix)
for name in repositories or BackupRepository.list(full=False) for name in repositories or BackupRepository.list(full=False)
} }
@ -1895,6 +1893,20 @@ def backup_download(name, repository=None):
return archive.download() return archive.download()
def backup_extract(name, paths, repository=None, target=None, exclude=[]):
if not repository:
repository = settings_get("misc.backup.backup_default_repositories")[0]
if not target:
target = os.getcwd()
repo = BackupRepository(repository)
archive = BackupArchive(repo, name)
archive.extract(path=paths, target=target, exclude_paths=exclude)
def backup_mount(name, path, repository=None): def backup_mount(name, path, repository=None):
if not repository: if not repository:
@ -2237,18 +2249,6 @@ def backup_timer_pause(operation_logger, shortname):
# #
def _create_archive_dir():
"""Create the YunoHost archives directory if doesn't exist"""
if not os.path.isdir(ARCHIVES_PATH):
if os.path.lexists(ARCHIVES_PATH):
raise YunohostError("backup_output_symlink_dir_broken", path=ARCHIVES_PATH)
# Create the archive folder, with 'admins' as groupowner, such that
# people can scp archives out of the server
mkdir(ARCHIVES_PATH, mode=0o770, parents=True, gid="admins")
def _call_for_each_path(self, callback, csv_path=None): def _call_for_each_path(self, callback, csv_path=None):
"""Call a callback for each path in csv""" """Call a callback for each path in csv"""

View file

@ -1,23 +1,21 @@
# -*- coding: utf-8 -*- #
# Copyright (c) 2022 YunoHost Contributors
""" License #
# This file is part of YunoHost (see https://yunohost.org)
Copyright (C) 2013 Yunohost #
# This program is free software: you can redistribute it and/or modify
This program is free software; you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as
it under the terms of the GNU Affero General Public License as published # published by the Free Software Foundation, either version 3 of the
by the Free Software Foundation, either version 3 of the License, or # License, or (at your option) any later version.
(at your option) any later version. #
# This program is distributed in the hope that it will be useful,
This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of
but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details.
GNU Affero General Public License for more details. #
# You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>.
along with this program; if not, see http://www.gnu.org/licenses #
"""
import os import os
import subprocess import subprocess
import json import json
@ -126,7 +124,6 @@ class BorgBackupRepository(LocalBackupRepository):
if "quota" in self.future_values and self.future_values["quota"]: if "quota" in self.future_values and self.future_values["quota"]:
cmd += ['--storage-quota', self.quota] cmd += ['--storage-quota', self.quota]
logger.debug(cmd)
try: try:
self._call('init', cmd) self._call('init', cmd)
except YunohostError as e: except YunohostError as e:
@ -182,11 +179,11 @@ class BorgBackupArchive(BackupArchive):
def backup(self): def backup(self):
cmd = ['borg', 'create', self.archive_path, './'] cmd = ['borg', 'create', self.archive_path, './']
self.repo._call('backup', cmd, cwd=self.work_dir) self.repository._call('backup', cmd, cwd=self.work_dir)
def delete(self): def delete(self):
cmd = ['borg', 'delete', '--force', self.archive_path] cmd = ['borg', 'delete', '--force', self.archive_path]
self.repo._call('delete_archive', cmd) self.repository._call('delete_archive', cmd)
def list(self, with_info=False): def list(self, with_info=False):
""" Return a list of archives names """ Return a list of archives names
@ -196,7 +193,7 @@ class BorgBackupArchive(BackupArchive):
""" """
cmd = ["borg", "list", "--json-lines" if with_info else "--short", cmd = ["borg", "list", "--json-lines" if with_info else "--short",
self.archive_path] self.archive_path]
out = self.repo._call('list_archive', cmd) out = self.repository._call('list_archive', cmd)
if not with_info: if not with_info:
return out.decode() return out.decode()
@ -218,7 +215,7 @@ class BorgBackupArchive(BackupArchive):
cmd = ["borg", "export-tar", self.archive_path, "-"] + paths cmd = ["borg", "export-tar", self.archive_path, "-"] + paths
for path in exclude_paths: for path in exclude_paths:
cmd += ['--exclude', path] cmd += ['--exclude', path]
reader = self.repo._run_borg_command(cmd, stdout=subprocess.PIPE) reader = self.repository._run_borg_command(cmd, stdout=subprocess.PIPE)
# We return a raw bottle HTTPresponse (instead of serializable data like # We return a raw bottle HTTPresponse (instead of serializable data like
# list/dict, ...), which is gonna be picked and used directly by moulinette # list/dict, ...), which is gonna be picked and used directly by moulinette
@ -226,15 +223,15 @@ class BorgBackupArchive(BackupArchive):
response.content_type = "application/x-tar" response.content_type = "application/x-tar"
return HTTPResponse(reader, 200) return HTTPResponse(reader, 200)
def extract(self, paths=[], destination=None, exclude_paths=[]): def extract(self, paths=[], target=None, exclude_paths=[]):
# TODO exclude_paths not available in actions map # TODO exclude_paths not available in actions map
paths, destination, exclude_paths = super().extract(paths, destination, exclude_paths) paths, target, exclude_paths = super().extract(paths, target, exclude_paths)
cmd = ['borg', 'extract', self.archive_path] + paths cmd = ['borg', 'extract', self.archive_path] + paths
for path in exclude_paths: for path in exclude_paths:
cmd += ['--exclude', path] cmd += ['--exclude', path]
return self.repo._call('extract_archive', cmd, cwd=destination) return self.repository._call('extract_archive', cmd, cwd=target)
def mount(self, path): def mount(self, path):
# FIXME How to be sure the place where we mount is secure ? # FIXME How to be sure the place where we mount is secure ?
cmd = ['borg', 'mount', self.archive_path, path] cmd = ['borg', 'mount', self.archive_path, path]
self.repo._call('mount_archive', cmd) self.repository._call('mount_archive', cmd)

View file

@ -1,158 +1,121 @@
# -*- coding: utf-8 -*- #
# Copyright (c) 2022 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import json
""" License
Copyright (C) 2013 Yunohost
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program; if not, see http://www.gnu.org/licenses
"""
from moulinette import m18n from moulinette import m18n
from moulinette.utils.log import getActionLogger from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import rm from moulinette.utils.filesystem import rm
from yunohost.hook import hook_callback from yunohost.hook import hook_callback, hook_exec
from yunohost.utils.error import YunohostError from yunohost.utils.error import YunohostError
from yunohost.repository import BackupRepository, BackupArchive from yunohost.repository import LocalBackupRepository, BackupArchive, HOOK_METHOD_DIR
logger = getActionLogger("yunohost.repository") logger = getActionLogger("yunohost.repository")
class HookBackupRepository(BackupRepository): def hook_backup_call(self, action, chdir=None, **kwargs):
""" Call a submethod of backup method hook
Exceptions:
backup_custom_ACTION_error -- Raised if the custom script failed
"""
if isinstance(self, LocalBackupRepository):
repository = self
args = [action, "", "", repository.location, "", repository.description]
else:
repository = self.repository
args = [action, self.work_dir, self.name, repository.location, "", ""]
args += kwargs.values()
env = {"YNH_BACKUP_" + key.upper(): str(value)
for key, value in list(kwargs.items()) + list(repository.values.items())}
return_code, return_data = hook_exec(
f"{HOOK_METHOD_DIR}/{repository.method}",
args=args,
raise_on_error=False,
chdir=chdir,
env=env,
)
if return_code == 38:
raise NotImplementedError()
elif return_code != 0:
raise YunohostError("backup_" + action + "_error")
return return_code, return_data
class HookBackupRepository(LocalBackupRepository):
method_name = "hook" method_name = "hook"
# ================================================= # =================================================
# Repository actions # Repository actions
# ================================================= # =================================================
def install(self): def install(self):
raise NotImplementedError() _, return_data = hook_backup_call(self, "install")
if return_data.get("super"):
super().install()
def update(self): def update(self):
raise NotImplementedError() hook_backup_call(self, "update")
def remove(self, purge=False): def purge(self):
if self.__class__ == BackupRepository: _, return_data = hook_backup_call(self, "purge")
raise NotImplementedError() # purge if return_data.get("super"):
super().purge()
rm(self.save_path, force=True) def list_archives_names(self, prefix=""):
logger.success(m18n.n("repository_removed", repository=self.shortname)) _, return_data = hook_backup_call(self, "list_archives_names",
prefix=prefix)
def list(self): return return_data
raise NotImplementedError()
def info(self, space_used=False):
result = super().get(mode="export")
if self.__class__ == BackupRepository and space_used is True:
raise NotImplementedError() # purge
return {self.shortname: result}
def prune(self):
raise NotImplementedError()
class HookBackupArchive(BackupArchive): class HookBackupArchive(BackupArchive):
# ================================================= # =================================================
# Archive actions # Archive actions
# ================================================= # =================================================
def need_organized_files(self):
return_code, _ = hook_backup_call(self, "need_mount")
return int(return_code) == 0
def backup(self): def backup(self):
raise NotImplementedError() hook_backup_call(self, "backup")
""" #self._call('backup', self.work_dir, self.name, self.repo.location, self.manager.size, self.manager.description)
Launch a custom script to backup
"""
self._call('backup', self.work_dir, self.name, self.repo.location, self.manager.size,
self.manager.description)
def restore(self):
raise NotImplementedError()
def delete(self): def delete(self):
raise NotImplementedError() hook_backup_call(self, "delete")
def list(self): def list(self, with_info=False):
raise NotImplementedError() return_code, return_data = hook_backup_call(self, "list_files",
""" Return a list of archives names with_info=with_info)
Exceptions: return return_data
backup_custom_list_error -- Raised if the custom script failed
"""
out = self._call('list', self.repo.location)
result = out.strip().splitlines()
return result
def info(self): def download(self, exclude_paths=[]):
raise NotImplementedError() # compute_space_used hook_backup_call(self, "download")
""" Return json string of the info.json file
Exceptions: def extract(self, paths=[], target=None, exclude_paths=[]):
backup_custom_info_error -- Raised if the custom script failed paths, destination, exclude_paths = super().extract(paths, target, exclude_paths)
""" hook_backup_call(self, "extract",
return self._call('info', self.name, self.repo.location) chdir=target,
paths=",".join(paths),
exclude=",".join(exclude_paths))
def download(self): def mount(self, path):
raise NotImplementedError() hook_backup_call(self, "mount", path=path)
def mount(self):
raise NotImplementedError()
"""
Launch a custom script to mount the custom archive
"""
super().mount()
self._call('mount', self.work_dir, self.name, self.repo.location, self.manager.size,
self.manager.description)
def extract(self):
raise NotImplementedError()
def need_organized_files(self):
"""Call the backup_method hook to know if we need to organize files"""
if self._need_mount is not None:
return self._need_mount
try:
self._call('nedd_mount')
except YunohostError:
return False
return True
def _call(self, *args):
""" Call a submethod of backup method hook
Exceptions:
backup_custom_ACTION_error -- Raised if the custom script failed
"""
ret = hook_callback("backup_method", [self.method],
args=args)
ret_failed = [
hook
for hook, infos in ret.items()
if any(result["state"] == "failed" for result in infos.values())
]
if ret_failed:
raise YunohostError("backup_custom_" + args[0] + "_error")
return ret["succeed"][self.method]["stdreturn"]
def _get_args(self, action):
"""Return the arguments to give to the custom script"""
return [
action,
self.work_dir,
self.name,
self.repo,
self.manager.size,
self.manager.description,
]

View file

@ -1,23 +1,21 @@
# -*- coding: utf-8 -*- #
# Copyright (c) 2022 YunoHost Contributors
""" License #
# This file is part of YunoHost (see https://yunohost.org)
Copyright (C) 2013 Yunohost #
# This program is free software: you can redistribute it and/or modify
This program is free software; you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as
it under the terms of the GNU Affero General Public License as published # published by the Free Software Foundation, either version 3 of the
by the Free Software Foundation, either version 3 of the License, or # License, or (at your option) any later version.
(at your option) any later version. #
# This program is distributed in the hope that it will be useful,
This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of
but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details.
GNU Affero General Public License for more details. #
# You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>.
along with this program; if not, see http://www.gnu.org/licenses #
"""
import os import os
import tarfile import tarfile
import shutil import shutil
@ -81,9 +79,9 @@ class TarBackupArchive(BackupArchive):
if isinstance(self.manager, BackupManager) and settings_get( if isinstance(self.manager, BackupManager) and settings_get(
"backup.compress_tar_archives" "backup.compress_tar_archives"
): ):
return os.path.join(self.repo.location, self.name + ".tar.gz") return os.path.join(self.repository.location, self.name + ".tar.gz")
f = os.path.join(self.repo.path, self.name + ".tar") f = os.path.join(self.repository.path, self.name + ".tar")
if os.path.exists(f + ".gz"): if os.path.exists(f + ".gz"):
f += ".gz" f += ".gz"
return f return f
@ -124,12 +122,12 @@ class TarBackupArchive(BackupArchive):
# Move info file # Move info file
shutil.copy( shutil.copy(
os.path.join(self.work_dir, "info.json"), os.path.join(self.work_dir, "info.json"),
os.path.join(self.repo.location, self.name + ".info.json"), os.path.join(self.repository.location, self.name + ".info.json"),
) )
# If backuped to a non-default location, keep a symlink of the archive # If backuped to a non-default location, keep a symlink of the archive
# to that location # to that location
link = os.path.join(self.repo.path, self.name + ".tar") link = os.path.join(self.repository.path, self.name + ".tar")
if not os.path.isfile(link): if not os.path.isfile(link):
os.symlink(self.archive_path, link) os.symlink(self.archive_path, link)
@ -144,8 +142,8 @@ class TarBackupArchive(BackupArchive):
tar.close() tar.close()
def delete(self): def delete(self):
archive_file = f"{self.repo.location}/{self.name}.tar" archive_file = f"{self.repository.location}/{self.name}.tar"
info_file = f"{self.repo.location}/{self.name}.info.json" info_file = f"{self.repository.location}/{self.name}.info.json"
if os.path.exists(archive_file + ".gz"): if os.path.exists(archive_file + ".gz"):
archive_file += ".gz" archive_file += ".gz"
@ -219,8 +217,8 @@ class TarBackupArchive(BackupArchive):
archive_folder, archive_file_name = archive_file.rsplit("/", 1) archive_folder, archive_file_name = archive_file.rsplit("/", 1)
return static_file(archive_file_name, archive_folder, download=archive_file_name) return static_file(archive_file_name, archive_folder, download=archive_file_name)
def extract(self, paths=[], destination=None, exclude_paths=[]): def extract(self, paths=[], target=None, exclude_paths=[]):
paths, destination, exclude_paths = super().extract(paths, destination, exclude_paths) paths, target, exclude_paths = super().extract(paths, target, exclude_paths)
# Open the tarball # Open the tarball
try: try:
tar = tarfile.open( tar = tarfile.open(
@ -241,7 +239,7 @@ class TarBackupArchive(BackupArchive):
and all([not tarinfo.name.startswith(path) for path in exclude_paths]) and all([not tarinfo.name.startswith(path) for path in exclude_paths])
) )
] ]
tar.extractall(members=subdir_and_files, path=destination) tar.extractall(members=subdir_and_files, path=target)
tar.close() tar.close()
def mount(self, path): def mount(self, path):

View file

@ -1,28 +1,21 @@
# -*- coding: utf-8 -*- #
# Copyright (c) 2022 YunoHost Contributors
""" License #
# This file is part of YunoHost (see https://yunohost.org)
Copyright (C) 2013 Yunohost #
# This program is free software: you can redistribute it and/or modify
This program is free software; you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as
it under the terms of the GNU Affero General Public License as published # published by the Free Software Foundation, either version 3 of the
by the Free Software Foundation, either version 3 of the License, or # License, or (at your option) any later version.
(at your option) any later version. #
# This program is distributed in the hope that it will be useful,
This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of
but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details.
GNU Affero General Public License for more details. #
# You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>.
along with this program; if not, see http://www.gnu.org/licenses #
"""
""" yunohost_repository.py
Manage backup repositories
"""
import json import json
import os import os
import re import re
@ -47,7 +40,10 @@ from yunohost.utils.system import disk_usage, binary_to_human
from yunohost.utils.network import get_ssh_public_key, SHF_BASE_URL from yunohost.utils.network import get_ssh_public_key, SHF_BASE_URL
logger = getActionLogger('yunohost.repository') logger = getActionLogger('yunohost.repository')
BACKUP_PATH = "/home/yunohost.backup"
ARCHIVES_PATH = f"{BACKUP_PATH}/archives"
REPOSITORIES_DIR = '/etc/yunohost/backup/repositories' REPOSITORIES_DIR = '/etc/yunohost/backup/repositories'
HOOK_METHOD_DIR = '/etc/yunohost/hooks.d/backup_method'
CACHE_INFO_DIR = "/var/cache/yunohost/repositories/{repository}" CACHE_INFO_DIR = "/var/cache/yunohost/repositories/{repository}"
REPOSITORY_CONFIG_PATH = "/usr/share/yunohost/other/config_repository.toml" REPOSITORY_CONFIG_PATH = "/usr/share/yunohost/other/config_repository.toml"
MB_ALLOWED_TO_ORGANIZE = 10 MB_ALLOWED_TO_ORGANIZE = 10
@ -159,21 +155,35 @@ class BackupRepository(ConfigPanel):
logger.debug("SHF running") logger.debug("SHF running")
return {'is_shf': True} return {'is_shf': True}
def post_ask__is_remote(self, question):
if question.value:
self.method = 'borg'
self._cast_by_backup_method()
return {}
def post_ask__method(self, question): def post_ask__method(self, question):
if question.value: if question.value:
self.method = question.value self.new_values["method"] = question.value
self._cast_by_backup_method() self._cast_by_backup_method()
return {} return {}
# ================================================= # =================================================
# Config Panel Override # Config Panel Override
# ================================================= # =================================================
def _get_toml(self):
toml = super()._get_toml()
# Add custom methods to the list of choices
methods = toml["main"]["main"]["method"]["choices"]
try:
hook_methods = os.listdir(HOOK_METHOD_DIR)
except FileNotFoundError:
hook_methods = []
for hook_method in hook_methods:
methods[hook_method] = hook_method.replace('-', ' ').replace('_', ' ').capitalize()
# Change default path
toml["main"]["main"]["path"]["default"] = f"{ARCHIVES_PATH}/{self.entity}"
return toml
def _get_default_values(self): def _get_default_values(self):
values = super()._get_default_values() values = super()._get_default_values()
# TODO move that in a getter hooks ? # TODO move that in a getter hooks ?
@ -268,8 +278,8 @@ class BackupRepository(ConfigPanel):
return {self.entity: result} return {self.entity: result}
def list_archives(self, with_info=False): def list_archives(self, with_info=False, prefix=""):
archives = self.list_archives_names() archives = self.list_archives_names(prefix=prefix)
if with_info: if with_info:
d = {} d = {}
for archive in archives: for archive in archives:
@ -373,7 +383,7 @@ class BackupRepository(ConfigPanel):
def purge(self): def purge(self):
raise NotImplementedError() raise NotImplementedError()
def list_archives_names(self, prefix=None): def list_archives_names(self, prefix=""):
raise NotImplementedError() raise NotImplementedError()
def compute_space_used(self): def compute_space_used(self):
@ -386,7 +396,11 @@ class BackupRepository(ConfigPanel):
class LocalBackupRepository(BackupRepository): class LocalBackupRepository(BackupRepository):
def install(self): def install(self):
self.new_values['location'] = self.location self.new_values['location'] = self.location
mkdir(self.location, mode=0o0750, parents=True, uid="admin", gid="root", force=True) if not os.path.isdir(self.location):
if os.path.lexists(self.location):
raise YunohostError("backup_output_symlink_dir_broken", path=self.location)
mkdir(self.location, mode=0o0750, parents=True, gid="admins", force=True)
def update(self): def update(self):
self.install() self.install()
@ -404,24 +418,24 @@ class BackupArchive:
self.name = self.name[: -len(".tar.gz")] self.name = self.name[: -len(".tar.gz")]
elif self.name.endswith(".tar"): elif self.name.endswith(".tar"):
self.name = self.name[: -len(".tar")] self.name = self.name[: -len(".tar")]
self.repo = repo self.repository = repo
# Cast # Cast
if self.repo.method_name == 'tar': if self.repository.method_name == 'tar':
self.__class__ = yunohost.repositories.tar.TarBackupArchive self.__class__ = yunohost.repositories.tar.TarBackupArchive
elif self.repo.method_name == 'borg': elif self.repository.method_name == 'borg':
self.__class__ = yunohost.repositories.borg.BorgBackupArchive self.__class__ = yunohost.repositories.borg.BorgBackupArchive
else: else:
self.__class__ = yunohost.repositories.hook.HookBackupArchive self.__class__ = yunohost.repositories.hook.HookBackupArchive
# Assert archive exists # Assert archive exists
if self.manager.__class__.__name__ != "BackupManager" and self.name not in self.repo.list_archives(False): if self.manager.__class__.__name__ != "BackupManager" and self.name not in self.repository.list_archives(False):
raise YunohostValidationError("backup_archive_name_unknown", name=name) raise YunohostValidationError("backup_archive_name_unknown", name=name)
@property @property
def archive_path(self): def archive_path(self):
"""Return the archive path""" """Return the archive path"""
return self.repo.location + '::' + self.name return self.repository.location + '::' + self.name
@property @property
def work_dir(self): def work_dir(self):
@ -434,11 +448,11 @@ class BackupArchive:
For a RestoreManager, it is the directory where we mount the archive For a RestoreManager, it is the directory where we mount the archive
before restoring before restoring
""" """
return self.manager.work_dir return self.manager.work_dir if self.manager else None
# This is not a property cause it could be managed in a hook # This is not a property cause it could be managed in a hook
def need_organized_files(self): def need_organized_files(self):
return self.repo.need_organized_files return self.repository.need_organized_files
def organize_and_backup(self): def organize_and_backup(self):
""" """
@ -450,10 +464,10 @@ class BackupArchive:
if self.need_organized_files(): if self.need_organized_files():
self._organize_files() self._organize_files()
self.repo.install() self.repository.install()
# Check free space in output # Check free space in output
self.repo.check_is_enough_free_space(self.manager.size) self.repository.check_is_enough_free_space(self.manager.size)
try: try:
self.backup() self.backup()
finally: finally:
@ -506,7 +520,7 @@ class BackupArchive:
def _get_info_string(self): def _get_info_string(self):
"""Extract info file from archive if needed and read it""" """Extract info file from archive if needed and read it"""
cache_info_dir = CACHE_INFO_DIR.format(repository=self.repo.entity) cache_info_dir = CACHE_INFO_DIR.format(repository=self.repository.entity)
mkdir(cache_info_dir, mode=0o0700, parents=True, force=True) mkdir(cache_info_dir, mode=0o0700, parents=True, force=True)
info_file = f"{cache_info_dir}/{self.name}.info.json" info_file = f"{cache_info_dir}/{self.name}.info.json"
@ -515,9 +529,9 @@ class BackupArchive:
try: try:
files_in_archive = self.list() files_in_archive = self.list()
if "info.json" in files_in_archive: if "info.json" in files_in_archive:
self.extract("info.json", destination=tmp_dir) self.extract("info.json", target=tmp_dir)
elif "./info.json" in files_in_archive: elif "./info.json" in files_in_archive:
self.extract("./info.json", destination=tmp_dir) self.extract("./info.json", target=tmp_dir)
else: else:
raise YunohostError( raise YunohostError(
"backup_archive_cant_retrieve_info_json", archive=self.archive_path "backup_archive_cant_retrieve_info_json", archive=self.archive_path
@ -741,7 +755,7 @@ class BackupArchive:
) )
return return
def extract(self, paths=None, destination=None, exclude_paths=[]): def extract(self, paths=None, target=None, exclude_paths=[]):
if self.__class__ == BackupArchive: if self.__class__ == BackupArchive:
raise NotImplementedError() raise NotImplementedError()
if isinstance(paths, str): if isinstance(paths, str):
@ -750,7 +764,7 @@ class BackupArchive:
paths = self.select_files() paths = self.select_files()
if isinstance(exclude_paths, str): if isinstance(exclude_paths, str):
exclude_paths = [exclude_paths] exclude_paths = [exclude_paths]
return paths, destination, exclude_paths return paths, target, exclude_paths
def mount(self): def mount(self):
if self.__class__ == BackupArchive: if self.__class__ == BackupArchive: