[wip] Rework backup repository with config panel

This commit is contained in:
ljf 2021-10-01 18:03:43 +02:00
parent 52ba727819
commit f0bff8f021
4 changed files with 230 additions and 114 deletions

View file

@ -1161,33 +1161,43 @@ backup:
### backup_repository_add() ### backup_repository_add()
add: add:
action_help: Add a backup repository action_help: Add a backup repository
api: POST /backup/repository/<name> api: POST /backup/repository/<shortname>
arguments: arguments:
location: shortname:
help: ID of the repository
extra:
pattern: &pattern_backup_repository_id
- !!str ^\w+$
- "pattern_backup_repository_id"
-n:
full: --name
help: Short description of the repository
-l:
full: --location
help: Location on this server or on an other help: Location on this server or on an other
extra: extra:
pattern: &pattern_backup_repository_location pattern: &pattern_backup_repository_location
- !!str ^((ssh://)?[a-z_]\w*@\[\w\-\.]+:)?(~?/)?[\w/]*$ - !!str ^((ssh://)?[a-z_]\w*@\[\w\-\.]+:)?(~?/)?[\w/]*$
- "pattern_backup_repository_location" - "pattern_backup_repository_location"
-n: -m:
full: --name full: --method
help: Name of the repository help: By default 'borg' method is used, could be 'tar' or a custom method
extra: default: borg
pattern: &pattern_backup_repository_name
- !!str ^\w+$
- "pattern_backup_repository_name"
-d:
full: --description
help: Short description of the repository
--methods:
help: List of backup methods accepted
nargs: "*"
-q: -q:
full: --quota full: --quota
help: Quota to configure with this repository help: Quota to configure with this repository
-e: -p:
full: --encryption full: --passphrase
help: Type of encryption help: A strong passphrase to encrypt/decrypt your backup (keep it preciously)
action: store_true
-a:
full: --alert
help: List of mails to which sent inactivity alert
nargs: "*"
-d:
full: --alert-delay
help: Inactivity delay in days after which we sent alerts mails
default: 7
### backup_repository_update() ### backup_repository_update()
update: update:

View file

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

View file

@ -1647,7 +1647,7 @@ class BackupMethod(object):
""" """
self.manager = manager self.manager = manager
if not repo or isinstance(repo, basestring): if not repo or isinstance(repo, basestring):
repo = BackupRepository.get_or_create(ARCHIVES_PATH) repo = BackupRepository.get(ARCHIVES_PATH)
self.repo = repo self.repo = repo
@property @property
@ -2635,7 +2635,7 @@ def backup_list(repos=[], with_info=False, human_readable=False):
result = OrderedDict() result = OrderedDict()
if repos == []: if repos == []:
repos = BackupRepository.all() repos = backup_repository_list(full=True)
else: else:
for k, repo in repos: for k, repo in repos:
repos[k] = BackupRepository.get(repo) repos[k] = BackupRepository.get(repo)

View file

@ -27,6 +27,8 @@ import os
import re import re
import time import time
import subprocess import subprocess
import re
import urllib.parse
from moulinette import msignals, m18n from moulinette import msignals, m18n
from moulinette.core import MoulinetteError from moulinette.core import MoulinetteError
@ -40,58 +42,43 @@ from yunohost.monitor import binary_to_human
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.yml' REPOSITORIES_PATH = '/etc/yunohost/repositories'
REPOSITORY_CONFIG_PATH = "/usr/share/yunohost/other/config_repository.toml"
# TODO
# TODO i18n
# TODO visible in cli
# TODO split COnfigPanel.get to extract "Format result" part and be able to override it
# TODO Migration
# TODO Remove BackupRepository.get_or_create()
# TODO Backup method
# TODO auto test F2F by testing .well-known url
# TODO API params to get description of forms
# TODO tests
# TODO detect external hard drive already mounted and suggest it
# TODO F2F client detection / add / update / delete
# TODO F2F server
class BackupRepository(object): class BackupRepository(ConfigPanel):
""" """
BackupRepository manage all repository the admin added to the instance BackupRepository manage all repository the admin added to the instance
""" """
repositories = {}
@classmethod @classmethod
def create(cls, location, name, *args, **kwargs): def get(cls, shortname):
cls.load() # FIXME
return BackupRepository(True, location, name, *args, **kwargs)
@classmethod
def get(cls, name):
cls.load()
if name not in cls.repositories: if name not in cls.repositories:
raise YunohostError('backup_repository_doesnt_exists', name=name) raise YunohostError('backup_repository_doesnt_exists', name=name)
return BackupRepository(False, **cls.repositories[name]) return cls.repositories[name]
@classmethod def __init__(self, repository):
def load(cls): self.repository = repository
""" self.save_mode = "full"
Read repositories configuration from file super().__init__(
""" config_path=REPOSITORY_CONFIG_PATH,
if cls.repositories != {}: save_path=f"{REPOSITORY_SETTINGS_DIR}/{repository}.yml",
return cls.repositories )
if os.path.exists(REPOSITORIES_PATH):
try:
cls.repositories = read_yaml(REPOSITORIES_PATH)['repositories']
except MoulinetteError as e:
raise YunohostError(
'backup_cant_open_repositories_file', reason=e)
return cls.repositories
@classmethod
def save(cls):
"""
Save managed repositories to file
"""
try:
write_to_json(REPOSITORIES_PATH, cls.repositories)
except Exception as e:
raise YunohostError('backup_cant_save_repositories_file', reason=e)
def __init__(self, created, location, name=None, description=None, method=None,
encryption=None, quota=None):
from yunohost.backup import BackupMethod from yunohost.backup import BackupMethod
self.location = location self.location = location
@ -102,12 +89,6 @@ class BackupRepository(object):
raise YunohostError( raise YunohostError(
'backup_repository_already_exists', repositories=self.name) 'backup_repository_already_exists', repositories=self.name)
self.description = description
self.encryption = encryption
self.quota = quota
if method is None:
method = 'tar' if self.domain is None else 'borg'
self.method = BackupMethod.get(method, self) self.method = BackupMethod.get(method, self)
def list(self, with_info=False): def list(self, with_info=False):
@ -122,21 +103,16 @@ class BackupRepository(object):
return self.used return self.used
def purge(self): def purge(self):
# TODO F2F delete
self.method.purge() self.method.purge()
def delete(self, purge=False): def delete(self, purge=False):
repositories = BackupRepository.repositories
repositories.pop(self.name)
BackupRepository.save()
if purge: if purge:
self.purge() self.purge()
def save(self): os.system("rm -rf {REPOSITORY_SETTINGS_DIR}/{self.repository}.yml")
BackupRepository.reposirories[self.name] = self.__dict__
BackupRepository.save()
def _split_location(self): def _split_location(self):
""" """
@ -159,27 +135,41 @@ def backup_repository_list(full=False):
""" """
List available repositories where put archives List available repositories where put archives
""" """
repositories = BackupRepository.load()
if full: try:
repositories = [f.rstrip(".yml")
for f in os.listdir(REPOSITORIES_PATH)
if os.path.isfile(f) and f.endswith(".yml")]
except FileNotFoundError:
repositories = []
if not full:
return repositories return repositories
else:
return repositories.keys() # FIXME: what if one repo.yml is corrupted ?
repositories = {repo: BackupRepository(repo).get(mode="export")
for repo in repositories}
return repositories
def backup_repository_info(name, human_readable=True, space_used=False): def backup_repository_info(shortname, human_readable=True, space_used=False):
""" """
Show info about a repository Show info about a repository
Keyword arguments: Keyword arguments:
name -- Name of the backup repository name -- Name of the backup repository
""" """
repository = BackupRepository.get(name) Question.operation_logger = operation_logger
repository = BackupRepository(shortname)
# TODO
if space_used: if space_used:
repository.compute_space_used() repository.compute_space_used()
repository = repository.__dict__ repository = repository.get(
mode="export"
)
if human_readable: if human_readable:
if 'quota' in repository: if 'quota' in repository:
repository['quota'] = binary_to_human(repository['quota']) repository['quota'] = binary_to_human(repository['quota'])
@ -190,58 +180,92 @@ def backup_repository_info(name, human_readable=True, space_used=False):
@is_unit_operation() @is_unit_operation()
def backup_repository_add(operation_logger, location, name, description=None, def backup_repository_add(operation_logger, shortname, name=None, location=None,
methods=None, quota=None, encryption="passphrase"): method=None, quota=None, passphrase=None,
alert=[], alert_delay=7):
""" """
Add a backup repository Add a backup repository
Keyword arguments: Keyword arguments:
location -- Location of the repository (could be a remote location) location -- Location of the repository (could be a remote location)
name -- Name of the backup repository shortname -- Name of the backup repository
description -- An optionnal description name -- An optionnal description
quota -- Maximum size quota of the repository quota -- Maximum size quota of the repository
encryption -- If available, the kind of encryption to use encryption -- If available, the kind of encryption to use
""" """
repository = BackupRepository( # FIXME i18n
location, name, description, methods, quota, encryption) # Deduce some value from location
args = {}
args['name'] = name
args['creation'] = True
if location:
args["location"] = location
args["is_remote"] = True
args["method"] = method if method else "borg"
domain_re = '^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$'
if re.match(domain_re, location):
args["is_f2f"] = True
elif location[0] != "/":
args["is_f2f"] = False
else:
args["is_remote"] = False
args["method"] = method
elif method == "tar":
args["is_remote"] = False
if not location:
args["method"] = method
try: args["quota"] = quota
repository.save() args["passphrase"] = passphrase
except MoulinetteError: args["alert"]= ",".join(alert) if alert else None
raise YunohostError('backup_repository_add_failed', args["alert_delay"]= alert_delay
repository=name, location=location)
logger.success(m18n.n('backup_repository_added', # TODO validation
repository=name, location=location)) # TODO activate service in apply (F2F or not)
Question.operation_logger = operation_logger
repository = BackupRepository(shortname)
return repository.set(
args=urllib.parse.urlencode(args),
operation_logger=operation_logger
)
@is_unit_operation() @is_unit_operation()
def backup_repository_update(operation_logger, name, description=None, def backup_repository_update(operation_logger, shortname, name=None,
quota=None, password=None): quota=None, passphrase=None,
alert=[], alert_delay=None):
""" """
Update a backup repository Update a backup repository
Keyword arguments: Keyword arguments:
name -- Name of the backup repository name -- Name of the backup repository
""" """
repository = BackupRepository.get(name)
if description is not None: args = {}
repository.description = description args['creation'] = False
if name:
args['name'] = name
if quota:
args["quota"] = quota
if passphrase:
args["passphrase"] = passphrase
if alert is not None:
args["alert"]= ",".join(alert) if alert else None
if alert_delay:
args["alert_delay"]= alert_delay
if quota is not None: # TODO validation
repository.quota = quota # TODO activate service in apply
Question.operation_logger = operation_logger
try: repository = BackupRepository(shortname)
repository.save() return repository.set(
except MoulinetteError: args=urllib.parse.urlencode(args),
raise YunohostError('backup_repository_update_failed', repository=name) operation_logger=operation_logger
logger.success(m18n.n('backup_repository_updated', repository=name, )
location=repository['location']))
@is_unit_operation() @is_unit_operation()
def backup_repository_remove(operation_logger, name, purge=False): def backup_repository_remove(operation_logger, shortname, purge=False):
""" """
Remove a backup repository Remove a backup repository
@ -249,7 +273,6 @@ def backup_repository_remove(operation_logger, name, purge=False):
name -- Name of the backup repository to remove name -- Name of the backup repository to remove
""" """
repository = BackupRepository.get(name) BackupRepository(shortname).delete(purge)
repository.delete(purge) logger.success(m18n.n('backup_repository_removed', repository=shortname,
logger.success(m18n.n('backup_repository_removed', repository=name,
path=repository['path'])) path=repository['path']))