[wip] Repository add

This commit is contained in:
ljf 2021-10-06 18:25:57 +02:00
parent f0bff8f021
commit 8376cade21
9 changed files with 165 additions and 127 deletions

View file

@ -1166,9 +1166,9 @@ backup:
shortname: shortname:
help: ID of the repository help: ID of the repository
extra: extra:
pattern: &pattern_backup_repository_id pattern: &pattern_backup_repository_shortname
- !!str ^\w+$ - !!str ^[a-zA-Z0-9-_]+$
- "pattern_backup_repository_id" - "pattern_backup_repository_shortname"
-n: -n:
full: --name full: --name
help: Short description of the repository help: Short description of the repository
@ -1207,7 +1207,7 @@ backup:
name: name:
help: Name of the backup repository to update help: Name of the backup repository to update
extra: extra:
pattern: *pattern_backup_repository_name pattern: *pattern_backup_repository_shortname
-d: -d:
full: --description full: --description
help: Short description of the repository help: Short description of the repository
@ -1229,7 +1229,7 @@ backup:
name: name:
help: Name of the backup repository to remove help: Name of the backup repository to remove
extra: extra:
pattern: *pattern_backup_repository_name pattern: *pattern_backup_repository_shortname
--purge: --purge:
help: Remove all archives and data inside repository help: Remove all archives and data inside repository
action: store_false action: store_false

View file

@ -3,73 +3,79 @@ version = "1.0"
i18n = "repository_config" i18n = "repository_config"
[main] [main]
name.en = "" name.en = ""
[] [main.main]
name.en = "" name.en = ""
# if method == "tar": question["value"] = False # if method == "tar": question["value"] = False
[creation] # TODO "Remote repository" [main.main.description]
type = "boolean"
visible = "false"
[name] # TODO "Remote repository"
type = "string" type = "string"
default = ""
[is_remote] # TODO "Remote repository" [main.main.is_remote]
type = "boolean" type = "boolean"
yes = true yes = true
no = false no = false
visible = "creation && is_remote" visible = "creation && is_remote"
default = "no"
[is_f2f] # TODO "It's a YunoHost", [main.main.location]
help = "" # "Answer yes if the remote server is a YunoHost instance or an other F2F compatible provider", ask.en = "{is_remote}"
type = "boolean"
yes = true
no = false
visible = "creation && is_remote"
[public_key] # TODO "Here is the public key to give to your BorgBackup provider : {public_key}"
type = "alert"
style = "info"
visible = "creation && is_remote && ! is_f2f"
[location]
ask = "Remote server domain"
type = "string" type = "string"
visible = "creation && is_remote" visible = "creation && is_remote"
pattern.regexp = '^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$' pattern.regexp = '^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$'
pattern.error = '' # TODO "Please provide a valid domain" pattern.error = '' # TODO "Please provide a valid domain"
default = ""
# FIXME: can't be a domain of this instances ? # FIXME: can't be a domain of this instances ?
[alert] # TODO "Alert emails" [main.main.is_f2f]
help = '' # TODO Declare emails to which sent inactivity alerts", ask.en = "{is_remote}"
help = ""
type = "boolean"
yes = true
no = false
visible = "creation && is_remote"
default = "no"
[main.main.public_key]
type = "alert"
style = "info"
visible = "creation && is_remote && ! is_f2f"
[main.main.alert]
help = ''
type = "tags" type = "tags"
visible = "is_remote && is_f2f" visible = "is_remote && is_f2f"
pattern.regexp = '^[\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$' pattern.regexp = '^[\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$'
pattern.error = "It seems it's not a valid email" pattern.error = "It seems it's not a valid email"
default = []
# "value": alert, # "value": alert,
[alert_delay] # TODO "Alert delay" [main.main.alert_delay]
help = '' # TODO "After how many inactivity days send email alerts", help = ''
type = "number" type = "number"
visible = "is_remote && is_f2f" visible = "is_remote && is_f2f"
min = 1 min = 1
default = 7
[quota] # TODO "Quota" [main.main.quota]
type = "string" type = "string"
visible = "is_remote && is_f2f" visible = "is_remote && is_f2f"
pattern.regexp = '^\d+[MGT]$' pattern.regexp = '^\d+[MGT]$'
pattern.error = '' # TODO "" pattern.error = '' # TODO ""
default = ""
[port] # TODO "Port" [main.main.port]
type = "number" type = "number"
visible = "is_remote && !is_f2f" visible = "is_remote && !is_f2f"
min = 1 min = 1
max = 65535 max = 65535
default = 22
[user] # TODO User [main.main.user]
type = "string" type = "string"
visible = "is_remote && !is_f2f" visible = "is_remote && !is_f2f"
default = ""
[method] # TODO "Backup method" [main.main.method]
type = "select" type = "select"
# "value": method, # "value": method,
choices.borg = "BorgBackup (recommended)" choices.borg = "BorgBackup (recommended)"
@ -77,7 +83,8 @@ name.en = ""
default = "borg" default = "borg"
visible = "!is_remote" visible = "!is_remote"
[path] # TODO "Archive path" [main.main.path]
type = "path" type = "path"
visible = "!is_remote or (is_remote and !is_f2f)" visible = "!is_remote or (is_remote and !is_f2f)"
default = "/home/yunohost.backup/archives"

