yunohost/src/backup.py
2022-01-20 18:43:33 +00:00

2697 lines
94 KiB
Python

# -*- coding: utf-8 -*-
""" 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
"""
""" yunohost_backup.py
Manage backups
"""
import os
import re
import json
import time
import tarfile
import shutil
import subprocess
import csv
import tempfile
from datetime import datetime
from glob import glob
from collections import OrderedDict
from functools import reduce
from packaging import version
from moulinette import Moulinette, m18n
from moulinette.utils import filesystem
from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml
from moulinette.utils.process import check_output
import yunohost.domain
from yunohost.app import (
app_info,
_is_installed,
_make_environment_for_app_script,
_make_tmp_workdir_for_app,
)
from yunohost.hook import (
hook_list,
hook_info,
hook_callback,
hook_exec,
hook_exec_with_script_debug_if_failure,
CUSTOM_HOOK_FOLDER,
)
from yunohost.tools import (
tools_postinstall,
_tools_migrations_run_after_system_restore,
_tools_migrations_run_before_app_restore,
)
from yunohost.regenconf import regen_conf
from yunohost.log import OperationLogger, is_unit_operation
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.packages import ynh_packages_version
from yunohost.utils.filesystem import free_space_in_directory
from yunohost.settings import settings_get
BACKUP_PATH = "/home/yunohost.backup"
ARCHIVES_PATH = f"{BACKUP_PATH}/archives"
APP_MARGIN_SPACE_SIZE = 100 # In MB
CONF_MARGIN_SPACE_SIZE = 10 # IN MB
POSTINSTALL_ESTIMATE_SPACE_SIZE = 5 # In MB
MB_ALLOWED_TO_ORGANIZE = 10
logger = getActionLogger("yunohost.backup")
class BackupRestoreTargetsManager:
"""
BackupRestoreTargetsManager manage the targets
in BackupManager and RestoreManager
"""
def __init__(self):
self.targets = {}
self.results = {"system": {}, "apps": {}}
def set_result(self, category, element, value):
"""
Change (or initialize) the current status/result of a given target.
Args:
category -- The category of the target
element -- The target for which to change the status/result
value -- The new status/result, among "Unknown", "Success",
"Warning", "Error" and "Skipped"
"""
levels = ["Unknown", "Success", "Warning", "Error", "Skipped"]
assert value in levels
if element not in self.results[category].keys():
self.results[category][element] = value
else:
currentValue = self.results[category][element]
if levels.index(currentValue) > levels.index(value):
return
else:
self.results[category][element] = value
def set_wanted(
self,
category,
wanted_targets,
available_targets,
error_if_wanted_target_is_unavailable,
):
"""
Define and validate targets to be backuped or to be restored (list of
system parts, apps..). The wanted targets are compared and filtered
with respect to the available targets. If a wanted targets is not
available, a call to "error_if_wanted_target_is_unavailable" is made.
Args:
category -- The category (apps or system) for which to set the
targets ;
wanted_targets -- List of targets which are wanted by the user. Can be
"None" or [], corresponding to "No targets" or "All
targets" ;
available_targets -- List of targets which are really available ;
error_if_wanted_target_is_unavailable
-- Callback for targets which are not available.
"""
# If no targets wanted, set as empty list
if wanted_targets is None:
self.targets[category] = []
# If all targets wanted, use all available targets
elif wanted_targets == []:
self.targets[category] = available_targets
# If the user manually specified which targets to backup, we need to
# validate that each target is actually available
else:
self.targets[category] = [
part for part in wanted_targets if part in available_targets
]
# Display an error for each target asked by the user but which is
# unknown
unavailable_targets = [
part for part in wanted_targets if part not in available_targets
]
for target in unavailable_targets:
self.set_result(category, target, "Skipped")
error_if_wanted_target_is_unavailable(target)
# For target with no result yet (like 'Skipped'), set it as unknown
if self.targets[category] is not None:
for target in self.targets[category]:
self.set_result(category, target, "Unknown")
return self.list(category, exclude=["Skipped"])
def list(self, category, include=None, exclude=None):
"""
List targets in a given category.
The list is filtered with a whitelist (include) or blacklist (exclude)
with respect to the current 'result' of the target.
"""
assert (include and isinstance(include, list) and not exclude) or (
exclude and isinstance(exclude, list) and not include
)
if include:
return [
target
for target in self.targets[category]
if self.results[category][target] in include
]
if exclude:
return [
target
for target in self.targets[category]
if self.results[category][target] not in exclude
]
class BackupManager:
"""
This class collect files to backup in a list and apply one or several
backup method on it.
The list contains dict with source and dest properties. The goal of this csv
is to list all directories and files which need to be backup in this
archive. The `source` property is the path of the source (dir or file).
The `dest` property is the path where it could be placed in the archive.
The list is filled by app backup scripts and system/user backup hooks.
Files located in the work_dir are automatically added.
With this list, "backup methods" are able to apply their backup strategy on
data listed in it. It's possible to tar each path (tar methods), to mount
each dir into the work_dir, to copy each files (copy method) or to call a
custom method (via a custom script).
Note: some future backups methods (like borg) are not able to specify a
different place than the original path. That's why the ynh_restore_file
helpers use primarily the SOURCE_PATH as argument.
Public properties:
info (getter)
work_dir (getter) # FIXME currently it's not a getter
is_tmp_work_dir (getter)
paths_to_backup (getter) # FIXME not a getter and list is not protected
name (getter) # FIXME currently it's not a getter
size (getter) # FIXME currently it's not a getter
Public methods:
add(self, method)
set_system_targets(self, system_parts=[])
set_apps_targets(self, apps=[])
collect_files(self)
backup(self)
Usage:
backup_manager = BackupManager(name="mybackup", description="bkp things")
# Add backup method to apply
backup_manager.add('copy', output_directory='/mnt/local_fs')
backup_manager.add('tar', output_directory='/mnt/remote_fs')
# Define targets to be backuped
backup_manager.set_system_targets(["data"])
backup_manager.set_apps_targets(["wordpress"])
# Collect files to backup from targets
backup_manager.collect_files()
# Apply backup methods
backup_manager.backup()
"""
def __init__(self, name=None, description="", methods=[], work_dir=None):
"""
BackupManager constructor
Args:
name -- (string) The name of this backup (without spaces). If
None, the name will be generated (default: None)
description -- (string) A description for this future backup archive
(default: '')
work_dir -- (None|string) A path where prepare the archive. If None,
temporary work_dir will be created (default: None)
"""
self.description = description or ""
self.created_at = int(time.time())
self.apps_return = {}
self.system_return = {}
self.paths_to_backup = []
self.size_details = {"system": {}, "apps": {}}
self.targets = BackupRestoreTargetsManager()
# Define backup name if needed
if not name:
name = self._define_backup_name()
self.name = name
# Define working directory if needed and initialize it
self.work_dir = work_dir
if self.work_dir is None:
self.work_dir = os.path.join(BACKUP_PATH, "tmp", name)
self._init_work_dir()
# Initialize backup methods
self.methods = [
BackupMethod.create(method, self, repo=work_dir) for method in methods
]
#
# Misc helpers #
#
@property
def info(self):
"""(Getter) Dict containing info about the archive being created"""
return {
"description": self.description,
"created_at": self.created_at,
"size": self.size,
"size_details": self.size_details,
"apps": self.apps_return,
"system": self.system_return,
"from_yunohost_version": ynh_packages_version()["yunohost"]["version"],
}
@property
def is_tmp_work_dir(self):
"""(Getter) Return true if the working directory is temporary and should
be clean at the end of the backup"""
return self.work_dir == os.path.join(BACKUP_PATH, "tmp", self.name)
def __repr__(self):
return json.dumps(self.info)
def _define_backup_name(self):
"""Define backup name
Return:
(string) A backup name created from current date 'YYMMDD-HHMMSS'
"""
# FIXME: case where this name already exist
return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
def _init_work_dir(self):
"""Initialize preparation directory
Ensure the working directory exists and is empty
"""
# FIXME replace isdir by exists ? manage better the case where the path
# exists
if not os.path.isdir(self.work_dir):
filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin")
elif self.is_tmp_work_dir:
logger.debug(
"temporary directory for backup '%s' already exists... attempting to clean it",
self.work_dir,
)
# Try to recursively unmount stuff (from a previously failed backup ?)
if not _recursive_umount(self.work_dir):
raise YunohostValidationError("backup_output_directory_not_empty")
else:
# If umount succeeded, remove the directory (we checked that
# we're in /home/yunohost.backup/tmp so that should be okay...
# c.f. method clean() which also does this)
filesystem.rm(self.work_dir, recursive=True, force=True)
filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin")
#
# Backup target management #
#
def set_system_targets(self, system_parts=[]):
"""
Define and validate targetted apps to be backuped
Args:
system_parts -- (list) list of system parts which should be backuped.
If empty list, all system will be backuped. If None,
no system parts will be backuped.
"""
def unknown_error(part):
logger.error(m18n.n("backup_hook_unknown", hook=part))
self.targets.set_wanted(
"system", system_parts, hook_list("backup")["hooks"], unknown_error
)
def set_apps_targets(self, apps=[]):
"""
Define and validate targetted apps to be backuped
Args:
apps -- (list) list of apps which should be backuped. If given an empty
list, all apps will be backuped. If given None, no apps will be
backuped.
"""
def unknown_error(app):
logger.error(m18n.n("unbackup_app", app=app))
target_list = self.targets.set_wanted(
"apps", apps, os.listdir("/etc/yunohost/apps"), unknown_error
)
# Additionnaly, we need to check that each targetted app has a
# backup and restore scripts
for app in target_list:
app_script_folder = f"/etc/yunohost/apps/{app}/scripts"
backup_script_path = os.path.join(app_script_folder, "backup")
restore_script_path = os.path.join(app_script_folder, "restore")
if not os.path.isfile(backup_script_path):
logger.warning(m18n.n("backup_with_no_backup_script_for_app", app=app))
self.targets.set_result("apps", app, "Skipped")
elif not os.path.isfile(restore_script_path):
logger.warning(m18n.n("backup_with_no_restore_script_for_app", app=app))
self.targets.set_result("apps", app, "Warning")
#
# Management of files to backup / "The CSV" #
#
def _import_to_list_to_backup(self, tmp_csv):
"""
Commit collected path from system hooks or app scripts
Args:
tmp_csv -- (string) Path to a temporary csv file with source and
destinations column to add to the list of paths to backup
"""
_call_for_each_path(self, BackupManager._add_to_list_to_backup, tmp_csv)
def _add_to_list_to_backup(self, source, dest=None):
"""
Mark file or directory to backup
This method add source/dest couple to the "paths_to_backup" list.
Args:
source -- (string) Source path to backup
dest -- (string) Destination path in the archive. If it ends by a
slash the basename of the source path will be added. If None,
the source path will be used, so source files will be set up
at the same place and with same name than on the system.
(default: None)
Usage:
self._add_to_list_to_backup('/var/www/wordpress', 'sources')
# => "wordpress" dir will be move and rename as "sources"
self._add_to_list_to_backup('/var/www/wordpress', 'sources/')
# => "wordpress" dir will be put inside "sources/" and won't be renamed
"""
if dest is None:
dest = source
source = os.path.join(self.work_dir, source)
if dest.endswith("/"):
dest = os.path.join(dest, os.path.basename(source))
self.paths_to_backup.append({"source": source, "dest": dest})
def _write_csv(self):
"""
Write the backup list into a CSV
The goal of this csv is to list all directories and files which need to
be backup in this archive. For the moment, this CSV contains 2 columns.
The first column `source` is the path of the source (dir or file). The
second `dest` is the path where it could be placed in the archive.
This CSV is filled by app backup scripts and system/user hooks.
Files in the work_dir are automatically added.
With this CSV, "backup methods" are able to apply their backup strategy
on data listed in it. It's possible to tar each path (tar methods), to
mount each dir into the work_dir, to copy each files (copy methods) or
a custom method (via a custom script).
Note: some future backups methods (like borg) are not able to specify a
different place than the original path. That's why the ynh_restore_file
helpers use primarily the SOURCE_PATH as argument.
Error:
backup_csv_creation_failed -- Raised if the CSV couldn't be created
backup_csv_addition_failed -- Raised if we can't write in the CSV
"""
self.csv_path = os.path.join(self.work_dir, "backup.csv")
try:
self.csv_file = open(self.csv_path, "a")
self.fieldnames = ["source", "dest"]
self.csv = csv.DictWriter(
self.csv_file, fieldnames=self.fieldnames, quoting=csv.QUOTE_ALL
)
except (IOError, OSError, csv.Error):
logger.error(m18n.n("backup_csv_creation_failed"))
for row in self.paths_to_backup:
try:
self.csv.writerow(row)
except csv.Error:
logger.error(m18n.n("backup_csv_addition_failed"))
self.csv_file.close()
#
# File collection from system parts and apps #
#
def collect_files(self):
"""
Collect all files to backup, write its into a CSV and create a
info.json file
Files to backup are listed by system parts backup hooks and by backup
app scripts that have been defined with the set_targets() method.
Some files or directories inside the working directory are added by
default:
info.json -- info about the archive
backup.csv -- a list of paths to backup
apps/ -- some apps generate here temporary files to backup (like
database dump)
conf/ -- system configuration backup scripts could generate here
temporary files to backup
data/ -- system data backup scripts could generate here temporary
files to backup
hooks/ -- restore scripts associated to system backup scripts are
copied here
"""
self._collect_system_files()
self._collect_apps_files()
# Check if something has been saved ('success' or 'warning')
successfull_apps = self.targets.list("apps", include=["Success", "Warning"])
successfull_system = self.targets.list("system", include=["Success", "Warning"])
if not successfull_apps and not successfull_system:
filesystem.rm(self.work_dir, True, True)
raise YunohostError("backup_nothings_done")
# Add unlisted files from backup tmp dir
self._add_to_list_to_backup("backup.csv")
self._add_to_list_to_backup("info.json")
for app in self.apps_return.keys():
self._add_to_list_to_backup(f"apps/{app}")
if os.path.isdir(os.path.join(self.work_dir, "conf")):
self._add_to_list_to_backup("conf")
if os.path.isdir(os.path.join(self.work_dir, "data")):
self._add_to_list_to_backup("data")
# Write CSV file
self._write_csv()
# Calculate total size
self._compute_backup_size()
# Create backup info file
with open(f"{self.work_dir}/info.json", "w") as f:
f.write(json.dumps(self.info))
def _get_env_var(self, app=None):
"""
Define environment variables for apps or system backup scripts.
Args:
app -- (string|None) The instance name of the app we want the variable
environment. If you want a variable environment for a system backup
script keep None. (default: None)
Return:
(Dictionnary) The environment variables to apply to the script
"""
env_var = {}
_, tmp_csv = tempfile.mkstemp(prefix="backupcsv_")
env_var["YNH_BACKUP_DIR"] = self.work_dir
env_var["YNH_BACKUP_CSV"] = tmp_csv
if app is not None:
env_var.update(_make_environment_for_app_script(app))
env_var["YNH_APP_BACKUP_DIR"] = os.path.join(
self.work_dir, "apps", app, "backup"
)
return env_var
def _collect_system_files(self):
"""
List file to backup for each selected system part
This corresponds to scripts in data/hooks/backup/ (system hooks) and
to those in /etc/yunohost/hooks.d/backup/ (user hooks)
Environment variables:
YNH_BACKUP_DIR -- The backup working directory (in
"/home/yunohost.backup/tmp/BACKUPNAME" or could be
defined by the user)
YNH_BACKUP_CSV -- A temporary CSV where the script whould list paths toi
backup
"""
system_targets = self.targets.list("system", exclude=["Skipped"])
# If nothing to backup, return immediately
if system_targets == []:
return
logger.debug(m18n.n("backup_running_hooks"))
# Prepare environnement
env_dict = self._get_env_var()
# Actual call to backup scripts/hooks
ret = hook_callback(
"backup",
system_targets,
args=[self.work_dir],
env=env_dict,
chdir=self.work_dir,
)
ret_succeed = {
hook: [
path for path, result in infos.items() if result["state"] == "succeed"
]
for hook, infos in ret.items()
if any(result["state"] == "succeed" for result in infos.values())
}
ret_failed = {
hook: [
path for path, result in infos.items() if result["state"] == "failed"
]
for hook, infos in ret.items()
if any(result["state"] == "failed" for result in infos.values())
}
if list(ret_succeed.keys()) != []:
self.system_return = ret_succeed
# Add files from targets (which they put in the CSV) to the list of
# files to backup
self._import_to_list_to_backup(env_dict["YNH_BACKUP_CSV"])
# Save restoration hooks for each part that suceeded (and which have
# a restore hook available)
restore_hooks_dir = os.path.join(self.work_dir, "hooks", "restore")
if not os.path.exists(restore_hooks_dir):
filesystem.mkdir(restore_hooks_dir, mode=0o700, parents=True, uid="root")
restore_hooks = hook_list("restore")["hooks"]
for part in ret_succeed.keys():
if part in restore_hooks:
part_restore_hooks = hook_info("restore", part)["hooks"]
for hook in part_restore_hooks:
self._add_to_list_to_backup(hook["path"], "hooks/restore/")
self.targets.set_result("system", part, "Success")
else:
logger.warning(m18n.n("restore_hook_unavailable", hook=part))
self.targets.set_result("system", part, "Warning")
for part in ret_failed.keys():
logger.error(m18n.n("backup_system_part_failed", part=part))
self.targets.set_result("system", part, "Error")
def _collect_apps_files(self):
"""Prepare backup for each selected apps"""
apps_targets = self.targets.list("apps", exclude=["Skipped"])
for app_instance_name in apps_targets:
self._collect_app_files(app_instance_name)
def _collect_app_files(self, app):
"""
List files to backup for the app into the paths_to_backup dict.
If the app backup script fails, paths from this app already listed for
backup aren't added to the general list and will be ignored
Environment variables:
YNH_BACKUP_DIR -- The backup working directory (in
"/home/yunohost.backup/tmp/BACKUPNAME" or could be
defined by the user)
YNH_BACKUP_CSV -- A temporary CSV where the script whould list paths toi
backup
YNH_APP_BACKUP_DIR -- The directory where the script should put
temporary files to backup like database dump,
files in this directory don't need to be added to
the temporary CSV.
YNH_APP_ID -- The app id (eg wordpress)
YNH_APP_INSTANCE_NAME -- The app instance name (eg wordpress__3)
YNH_APP_INSTANCE_NUMBER -- The app instance number (eg 3)
Args:
app -- (string) an app instance name (already installed) to backup
"""
from yunohost.permission import user_permission_list
app_setting_path = os.path.join("/etc/yunohost/apps/", app)
# Prepare environment
env_dict = self._get_env_var(app)
env_dict["YNH_APP_BASEDIR"] = os.path.join(
self.work_dir, "apps", app, "settings"
)
tmp_app_bkp_dir = env_dict["YNH_APP_BACKUP_DIR"]
settings_dir = os.path.join(self.work_dir, "apps", app, "settings")
logger.info(m18n.n("app_start_backup", app=app))
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
try:
# Prepare backup directory for the app
filesystem.mkdir(tmp_app_bkp_dir, 0o700, True, uid="root")
# Copy the app settings to be able to call _common.sh
shutil.copytree(app_setting_path, settings_dir)
hook_exec(
f"{tmp_workdir_for_app}/scripts/backup",
raise_on_error=True,
chdir=tmp_app_bkp_dir,
env=env_dict,
)[0]
self._import_to_list_to_backup(env_dict["YNH_BACKUP_CSV"])
# backup permissions
logger.debug(m18n.n("backup_permission", app=app))
permissions = user_permission_list(full=True, apps=[app])["permissions"]
this_app_permissions = {name: infos for name, infos in permissions.items()}
write_to_yaml(f"{settings_dir}/permissions.yml", this_app_permissions)
except Exception as e:
logger.debug(e)
abs_tmp_app_dir = os.path.join(self.work_dir, "apps/", app)
shutil.rmtree(abs_tmp_app_dir, ignore_errors=True)
logger.error(m18n.n("backup_app_failed", app=app))
self.targets.set_result("apps", app, "Error")
else:
# Add app info
i = app_info(app)
self.apps_return[app] = {
"version": i["version"],
"name": i["name"],
"description": i["description"],
}
self.targets.set_result("apps", app, "Success")
# Remove tmp files in all situations
finally:
shutil.rmtree(tmp_workdir_for_app)
filesystem.rm(env_dict["YNH_BACKUP_CSV"], force=True)
#
# Actual backup archive creation / method management #
#
def backup(self):
"""Apply backup methods"""
for method in self.methods:
logger.debug(m18n.n("backup_applying_method_" + method.method_name))
method.mount_and_backup()
logger.debug(m18n.n("backup_method_" + method.method_name + "_finished"))
def _compute_backup_size(self):
"""
Compute backup global size and details size for each apps and system
parts
Update self.size and self.size_details
Note: currently, these sizes are the size in this archive, not really
the size of needed to restore the archive. To know the size needed to
restore we should consider apt/npm/pip dependencies space and database
dump restore operations.
Return:
(int) The global size of the archive in bytes
"""
# FIXME Database dump will be loaded, so dump should use almost the
# double of their space
# FIXME Some archive will set up dependencies, those are not in this
# size info
self.size = 0
for system_key in self.system_return:
self.size_details["system"][system_key] = 0
for app_key in self.apps_return:
self.size_details["apps"][app_key] = 0
for row in self.paths_to_backup:
if row["dest"] == "info.json":
continue
size = disk_usage(row["source"])
# Add size to apps details
splitted_dest = row["dest"].split("/")
category = splitted_dest[0]
if category == "apps":
for app_key in self.apps_return:
if row["dest"].startswith("apps/" + app_key):
self.size_details["apps"][app_key] += size
break
# OR Add size to the correct system element
elif category == "data" or category == "conf":
for system_key in self.system_return:
if row["dest"].startswith(system_key.replace("_", "/")):
self.size_details["system"][system_key] += size
break
self.size += size
return self.size
class RestoreManager:
"""
RestoreManager allow to restore a past backup archive
Currently it's a tar file, but it could be another kind of archive
Public properties:
info (getter)i # FIXME
work_dir (getter) # FIXME currently it's not a getter
name (getter) # FIXME currently it's not a getter
success (getter)
result (getter) # FIXME
Public methods:
set_targets(self, system_parts=[], apps=[])
restore(self)
Usage:
restore_manager = RestoreManager(name)
restore_manager.set_targets(None, ['wordpress__3'])
restore_manager.restore()
if restore_manager.success:
logger.success(m18n.n('restore_complete'))
return restore_manager.result
"""
def __init__(self, name, method="tar"):
"""
RestoreManager constructor
Args:
name -- (string) Archive name
method -- (string) Method name to use to mount the archive
"""
# Retrieve and open the archive
# FIXME this way to get the info is not compatible with copy or custom
# backup methods
self.info = backup_info(name, with_details=True)
from_version = self.info.get("from_yunohost_version", "")
# Remove any '~foobar' in the version ... c.f ~alpha, ~beta version during
# early dev for next debian version
from_version = re.sub(r"~\w+", "", from_version)
if not from_version or version.parse(from_version) < version.parse("4.2.0"):
raise YunohostValidationError("restore_backup_too_old")
self.archive_path = self.info["path"]
self.name = name
self.method = BackupMethod.create(method, self)
self.targets = BackupRestoreTargetsManager()
#
# Misc helpers #
#
@property
def success(self):
successful_apps = self.targets.list("apps", include=["Success", "Warning"])
successful_system = self.targets.list("system", include=["Success", "Warning"])
return len(successful_apps) != 0 or len(successful_system) != 0
def _read_info_files(self):
"""
Read the info file from inside an archive
"""
# Retrieve backup info
info_file = os.path.join(self.work_dir, "info.json")
try:
with open(info_file, "r") as f:
self.info = json.load(f)
# Historically, "system" was "hooks"
if "system" not in self.info.keys():
self.info["system"] = self.info["hooks"]
except IOError:
logger.debug("unable to load '%s'", info_file, exc_info=1)
raise YunohostError(
"backup_archive_cant_retrieve_info_json", archive=self.archive_path
)
else:
logger.debug(
"restoring from backup '%s' created on %s",
self.name,
datetime.utcfromtimestamp(self.info["created_at"]),
)
def _postinstall_if_needed(self):
"""
Post install yunohost if needed
"""
# Check if YunoHost is installed
if not os.path.isfile("/etc/yunohost/installed"):
# Retrieve the domain from the backup
try:
with open(f"{self.work_dir}/conf/ynh/current_host", "r") as f:
domain = f.readline().rstrip()
except IOError:
logger.debug(
"unable to retrieve current_host from the backup", exc_info=1
)
# FIXME include the current_host by default ?
raise YunohostError(
"The main domain name cannot be retrieved from inside the archive, and is needed to perform the postinstall",
raw_msg=True,
)
logger.debug("executing the post-install...")
tools_postinstall(domain, "Yunohost", True)
def clean(self):
"""
End a restore operations by cleaning the working directory and
regenerate ssowat conf (if some apps were restored)
"""
from .permission import permission_sync_to_user
permission_sync_to_user()
if os.path.ismount(self.work_dir):
ret = subprocess.call(["umount", self.work_dir])
if ret != 0:
logger.warning(m18n.n("restore_cleaning_failed"))
filesystem.rm(self.work_dir, recursive=True, force=True)
#
# Restore target manangement #
#
def set_system_targets(self, system_parts=[]):
"""
Define system parts that will be restored
Args:
system_parts -- (list) list of system parts which should be restored.
If an empty list if given, restore all system part in
the archive. If None is given, no system will be restored.
"""
def unknown_error(part):
logger.error(m18n.n("backup_archive_system_part_not_available", part=part))
target_list = self.targets.set_wanted(
"system", system_parts, self.info["system"].keys(), unknown_error
)
# Now we need to check that the restore hook is actually available for
# all targets we want to restore
# These are the hooks on the current installation
available_restore_system_hooks = hook_list("restore")["hooks"]
custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore")
filesystem.mkdir(custom_restore_hook_folder, 755, parents=True, force=True)
for system_part in target_list:
# By default, we'll use the restore hooks on the current install
# if available
# FIXME: so if the restore hook exist we use the new one and not
# the one from backup. So hook should not break compatibility..
if system_part in available_restore_system_hooks:
continue
# Otherwise, attempt to find it (or them?) in the archive
# If we didn't find it, we ain't gonna be able to restore it
if (
system_part not in self.info["system"]
or "paths" not in self.info["system"][system_part]
or len(self.info["system"][system_part]["paths"]) == 0
):
logger.error(m18n.n("restore_hook_unavailable", part=system_part))
self.targets.set_result("system", system_part, "Skipped")
continue
hook_paths = self.info["system"][system_part]["paths"]
hook_paths = [f"hooks/restore/{os.path.basename(p)}" for p in hook_paths]
# Otherwise, add it from the archive to the system
# FIXME: Refactor hook_add and use it instead
for hook_path in hook_paths:
logger.debug(
"Adding restoration script '%s' to the system "
"from the backup archive '%s'",
hook_path,
self.archive_path,
)
self.method.copy(hook_path, custom_restore_hook_folder)
def set_apps_targets(self, apps=[]):
"""
Define and validate targetted apps to be restored
Args:
apps -- (list) list of apps which should be restored. If [] is given,
all apps in the archive will be restored. If None is given,
no apps will be restored.
"""
def unknown_error(app):
logger.error(m18n.n("backup_archive_app_not_found", app=app))
to_be_restored = self.targets.set_wanted(
"apps", apps, self.info["apps"].keys(), unknown_error
)
# If all apps to restore are already installed, stop right here.
# Otherwise, if at least one app can be restored, we keep going on
# because those which can be restored will indeed be restored
already_installed = [app for app in to_be_restored if _is_installed(app)]
if already_installed != []:
if already_installed == to_be_restored:
raise YunohostValidationError(
"restore_already_installed_apps", apps=", ".join(already_installed)
)
else:
logger.warning(
m18n.n(
"restore_already_installed_apps",
apps=", ".join(already_installed),
)
)
#
# Archive mounting #
#
def mount(self):
"""
Mount the archive. We avoid copy to be able to restore on system without
too many space.
Use the mount method from the BackupMethod instance and read info about
this archive
"""
self.work_dir = os.path.join(BACKUP_PATH, "tmp", self.name)
if os.path.ismount(self.work_dir):
logger.debug("An already mounting point '%s' already exists", self.work_dir)
ret = subprocess.call(["umount", self.work_dir])
if ret == 0:
subprocess.call(["rmdir", self.work_dir])
logger.debug(f"Unmount dir: {self.work_dir}")
else:
raise YunohostError("restore_removing_tmp_dir_failed")
elif os.path.isdir(self.work_dir):
logger.debug(
"temporary restore directory '%s' already exists", self.work_dir
)
ret = subprocess.call(["rm", "-Rf", self.work_dir])
if ret == 0:
logger.debug(f"Delete dir: {self.work_dir}")
else:
raise YunohostError("restore_removing_tmp_dir_failed")
filesystem.mkdir(self.work_dir, parents=True)
self.method.mount()
self._read_info_files()
#
# Space computation / checks #
#
def _compute_needed_space(self):
"""
Compute needed space to be able to restore
Return:
size -- (int) needed space to backup in bytes
margin -- (int) margin to be sure the backup don't fail by missing space
in bytes
"""
system = self.targets.list("system", exclude=["Skipped"])
apps = self.targets.list("apps", exclude=["Skipped"])
restore_all_system = system == self.info["system"].keys()
restore_all_apps = apps == self.info["apps"].keys()
# If complete restore operations (or legacy archive)
margin = CONF_MARGIN_SPACE_SIZE * 1024 * 1024
if (restore_all_system and restore_all_apps) or "size_details" not in self.info:
size = self.info["size"]
if (
"size_details" not in self.info
or self.info["size_details"]["apps"] != {}
):
margin = APP_MARGIN_SPACE_SIZE * 1024 * 1024
# Partial restore don't need all backup size
else:
size = 0
if system is not None:
for system_element in system:
size += self.info["size_details"]["system"][system_element]
# TODO how to know the dependencies size ?
if apps is not None:
for app in apps:
size += self.info["size_details"]["apps"][app]
margin = APP_MARGIN_SPACE_SIZE * 1024 * 1024
if not os.path.isfile("/etc/yunohost/installed"):
size += POSTINSTALL_ESTIMATE_SPACE_SIZE * 1024 * 1024
return (size, margin)
def assert_enough_free_space(self):
"""
Check available disk space
"""
free_space = free_space_in_directory(BACKUP_PATH)
(needed_space, margin) = self._compute_needed_space()
if free_space >= needed_space + margin:
return True
elif free_space > needed_space:
# TODO Add --force options to avoid the error raising
raise YunohostValidationError(
"restore_may_be_not_enough_disk_space",
free_space=free_space,
needed_space=needed_space,
margin=margin,
)
else:
raise YunohostValidationError(
"restore_not_enough_disk_space",
free_space=free_space,
needed_space=needed_space,
margin=margin,
)
#
# "Actual restore" (reverse step of the backup collect part) #
#
def restore(self):
"""
Restore the archive
Restore system parts and apps after mounting the archive, checking free
space and postinstall if needed
"""
try:
self._postinstall_if_needed()
# Apply dirty patch to redirect php5 file on php7
self._patch_legacy_php_versions_in_csv_file()
self._restore_system()
self._restore_apps()
except Exception as e:
raise YunohostError(
f"The following critical error happened during restoration: {e}"
)
finally:
self.clean()
def _patch_legacy_php_versions_in_csv_file(self):
"""
Apply dirty patch to redirect php5 and php7.0 files to php7.4
"""
from yunohost.utils.legacy import LEGACY_PHP_VERSION_REPLACEMENTS
backup_csv = os.path.join(self.work_dir, "backup.csv")
if not os.path.isfile(backup_csv):
return
replaced_something = False
with open(backup_csv) as csvfile:
reader = csv.DictReader(csvfile, fieldnames=["source", "dest"])
newlines = []
for row in reader:
for pattern, replace in LEGACY_PHP_VERSION_REPLACEMENTS:
if pattern in row["source"]:
replaced_something = True
row["source"] = row["source"].replace(pattern, replace)
newlines.append(row)
if not replaced_something:
return
with open(backup_csv, "w") as csvfile:
writer = csv.DictWriter(
csvfile, fieldnames=["source", "dest"], quoting=csv.QUOTE_ALL
)
for row in newlines:
writer.writerow(row)
def _restore_system(self):
"""Restore user and system parts"""
system_targets = self.targets.list("system", exclude=["Skipped"])
# If nothing to restore, return immediately
if system_targets == []:
return
from yunohost.permission import (
permission_create,
permission_delete,
user_permission_list,
permission_sync_to_user,
)
# Backup old permission for apps
# We need to do that because in case of an app is installed we can't remove the permission for this app
old_apps_permission = user_permission_list(ignore_system_perms=True, full=True)[
"permissions"
]
# Start register change on system
operation_logger = OperationLogger("backup_restore_system")
operation_logger.start()
logger.debug(m18n.n("restore_running_hooks"))
env_dict = {
"YNH_BACKUP_DIR": self.work_dir,
"YNH_BACKUP_CSV": os.path.join(self.work_dir, "backup.csv"),
}
operation_logger.extra["env"] = env_dict
operation_logger.flush()
ret = hook_callback(
"restore",
system_targets,
args=[self.work_dir],
env=env_dict,
chdir=self.work_dir,
)
ret_succeed = [
hook
for hook, infos in ret.items()
if any(result["state"] == "succeed" for result in infos.values())
]
ret_failed = [
hook
for hook, infos in ret.items()
if any(result["state"] == "failed" for result in infos.values())
]
for part in ret_succeed:
self.targets.set_result("system", part, "Success")
error_part = []
for part in ret_failed:
logger.error(m18n.n("restore_system_part_failed", part=part))
self.targets.set_result("system", part, "Error")
error_part.append(part)
if ret_failed:
operation_logger.error(
m18n.n("restore_system_part_failed", part=", ".join(error_part))
)
else:
operation_logger.success()
yunohost.domain.domain_list_cache = {}
regen_conf()
_tools_migrations_run_after_system_restore(
backup_version=self.info["from_yunohost_version"]
)
# Remove all permission for all app still in the LDAP
for permission_name in user_permission_list(ignore_system_perms=True)[
"permissions"
].keys():
permission_delete(permission_name, force=True, sync_perm=False)
# Restore permission for apps installed
for permission_name, permission_infos in old_apps_permission.items():
app_name, perm_name = permission_name.split(".")
if _is_installed(app_name):
permission_create(
permission_name,
allowed=permission_infos["allowed"],
url=permission_infos["url"],
additional_urls=permission_infos["additional_urls"],
auth_header=permission_infos["auth_header"],
label=permission_infos["label"]
if perm_name == "main"
else permission_infos["sublabel"],
show_tile=permission_infos["show_tile"],
protected=permission_infos["protected"],
sync_perm=False,
)
permission_sync_to_user()
def _restore_apps(self):
"""Restore all apps targeted"""
apps_targets = self.targets.list("apps", exclude=["Skipped"])
for app in apps_targets:
self._restore_app(app)
def _restore_app(self, app_instance_name):
"""
Restore an app
Environment variables:
YNH_BACKUP_DIR -- The backup working directory (in
"/home/yunohost.backup/tmp/BACKUPNAME" or could be
defined by the user)
YNH_BACKUP_CSV -- A temporary CSV where the script whould list paths to
backup
YNH_APP_BACKUP_DIR -- The directory where the script should put
temporary files to backup like database dump,
files in this directory don't need to be added to
the temporary CSV.
YNH_APP_ID -- The app id (eg wordpress)
YNH_APP_INSTANCE_NAME -- The app instance name (eg wordpress__3)
YNH_APP_INSTANCE_NUMBER -- The app instance number (eg 3)
Args:
app_instance_name -- (string) The app name to restore (no app with this
name should be already install)
"""
from yunohost.utils.legacy import (
_patch_legacy_php_versions,
_patch_legacy_php_versions_in_settings,
_patch_legacy_helpers,
)
from yunohost.user import user_group_list
from yunohost.permission import (
permission_create,
permission_delete,
user_permission_list,
permission_sync_to_user,
)
def copytree(src, dst, symlinks=False, ignore=None):
for item in os.listdir(src):
s = os.path.join(src, item)
d = os.path.join(dst, item)
if os.path.isdir(s):
shutil.copytree(s, d, symlinks, ignore)
else:
shutil.copy2(s, d)
# Check if the app is not already installed
if _is_installed(app_instance_name):
logger.error(m18n.n("restore_already_installed_app", app=app_instance_name))
self.targets.set_result("apps", app_instance_name, "Error")
return
# Start register change on system
related_to = [("app", app_instance_name)]
operation_logger = OperationLogger("backup_restore_app", related_to)
operation_logger.start()
logger.info(m18n.n("app_start_restore", app=app_instance_name))
app_dir_in_archive = os.path.join(self.work_dir, "apps", app_instance_name)
app_backup_in_archive = os.path.join(app_dir_in_archive, "backup")
app_settings_in_archive = os.path.join(app_dir_in_archive, "settings")
app_scripts_in_archive = os.path.join(app_settings_in_archive, "scripts")
# Attempt to patch legacy helpers...
_patch_legacy_helpers(app_settings_in_archive)
# Apply dirty patch to make php5 apps compatible with php7
_patch_legacy_php_versions(app_settings_in_archive)
_patch_legacy_php_versions_in_settings(app_settings_in_archive)
# Delete _common.sh file in backup
common_file = os.path.join(app_backup_in_archive, "_common.sh")
filesystem.rm(common_file, force=True)
# Check if the app has a restore script
app_restore_script_in_archive = os.path.join(app_scripts_in_archive, "restore")
if not os.path.isfile(app_restore_script_in_archive):
logger.warning(m18n.n("unrestore_app", app=app_instance_name))
self.targets.set_result("apps", app_instance_name, "Warning")
return
try:
# Restore app settings
app_settings_new_path = os.path.join(
"/etc/yunohost/apps/", app_instance_name
)
app_scripts_new_path = os.path.join(app_settings_new_path, "scripts")
shutil.copytree(app_settings_in_archive, app_settings_new_path)
filesystem.chmod(app_settings_new_path, 0o400, 0o400, True)
filesystem.chown(app_scripts_new_path, "root", None, True)
# Copy the app scripts to a writable temporary folder
tmp_workdir_for_app = _make_tmp_workdir_for_app()
copytree(app_scripts_in_archive, tmp_workdir_for_app)
filesystem.chmod(tmp_workdir_for_app, 0o700, 0o700, True)
filesystem.chown(tmp_workdir_for_app, "root", None, True)
restore_script = os.path.join(tmp_workdir_for_app, "restore")
# Restore permissions
if not os.path.isfile(f"{app_settings_new_path}/permissions.yml"):
raise YunohostError(
"Didnt find a permssions.yml for the app !?", raw_msg=True
)
permissions = read_yaml(f"{app_settings_new_path}/permissions.yml")
existing_groups = user_group_list()["groups"]
for permission_name, permission_infos in permissions.items():
if "allowed" not in permission_infos:
logger.warning(
f"'allowed' key corresponding to allowed groups for permission {permission_name} not found when restoring app {app_instance_name} … You might have to reconfigure permissions yourself."
)
should_be_allowed = ["all_users"]
else:
should_be_allowed = [
g for g in permission_infos["allowed"] if g in existing_groups
]
perm_name = permission_name.split(".")[1]
permission_create(
permission_name,
allowed=should_be_allowed,
url=permission_infos.get("url"),
additional_urls=permission_infos.get("additional_urls"),
auth_header=permission_infos.get("auth_header"),
label=permission_infos.get("label")
if perm_name == "main"
else permission_infos.get("sublabel"),
show_tile=permission_infos.get("show_tile", True),
protected=permission_infos.get("protected", False),
sync_perm=False,
)
permission_sync_to_user()
os.remove(f"{app_settings_new_path}/permissions.yml")
_tools_migrations_run_before_app_restore(
backup_version=self.info["from_yunohost_version"],
app_id=app_instance_name,
)
except Exception:
import traceback
error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
msg = m18n.n("app_restore_failed", app=app_instance_name, error=error)
logger.error(msg)
operation_logger.error(msg)
self.targets.set_result("apps", app_instance_name, "Error")
# Cleanup
shutil.rmtree(app_settings_new_path, ignore_errors=True)
shutil.rmtree(tmp_workdir_for_app, ignore_errors=True)
return
logger.debug(m18n.n("restore_running_app_script", app=app_instance_name))
# Prepare env. var. to pass to script
# FIXME : workdir should be a tmp workdir
app_workdir = os.path.join(self.work_dir, "apps", app_instance_name, "settings")
env_dict = _make_environment_for_app_script(
app_instance_name, workdir=app_workdir
)
env_dict.update(
{
"YNH_BACKUP_DIR": self.work_dir,
"YNH_BACKUP_CSV": os.path.join(self.work_dir, "backup.csv"),
"YNH_APP_BACKUP_DIR": os.path.join(
self.work_dir, "apps", app_instance_name, "backup"
),
}
)
operation_logger.extra["env"] = env_dict
operation_logger.flush()
# Execute the app install script
restore_failed = True
try:
(
restore_failed,
failure_message_with_debug_instructions,
) = hook_exec_with_script_debug_if_failure(
restore_script,
chdir=app_backup_in_archive,
env=env_dict,
operation_logger=operation_logger,
error_message_if_script_failed=m18n.n("app_restore_script_failed"),
error_message_if_failed=lambda e: m18n.n(
"app_restore_failed", app=app_instance_name, error=e
),
)
finally:
# Cleaning temporary scripts directory
shutil.rmtree(tmp_workdir_for_app, ignore_errors=True)
if not restore_failed:
self.targets.set_result("apps", app_instance_name, "Success")
operation_logger.success()
else:
self.targets.set_result("apps", app_instance_name, "Error")
remove_script = os.path.join(app_scripts_in_archive, "remove")
# Setup environment for remove script
env_dict_remove = _make_environment_for_app_script(
app_instance_name, workdir=app_workdir
)
remove_operation_logger = OperationLogger(
"remove_on_failed_restore",
[("app", app_instance_name)],
env=env_dict_remove,
)
remove_operation_logger.start()
# Execute remove script
if hook_exec(remove_script, env=env_dict_remove)[0] != 0:
msg = m18n.n("app_not_properly_removed", app=app_instance_name)
logger.warning(msg)
remove_operation_logger.error(msg)
else:
remove_operation_logger.success()
# Cleaning app directory
shutil.rmtree(app_settings_new_path, ignore_errors=True)
# Remove all permission in LDAP for this app
for permission_name in user_permission_list()["permissions"].keys():
if permission_name.startswith(app_instance_name + "."):
permission_delete(permission_name, force=True)
# TODO Cleaning app hooks
logger.error(failure_message_with_debug_instructions)
#
# Backup methods #
#
class BackupMethod:
"""
BackupMethod is an abstract class that represents a way to backup and
restore a list of files.
Daughters of this class can be used by a BackupManager or RestoreManager
instance. Some methods are meant to be used by BackupManager and others
by RestoreManager.
BackupMethod has a factory method "create" to initialize instances.
Currently, there are 3 BackupMethods implemented:
CopyBackupMethod
----------------
This method corresponds to a raw (uncompressed) copy of files to a location,
and (could?) reverse the copy when restoring.
TarBackupMethod
---------------
This method compresses all files to backup in a .tar archive. When
restoring, it untars the required parts.
CustomBackupMethod
------------------
This one use a custom bash scrip/hook "backup_method" to do the
backup/restore operations. A user can add his own hook inside
/etc/yunohost/hooks.d/backup_method/
Public properties:
method_name
Public methods:
mount_and_backup(self)
mount(self)
create(cls, method, **kwargs)
Usage:
method = BackupMethod.create("tar", backup_manager)
method.mount_and_backup()
#or
method = BackupMethod.create("copy", restore_manager)
method.mount()
"""
@classmethod
def create(cls, method, manager, **kwargs):
"""
Factory method to create instance of BackupMethod
Args:
method -- (string) The method name of an existing BackupMethod. If the
name is unknown the CustomBackupMethod will be tried
*args -- Specific args for the method, could be the repo target by the
method
Return a BackupMethod instance
"""
known_methods = {c.method_name: c for c in BackupMethod.__subclasses__()}
backup_method = known_methods.get(method, CustomBackupMethod)
return backup_method(manager, method=method, **kwargs)
def __init__(self, manager, repo=None, **kwargs):
"""
BackupMethod constructors
Note it is an abstract class. You should use the "create" class method
to create instance.
Args:
repo -- (string|None) A string that represent the repo where put or
get the backup. It could be a path, and in future a
BackupRepository object. If None, the default repo is used :
/home/yunohost.backup/archives/
"""
self.manager = manager
self.repo = ARCHIVES_PATH if repo is None else repo
@property
def method_name(self):
"""Return the string name of a BackupMethod (eg "tar" or "copy")"""
raise YunohostError("backup_abstract_method")
@property
def name(self):
"""Return the backup name"""
return self.manager.name
@property
def work_dir(self):
"""
Return the working directory
For a BackupManager, it is the directory where we prepare the files to
backup
For a RestoreManager, it is the directory where we mount the archive
before restoring
"""
return self.manager.work_dir
def need_mount(self):
"""
Return True if this backup method need to organize path to backup by
binding its in the working directory before to backup its.
Indeed, some methods like tar or copy method don't need to organize
files before to add it inside the archive, but others like borgbackup
are not able to organize directly the files. In this case we have the
choice to organize in the working directory before to put in the archive
or to organize after mounting the archive before the restoring
operation.
The default behaviour is to return False. To change it override the
method.
Note it's not a property because some overrided methods could do long
treatment to get this info
"""
return False
def mount_and_backup(self):
"""
Run the backup on files listed by the BackupManager instance
This method shouldn't be overrided, prefer overriding self.backup() and
self.clean()
"""
if self.need_mount():
self._organize_files()
try:
self.backup()
finally:
self.clean()
def mount(self):
"""
Mount the archive from RestoreManager instance in the working directory
This method should be extended.
"""
pass
def clean(self):
"""
Umount sub directories of working dirextories and delete it if temporary
"""
if self.need_mount():
if not _recursive_umount(self.work_dir):
raise YunohostError("backup_cleaning_failed")
if self.manager.is_tmp_work_dir:
filesystem.rm(self.work_dir, True, True)
def _check_is_enough_free_space(self):
"""
Check free space in repository or output directory before to backup
"""
# TODO How to do with distant repo or with deduplicated backup ?
backup_size = self.manager.size
free_space = free_space_in_directory(self.repo)
if free_space < backup_size:
logger.debug(
"Not enough space at %s (free: %s / needed: %d)",
self.repo,
free_space,
backup_size,
)
raise YunohostValidationError("not_enough_disk_space", path=self.repo)
def _organize_files(self):
"""
Mount all csv src in their related path
The goal is to organize the files app by app and hook by hook, before
custom backup method or before the restore operation (in the case of an
unorganize archive).
The usage of binding could be strange for a user because the du -sb
command will return that the working directory is big.
"""
paths_needed_to_be_copied = []
for path in self.manager.paths_to_backup:
src = path["source"]
if self.manager is RestoreManager:
# TODO Support to run this before a restore (and not only before
# backup). To do that RestoreManager.unorganized_work_dir should
# be implemented
src = os.path.join(self.unorganized_work_dir, src)
dest = os.path.join(self.work_dir, path["dest"])
if dest == src:
continue
dest_dir = os.path.dirname(dest)
# Be sure the parent dir of destination exists
if not os.path.isdir(dest_dir):
filesystem.mkdir(dest_dir, parents=True)
# For directory, attempt to mount bind
if os.path.isdir(src):
filesystem.mkdir(dest, parents=True, force=True)
try:
subprocess.check_call(["mount", "--rbind", src, dest])
subprocess.check_call(["mount", "-o", "remount,ro,bind", dest])
except Exception:
logger.warning(m18n.n("backup_couldnt_bind", src=src, dest=dest))
# To check if dest is mounted, use /proc/mounts that
# escape spaces as \040
raw_mounts = read_file("/proc/mounts").strip().split("\n")
mounts = [m.split()[1] for m in raw_mounts]
mounts = [m.replace("\\040", " ") for m in mounts]
if dest in mounts:
subprocess.check_call(["umount", "-R", dest])
else:
# Success, go to next file to organize
continue
# For files, create a hardlink
elif os.path.isfile(src) or os.path.islink(src):
# Can create a hard link only if files are on the same fs
# (i.e. we can't if it's on a different fs)
if os.stat(src).st_dev == os.stat(dest_dir).st_dev:
# Don't hardlink /etc/cron.d files to avoid cron bug
# 'NUMBER OF HARD LINKS > 1' see #1043
cron_path = os.path.abspath("/etc/cron") + "."
if not os.path.abspath(src).startswith(cron_path):
try:
os.link(src, dest)
except Exception as e:
# This kind of situation may happen when src and dest are on different
# logical volume ... even though the st_dev check previously match...
# E.g. this happens when running an encrypted hard drive
# where everything is mapped to /dev/mapper/some-stuff
# yet there are different devices behind it or idk ...
logger.warning(
f"Could not link {src} to {dest} ({e}) ... falling back to regular copy."
)
else:
# Success, go to next file to organize
continue
# If mountbind or hardlink couldnt be created,
# prepare a list of files that need to be copied
paths_needed_to_be_copied.append(path)
if len(paths_needed_to_be_copied) == 0:
return
# Manage the case where we are not able to use mount bind abilities
# It could be just for some small files on different filesystems or due
# to mounting error
# Compute size to copy
size = sum(disk_usage(path["source"]) for path in paths_needed_to_be_copied)
size /= 1024 * 1024 # Convert bytes to megabytes
# Ask confirmation for copying
if size > MB_ALLOWED_TO_ORGANIZE:
try:
i = Moulinette.prompt(
m18n.n(
"backup_ask_for_copying_if_needed",
answers="y/N",
size=str(size),
)
)
except NotImplemented:
raise YunohostError("backup_unable_to_organize_files")
else:
if i != "y" and i != "Y":
raise YunohostError("backup_unable_to_organize_files")
# Copy unbinded path
logger.debug(m18n.n("backup_copying_to_organize_the_archive", size=str(size)))
for path in paths_needed_to_be_copied:
dest = os.path.join(self.work_dir, path["dest"])
if os.path.isdir(path["source"]):
shutil.copytree(path["source"], dest, symlinks=True)
else:
shutil.copy(path["source"], dest)
class CopyBackupMethod(BackupMethod):
"""
This class just do an uncompress copy of each file in a location, and
could be the inverse for restoring
"""
method_name = "copy"
def backup(self):
"""Copy prepared files into a the repo"""
# Check free space in output
self._check_is_enough_free_space()
for path in self.manager.paths_to_backup:
source = path["source"]
dest = os.path.join(self.repo, path["dest"])
if source == dest:
logger.debug("Files already copyed")
return
dest_parent = os.path.dirname(dest)
if not os.path.exists(dest_parent):
filesystem.mkdir(dest_parent, 0o700, True, uid="admin")
if os.path.isdir(source):
shutil.copytree(source, dest)
else:
shutil.copy(source, dest)
def mount(self):
"""
Mount the uncompress backup in readonly mode to the working directory
"""
# FIXME: This code is untested because there is no way to run it from
# the ynh cli
super(CopyBackupMethod, self).mount()
if not os.path.isdir(self.repo):
raise YunohostError("backup_no_uncompress_archive_dir")
filesystem.mkdir(self.work_dir, parent=True)
ret = subprocess.call(["mount", "-r", "--rbind", self.repo, self.work_dir])
if ret == 0:
return
logger.warning(
"Could not mount the backup in readonly mode with --rbind ... Unmounting"
)
# FIXME : Does this stuff really works ? '&&' is going to be interpreted as an argument for mounpoint here ... Not as a classical '&&' ...
subprocess.call(
["mountpoint", "-q", self.work_dir, "&&", "umount", "-R", self.work_dir]
)
raise YunohostError("backup_cant_mount_uncompress_archive")
def copy(self, file, target):
shutil.copy(file, target)
class TarBackupMethod(BackupMethod):
method_name = "tar"
@property
def _archive_file(self):
if isinstance(self.manager, BackupManager) and settings_get(
"backup.compress_tar_archives"
):
return os.path.join(self.repo, self.name + ".tar.gz")
f = os.path.join(self.repo, self.name + ".tar")
if os.path.exists(f + ".gz"):
f += ".gz"
return f
def backup(self):
"""
Compress prepared files
It adds the info.json in /home/yunohost.backup/archives and if the
compress archive isn't located here, add a symlink to the archive to.
"""
if not os.path.exists(self.repo):
filesystem.mkdir(self.repo, 0o750, parents=True, uid="admin")
# Check free space in output
self._check_is_enough_free_space()
# Open archive file for writing
try:
tar = tarfile.open(
self._archive_file,
"w:gz" if self._archive_file.endswith(".gz") else "w",
)
except Exception:
logger.debug(
"unable to open '%s' for writing", self._archive_file, exc_info=1
)
raise YunohostError("backup_archive_open_failed")
# Add files to the archive
try:
for path in self.manager.paths_to_backup:
# Add the "source" into the archive and transform the path into
# "dest"
tar.add(path["source"], arcname=path["dest"])
except IOError:
logger.error(
m18n.n(
"backup_archive_writing_error",
source=path["source"],
archive=self._archive_file,
dest=path["dest"],
),
exc_info=1,
)
raise YunohostError("backup_creation_failed")
finally:
tar.close()
# Move info file
shutil.copy(
os.path.join(self.work_dir, "info.json"),
os.path.join(ARCHIVES_PATH, self.name + ".info.json"),
)
# If backuped to a non-default location, keep a symlink of the archive
# to that location
link = os.path.join(ARCHIVES_PATH, self.name + ".tar")
if not os.path.isfile(link):
os.symlink(self._archive_file, link)
def mount(self):
"""
Mount the archive. We avoid intermediate copies to be able to restore on system with low free space.
"""
super(TarBackupMethod, self).mount()
# Mount the tarball
logger.debug(m18n.n("restore_extracting"))
try:
tar = tarfile.open(
self._archive_file,
"r:gz" if self._archive_file.endswith(".gz") else "r",
)
except Exception:
logger.debug(
"cannot open backup archive '%s'", self._archive_file, exc_info=1
)
raise YunohostError("backup_archive_open_failed")
try:
files_in_archive = tar.getnames()
except (IOError, EOFError, tarfile.ReadError) as e:
raise YunohostError(
"backup_archive_corrupted", archive=self._archive_file, error=str(e)
)
if "info.json" in tar.getnames():
leading_dot = ""
tar.extract("info.json", path=self.work_dir)
elif "./info.json" in files_in_archive:
leading_dot = "./"
tar.extract("./info.json", path=self.work_dir)
else:
logger.debug(
"unable to retrieve 'info.json' inside the archive", exc_info=1
)
tar.close()
raise YunohostError(
"backup_archive_cant_retrieve_info_json", archive=self._archive_file
)
if "backup.csv" in files_in_archive:
tar.extract("backup.csv", path=self.work_dir)
elif "./backup.csv" in files_in_archive:
tar.extract("./backup.csv", path=self.work_dir)
else:
# Old backup archive have no backup.csv file
pass
# Extract system parts backup
conf_extracted = False
system_targets = self.manager.targets.list("system", exclude=["Skipped"])
apps_targets = self.manager.targets.list("apps", exclude=["Skipped"])
for system_part in system_targets:
# Caution: conf_ynh_currenthost helpers put its files in
# conf/ynh
if system_part.startswith("conf_"):
if conf_extracted:
continue
system_part = "conf/"
conf_extracted = True
else:
system_part = system_part.replace("_", "/") + "/"
subdir_and_files = [
tarinfo
for tarinfo in tar.getmembers()
if tarinfo.name.startswith(leading_dot + system_part)
]
tar.extractall(members=subdir_and_files, path=self.work_dir)
subdir_and_files = [
tarinfo
for tarinfo in tar.getmembers()
if tarinfo.name.startswith(leading_dot + "hooks/restore/")
]
tar.extractall(members=subdir_and_files, path=self.work_dir)
# Extract apps backup
for app in apps_targets:
subdir_and_files = [
tarinfo
for tarinfo in tar.getmembers()
if tarinfo.name.startswith(leading_dot + "apps/" + app)
]
tar.extractall(members=subdir_and_files, path=self.work_dir)
tar.close()
def copy(self, file, target):
tar = tarfile.open(
self._archive_file, "r:gz" if self._archive_file.endswith(".gz") else "r"
)
file_to_extract = tar.getmember(file)
# Remove the path
file_to_extract.name = os.path.basename(file_to_extract.name)
tar.extract(file_to_extract, path=target)
tar.close()
class CustomBackupMethod(BackupMethod):
"""
This class use a bash script/hook "backup_method" to do the
backup/restore operations. A user can add his own hook inside
/etc/yunohost/hooks.d/backup_method/
"""
method_name = "custom"
def __init__(self, manager, repo=None, method=None, **kwargs):
super(CustomBackupMethod, self).__init__(manager, repo)
self.args = kwargs
self.method = method
self._need_mount = None
def need_mount(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
ret = hook_callback(
"backup_method", [self.method], args=self._get_args("need_mount")
)
ret_succeed = [
hook
for hook, infos in ret.items()
if any(result["state"] == "succeed" for result in infos.values())
]
self._need_mount = True if ret_succeed else False
return self._need_mount
def backup(self):
"""
Launch a custom script to backup
"""
ret = hook_callback(
"backup_method", [self.method], args=self._get_args("backup")
)
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_backup_error")
def mount(self):
"""
Launch a custom script to mount the custom archive
"""
super(CustomBackupMethod, self).mount()
ret = hook_callback(
"backup_method", [self.method], args=self._get_args("mount")
)
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_mount_error")
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,
]
#
# "Front-end" #
#
@is_unit_operation()
def backup_create(
operation_logger,
name=None,
description=None,
methods=[],
output_directory=None,
system=[],
apps=[],
dry_run=False,
):
"""
Create a backup local archive
Keyword arguments:
name -- Name of the backup archive
description -- Short description of the backup
method -- Method of backup to use
output_directory -- Output directory for the backup
system -- List of system elements to backup
apps -- List of application names to backup
"""
# TODO: Add a 'clean' argument to clean output directory
#
# Validate / parse arguments #
#
# Validate there is no archive with the same name
if name and name in backup_list()["archives"]:
raise YunohostValidationError("backup_archive_name_exists")
# By default we backup using the tar method
if not methods:
methods = ["tar"]
# Validate output_directory option
if output_directory:
output_directory = os.path.abspath(output_directory)
# Check for forbidden folders
if output_directory.startswith(ARCHIVES_PATH) or re.match(
r"^/(|(bin|boot|dev|etc|lib|root|run|sbin|sys|usr|var)(|/.*))$",
output_directory,
):
raise YunohostValidationError("backup_output_directory_forbidden")
if "copy" in methods:
if not output_directory:
raise YunohostValidationError("backup_output_directory_required")
# Check that output directory is empty
elif os.path.isdir(output_directory) and os.listdir(output_directory):
raise YunohostValidationError("backup_output_directory_not_empty")
# If no --system or --apps given, backup everything
if system is None and apps is None:
system = []
apps = []
#
# Intialize #
#
operation_logger.start()
# Create yunohost archives directory if it does not exists
_create_archive_dir()
# Initialize backup manager
backup_manager = BackupManager(
name, description, methods=methods, work_dir=output_directory
)
# Add backup targets (system and apps)
backup_manager.set_system_targets(system)
backup_manager.set_apps_targets(apps)
for app in backup_manager.targets.list("apps", exclude=["Skipped"]):
operation_logger.related_to.append(("app", app))
operation_logger.flush()
#
# Collect files and put them in the archive #
#
# Collect files to be backup (by calling app backup script / system hooks)
backup_manager.collect_files()
if dry_run:
return {
"size": backup_manager.size,
"size_details": backup_manager.size_details,
}
# Apply backup methods on prepared files
logger.info(m18n.n("backup_actually_backuping"))
logger.info(
m18n.n(
"backup_create_size_estimation",
size=binary_to_human(backup_manager.size) + "B",
)
)
backup_manager.backup()
logger.success(m18n.n("backup_created"))
operation_logger.success()
return {
"name": backup_manager.name,
"size": backup_manager.size,
"results": backup_manager.targets.results,
}
def backup_restore(name, system=[], apps=[], force=False):
"""
Restore from a local backup archive
Keyword argument:
name -- Name of the local backup archive
force -- Force restauration on an already installed system
system -- List of system parts to restore
apps -- List of application names to restore
"""
#
# Validate / parse arguments #
#
# If no --system or --apps given, restore everything
if system is None and apps is None:
system = []
apps = []
#
# Initialize #
#
if name.endswith(".tar.gz"):
name = name[: -len(".tar.gz")]
elif name.endswith(".tar"):
name = name[: -len(".tar")]
restore_manager = RestoreManager(name)
restore_manager.set_system_targets(system)
restore_manager.set_apps_targets(apps)
restore_manager.assert_enough_free_space()
#
# Add validation if restoring system parts on an already-installed system
#
if restore_manager.targets.targets["system"] != [] and os.path.isfile(
"/etc/yunohost/installed"
):
logger.warning(m18n.n("yunohost_already_installed"))
if not force:
try:
# Ask confirmation for restoring
i = Moulinette.prompt(
m18n.n("restore_confirm_yunohost_installed", answers="y/N")
)
except NotImplemented:
pass
else:
if i == "y" or i == "Y":
force = True
if not force:
raise YunohostError("restore_failed")
#
# Mount the archive then call the restore for each system part / app #
#
logger.info(m18n.n("backup_mount_archive_for_restore"))
restore_manager.mount()
restore_manager.restore()
# Check if something has been restored
if restore_manager.success:
logger.success(m18n.n("restore_complete"))
else:
raise YunohostError("restore_nothings_done")
return restore_manager.targets.results
def backup_list(with_info=False, human_readable=False):
"""
List available local backup archives
Keyword arguments:
with_info -- Show backup information for each archive
human_readable -- Print sizes in human readable format
"""
# Get local archives sorted according to last modification time
# (we do a realpath() to resolve symlinks)
archives = glob(f"{ARCHIVES_PATH}/*.tar.gz") + glob(f"{ARCHIVES_PATH}/*.tar")
archives = {os.path.realpath(archive) for archive in archives}
archives = sorted(archives, key=lambda x: os.path.getctime(x))
# Extract only filename without the extension
def remove_extension(f):
if f.endswith(".tar.gz"):
return os.path.basename(f)[: -len(".tar.gz")]
else:
return os.path.basename(f)[: -len(".tar")]
archives = [remove_extension(f) for f in archives]
if with_info:
d = OrderedDict()
for archive in archives:
try:
d[archive] = backup_info(archive, human_readable=human_readable)
except YunohostError as e:
logger.warning(str(e))
except Exception:
import traceback
trace_ = "\n" + traceback.format_exc()
logger.warning(f"Could not check infos for archive {archive}: {trace_}")
archives = d
return {"archives": archives}
def backup_download(name):
if Moulinette.interface.type != "api":
logger.error(
"This option is only meant for the API/webadmin and doesn't make sense for the command line."
)
return
archive_file = f"{ARCHIVES_PATH}/{name}.tar"
# Check file exist (even if it's a broken symlink)
if not os.path.lexists(archive_file):
archive_file += ".gz"
if not os.path.lexists(archive_file):
raise YunohostValidationError("backup_archive_name_unknown", name=name)
# If symlink, retrieve the real path
if os.path.islink(archive_file):
archive_file = os.path.realpath(archive_file)
# Raise exception if link is broken (e.g. on unmounted external storage)
if not os.path.exists(archive_file):
raise YunohostValidationError(
"backup_archive_broken_link", path=archive_file
)
# We return a raw bottle HTTPresponse (instead of serializable data like
# list/dict, ...), which is gonna be picked and used directly by moulinette
from bottle import static_file
archive_folder, archive_file_name = archive_file.rsplit("/", 1)
return static_file(archive_file_name, archive_folder, download=archive_file_name)
def backup_info(name, with_details=False, human_readable=False):
"""
Get info about a local backup archive
Keyword arguments:
name -- Name of the local backup archive
with_details -- Show additional backup information
human_readable -- Print sizes in human readable format
"""
if name.endswith(".tar.gz"):
name = name[: -len(".tar.gz")]
elif name.endswith(".tar"):
name = name[: -len(".tar")]
archive_file = f"{ARCHIVES_PATH}/{name}.tar"
# Check file exist (even if it's a broken symlink)
if not os.path.lexists(archive_file):
archive_file += ".gz"
if not os.path.lexists(archive_file):
raise YunohostValidationError("backup_archive_name_unknown", name=name)
# If symlink, retrieve the real path
if os.path.islink(archive_file):
archive_file = os.path.realpath(archive_file)
# Raise exception if link is broken (e.g. on unmounted external storage)
if not os.path.exists(archive_file):
raise YunohostValidationError(
"backup_archive_broken_link", path=archive_file
)
info_file = f"{ARCHIVES_PATH}/{name}.info.json"
if not os.path.exists(info_file):
tar = tarfile.open(
archive_file, "r:gz" if archive_file.endswith(".gz") else "r"
)
info_dir = info_file + ".d"
try:
files_in_archive = tar.getnames()
except (IOError, EOFError, tarfile.ReadError) as e:
raise YunohostError(
"backup_archive_corrupted", archive=archive_file, error=str(e)
)
try:
if "info.json" in files_in_archive:
tar.extract("info.json", path=info_dir)
elif "./info.json" in files_in_archive:
tar.extract("./info.json", path=info_dir)
else:
raise KeyError
except KeyError:
logger.debug(
"unable to retrieve '%s' inside the archive", info_file, exc_info=1
)
raise YunohostError(
"backup_archive_cant_retrieve_info_json", archive=archive_file
)
else:
shutil.move(os.path.join(info_dir, "info.json"), info_file)
finally:
tar.close()
os.rmdir(info_dir)
try:
with open(info_file) as f:
# Retrieve backup info
info = json.load(f)
except Exception:
logger.debug("unable to load '%s'", info_file, exc_info=1)
raise YunohostError(
"backup_archive_cant_retrieve_info_json", archive=archive_file
)
# Retrieve backup size
size = info.get("size", 0)
if not size:
tar = tarfile.open(
archive_file, "r:gz" if archive_file.endswith(".gz") else "r"
)
size = reduce(
lambda x, y: getattr(x, "size", x) + getattr(y, "size", y), tar.getmembers()
)
tar.close()
if human_readable:
size = binary_to_human(size) + "B"
result = {
"path": archive_file,
"created_at": datetime.utcfromtimestamp(info["created_at"]),
"description": info["description"],
"size": size,
}
if with_details:
system_key = "system"
# Historically 'system' was 'hooks'
if "hooks" in info.keys():
system_key = "hooks"
if "size_details" in info.keys():
for category in ["apps", "system"]:
for name, key_info in info[category].items():
if category == "system":
# Stupid legacy fix for weird format between 3.5 and 3.6
if isinstance(key_info, dict):
key_info = key_info.keys()
info[category][name] = key_info = {"paths": key_info}
else:
info[category][name] = key_info
if name in info["size_details"][category].keys():
key_info["size"] = info["size_details"][category][name]
if human_readable:
key_info["size"] = binary_to_human(key_info["size"]) + "B"
else:
key_info["size"] = -1
if human_readable:
key_info["size"] = "?"
result["apps"] = info["apps"]
result["system"] = info[system_key]
result["from_yunohost_version"] = info.get("from_yunohost_version")
return result
def backup_delete(name):
"""
Delete a backup
Keyword arguments:
name -- Name of the local backup archive
"""
if name not in backup_list()["archives"]:
raise YunohostValidationError("backup_archive_name_unknown", name=name)
hook_callback("pre_backup_delete", args=[name])
archive_file = f"{ARCHIVES_PATH}/{name}.tar"
if not os.path.exists(archive_file) and os.path.exists(archive_file + ".gz"):
archive_file += ".gz"
info_file = f"{ARCHIVES_PATH}/{name}.info.json"
files_to_delete = [archive_file, info_file]
# To handle the case where archive_file is in fact a symlink
if os.path.islink(archive_file):
actual_archive = os.path.realpath(archive_file)
files_to_delete.append(actual_archive)
for backup_file in files_to_delete:
if not os.path.exists(backup_file):
continue
try:
os.remove(backup_file)
except Exception:
logger.debug("unable to delete '%s'", backup_file, exc_info=1)
logger.warning(m18n.n("backup_delete_error", path=backup_file))
hook_callback("post_backup_delete", args=[name])
logger.success(m18n.n("backup_deleted"))
#
# Misc helpers #
#
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 'admin' as owner, such that
# people can scp archives out of the server
mkdir(ARCHIVES_PATH, mode=0o750, parents=True, uid="admin", gid="root")
def _call_for_each_path(self, callback, csv_path=None):
"""Call a callback for each path in csv"""
if csv_path is None:
csv_path = self.csv_path
with open(csv_path, "r") as backup_file:
backup_csv = csv.DictReader(backup_file, fieldnames=["source", "dest"])
for row in backup_csv:
callback(self, row["source"], row["dest"])
def _recursive_umount(directory):
"""
Recursively umount sub directories of a directory
Args:
directory -- a directory path
"""
mount_lines = check_output("mount").split("\n")
points_to_umount = [
line.split(" ")[2]
for line in mount_lines
if len(line) >= 3 and line.split(" ")[2].startswith(os.path.realpath(directory))
]
everything_went_fine = True
for point in reversed(points_to_umount):
ret = subprocess.call(["umount", point])
if ret != 0:
everything_went_fine = False
logger.warning(m18n.n("backup_cleaning_failed", point))
continue
return everything_went_fine
def disk_usage(path):
# We don't do this in python with os.stat because we don't want
# to follow symlinks
du_output = check_output(["du", "-sb", path], shell=False)
return int(du_output.split()[0])
def binary_to_human(n, customary=False):
"""
Convert bytes or bits into human readable format with binary prefix
Keyword argument:
n -- Number to convert
customary -- Use customary symbol instead of IEC standard
"""
symbols = ("Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi")
if customary:
symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
prefix = {}
for i, s in enumerate(symbols):
prefix[s] = 1 << (i + 1) * 10
for s in reversed(symbols):
if n >= prefix[s]:
value = float(n) / prefix[s]
return "{:.1f}{}".format(value, s)
return "%s" % n