View file

@ -593,6 +593,21 @@
"regenconf_would_be_updated": "The configuration would have been updated for category '{category}'", "regenconf_would_be_updated": "The configuration would have been updated for category '{category}'",
"regex_incompatible_with_tile": "/!\\ Packagers! Permission '{permission}' has show_tile set to 'true' and you therefore cannot define a regex URL as the main URL", "regex_incompatible_with_tile": "/!\\ Packagers! Permission '{permission}' has show_tile set to 'true' and you therefore cannot define a regex URL as the main URL",
"regex_with_only_domain": "You can't use a regex for domain, only for path", "regex_with_only_domain": "You can't use a regex for domain, only for path",
"repository_config_description": "Long name",
"repository_config_is_remote": "Remote repository",
"repository_config_is_f2f": "It's a YunoHost",
"repository_config_is_f2f_help": "Answer yes if the remote server is a YunoHost instance or an other F2F compatible provider",
"repository_config_location": "Remote server domain",
"repository_config_public_key": "Public key to give to your BorgBackup provider : {public_key}",
"repository_config_alert": "Alert emails",
"repository_config_alert_help": "Declare emails to which sent inactivity alerts",
"repository_config_alert_delay": "Alert delay",
"repository_config_alert_delay_help": "After how many inactivity days send email alerts",
"repository_config_quota": "Quota",
"repository_config_port": "Port",
"repository_config_user": "User",
"repository_config_method": "Method",
"repository_config_path": "Archive path",
"restore_already_installed_app": "An app with the ID '{app}' is already installed", "restore_already_installed_app": "An app with the ID '{app}' is already installed",
"restore_already_installed_apps": "The following apps can't be restored because they are already installed: {apps}", "restore_already_installed_apps": "The following apps can't be restored because they are already installed: {apps}",
"restore_backup_too_old": "This backup archive can not be restored because it comes from a too-old YunoHost version.", "restore_backup_too_old": "This backup archive can not be restored because it comes from a too-old YunoHost version.",

View file

@ -73,7 +73,7 @@ from yunohost.log import OperationLogger, is_unit_operation
from yunohost.repository import BackupRepository from yunohost.repository import BackupRepository
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.packages import ynh_packages_version from yunohost.utils.packages import ynh_packages_version
from yunohost.utils.filesystem import free_space_in_directory from yunohost.utils.filesystem import free_space_in_directory, disk_usage, binary_to_human
from yunohost.settings import settings_get from yunohost.settings import settings_get
BACKUP_PATH = "/home/yunohost.backup" BACKUP_PATH = "/home/yunohost.backup"
@ -2804,24 +2804,28 @@ def backup_delete(name):
import yunohost.repository import yunohost.repository
def backup_repository_list(full): def backup_repository_list(full=False):
return yunohost.repository.backup_repository_list(full) return yunohost.repository.backup_repository_list(full)
def backup_repository_info(name, human_readable, space_used): def backup_repository_info(shortname, human_readable=True, space_used=False):
return yunohost.repository.backup_repository_info(name, human_readable, space_used) return yunohost.repository.backup_repository_info(shortname, human_readable, space_used)
def backup_repository_add(location, name, description, methods, quota, encryption): def backup_repository_add(shortname, name=None, location=None,
return yunohost.repository.backup_repository_add(location, name, description, methods, quota, encryption) method=None, quota=None, passphrase=None,
alert=[], alert_delay=7):
return yunohost.repository.backup_repository_add(location, shortname, name, method, quota, passphrase, alert, alert_delay)
def backup_repository_update(name, description, quota, password): def backup_repository_update(shortname, name=None,
return yunohost.repository.backup_repository_update(name, description, quota, password) quota=None, passphrase=None,
alert=[], alert_delay=None):
return yunohost.repository.backup_repository_update(shortname, name, quota, passphrase, alert, alert_delay)
def backup_repository_remove(name, purge): def backup_repository_remove(shortname, purge=False):
return yunohost.repository.backup_repository_remove(name, purge) return yunohost.repository.backup_repository_remove(shortname, purge)
@ -2882,29 +2886,3 @@ def _recursive_umount(directory):
return everything_went_fine 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%s" % (value, s)
return "%s" % n

View file

@ -30,24 +30,24 @@ import subprocess
import re import re
import urllib.parse import urllib.parse
from moulinette import msignals, m18n from moulinette import Moulinette, m18n
from moulinette.core import MoulinetteError from moulinette.core import MoulinetteError
from moulinette.utils import filesystem from moulinette.utils import filesystem
from moulinette.utils.log import getActionLogger from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import read_file, read_yaml, write_to_json from moulinette.utils.filesystem import read_file, read_yaml, write_to_json
from yunohost.utils.config import ConfigPanel, Question
from yunohost.utils.error import YunohostError from yunohost.utils.error import YunohostError
from yunohost.monitor import binary_to_human from yunohost.utils.filesystem import binary_to_human
from yunohost.utils.network import get_ssh_public_key
from yunohost.log import OperationLogger, is_unit_operation from yunohost.log import OperationLogger, is_unit_operation
logger = getActionLogger('yunohost.repository') logger = getActionLogger('yunohost.repository')
REPOSITORIES_PATH = '/etc/yunohost/repositories' REPOSITORIES_DIR = '/etc/yunohost/repositories'
REPOSITORY_CONFIG_PATH = "/usr/share/yunohost/other/config_repository.toml" REPOSITORY_CONFIG_PATH = "/usr/share/yunohost/other/config_repository.toml"
# TODO
# TODO i18n # TODO i18n
# TODO visible in cli
# TODO split COnfigPanel.get to extract "Format result" part and be able to override it # TODO split COnfigPanel.get to extract "Format result" part and be able to override it
# TODO Migration # TODO Migration
# TODO Remove BackupRepository.get_or_create() # TODO Remove BackupRepository.get_or_create()
@ -76,20 +76,15 @@ class BackupRepository(ConfigPanel):
self.save_mode = "full" self.save_mode = "full"
super().__init__( super().__init__(
config_path=REPOSITORY_CONFIG_PATH, config_path=REPOSITORY_CONFIG_PATH,
save_path=f"{REPOSITORY_SETTINGS_DIR}/{repository}.yml", save_path=f"{REPOSITORIES_DIR}/{repository}.yml",
) )
#self.method = BackupMethod.get(method, self)
from yunohost.backup import BackupMethod def _get_default_values(self):
self.location = location values = super()._get_default_values()
self._split_location() values["public_key"] = get_ssh_public_key()
return values
self.name = location if name is None else name
if created and self.name in BackupMethod.repositories:
raise YunohostError(
'backup_repository_already_exists', repositories=self.name)
self.method = BackupMethod.get(method, self)
def list(self, with_info=False): def list(self, with_info=False):
return self.method.list(with_info) return self.method.list(with_info)
@ -138,7 +133,7 @@ def backup_repository_list(full=False):
try: try:
repositories = [f.rstrip(".yml") repositories = [f.rstrip(".yml")
for f in os.listdir(REPOSITORIES_PATH) for f in os.listdir(REPOSITORIES_DIR)
if os.path.isfile(f) and f.endswith(".yml")] if os.path.isfile(f) and f.endswith(".yml")]
except FileNotFoundError: except FileNotFoundError:
repositories = [] repositories = []
@ -196,7 +191,7 @@ def backup_repository_add(operation_logger, shortname, name=None, location=None,
# FIXME i18n # FIXME i18n
# Deduce some value from location # Deduce some value from location
args = {} args = {}
args['name'] = name args['description'] = name
args['creation'] = True args['creation'] = True
if location: if location:
args["location"] = location args["location"] = location
@ -244,7 +239,7 @@ def backup_repository_update(operation_logger, shortname, name=None,
args = {} args = {}
args['creation'] = False args['creation'] = False
if name: if name:
args['name'] = name args['description'] = name
if quota: if quota:
args["quota"] = quota args["quota"] = quota
if passphrase: if passphrase:

View file

@ -86,7 +86,7 @@ class ConfigPanel:
if "ask" in option: if "ask" in option:
ask = _value_for_locale(option["ask"]) ask = _value_for_locale(option["ask"])
elif "i18n" in self.config: elif "i18n" in self.config:
ask = m18n.n(self.config["i18n"] + "_" + option["id"]) ask = m18n.n(self.config["i18n"] + "_" + option["id"], **self.values)
if mode == "full": if mode == "full":
# edit self.config directly # edit self.config directly
@ -298,7 +298,9 @@ class ConfigPanel:
logger.warning(f"Unknown key '{key}' found in config panel") logger.warning(f"Unknown key '{key}' found in config panel")
# Todo search all i18n keys # Todo search all i18n keys
out[key] = ( out[key] = (
value if key not in ["ask", "help", "name"] else {"en": value} value
if key not in ["ask", "help", "name"] or isinstance(value, (dict, OrderedDict))
else {"en": value}
) )
return out return out
@ -367,7 +369,7 @@ class ConfigPanel:
if "i18n" in self.config: if "i18n" in self.config:
for panel, section, option in self._iterate(): for panel, section, option in self._iterate():
if "ask" not in option: if "ask" not in option:
option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"]) option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"], **self.values)
def display_header(message): def display_header(message):
"""CLI panel/section header display""" """CLI panel/section header display"""
@ -377,6 +379,7 @@ class ConfigPanel:
for panel, section, obj in self._iterate(["panel", "section"]): for panel, section, obj in self._iterate(["panel", "section"]):
if panel == obj: if panel == obj:
name = _value_for_locale(panel["name"]) name = _value_for_locale(panel["name"])
if name:
display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}") display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}")
continue continue
name = _value_for_locale(section["name"]) name = _value_for_locale(section["name"])
@ -570,7 +573,7 @@ class Question(object):
def _format_text_for_user_input_in_cli(self): def _format_text_for_user_input_in_cli(self):
text_for_user_input_in_cli = _value_for_locale(self.ask) text_for_user_input_in_cli = _value_for_locale(self.ask).format(**self.context)
if self.choices: if self.choices:

View file

@ -29,3 +29,30 @@ def free_space_in_directory(dirpath):
def space_used_by_directory(dirpath): def space_used_by_directory(dirpath):
stat = os.statvfs(dirpath) stat = os.statvfs(dirpath)
return stat.f_frsize * stat.f_blocks return stat.f_frsize * stat.f_blocks
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%s" % (value, s)
return "%s" % n

View file

@ -18,6 +18,8 @@
along with this program; if not, see http://www.gnu.org/licenses along with this program; if not, see http://www.gnu.org/licenses
""" """
from collections import OrderedDict
from moulinette import m18n from moulinette import m18n
@ -32,7 +34,7 @@ def _value_for_locale(values):
An utf-8 encoded string An utf-8 encoded string
""" """
if not isinstance(values, dict): if not isinstance(values, (dict, OrderedDict)):
return values return values
for lang in [m18n.locale, m18n.default_locale]: for lang in [m18n.locale, m18n.default_locale]:

View file

@ -165,3 +165,14 @@ def _extract_inet(string, skip_netmask=False, skip_loopback=True):
break break
return result return result
def get_ssh_public_key():
keys = [
'/etc/ssh/ssh_host_ed25519_key.pub',
'/etc/ssh/ssh_host_rsa_key.pub'
]
for key in keys:
if os.path.exists(key):
# We return the key without user and machine name.
# Providers don't need this info.
return " ".join(read_file(key).split(" ")[0:2])