[enh] Prune and keep options

This commit is contained in:
ljf 2022-10-24 23:18:32 +02:00
parent 5d9b91af96
commit 3a6f1bd612
No known key found for this signature in database
5 changed files with 95 additions and 59 deletions

View file

@ -114,6 +114,7 @@
"backup_not_sent": "Backup archive was not saved at all", "backup_not_sent": "Backup archive was not saved at all",
"backup_partially_sent": "Backup archive was not sent into all repositories listed", "backup_partially_sent": "Backup archive was not sent into all repositories listed",
"backup_nothings_done": "Nothing to save", "backup_nothings_done": "Nothing to save",
"backup_nowhere_to_backup": "Nowhere to backup your file with this archive name",
"backup_output_directory_forbidden": "Pick a different output directory. Backups cannot be created in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var or /home/yunohost.backup/archives sub-folders", "backup_output_directory_forbidden": "Pick a different output directory. Backups cannot be created in /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var or /home/yunohost.backup/archives sub-folders",
"backup_output_directory_not_empty": "You should pick an empty output directory", "backup_output_directory_not_empty": "You should pick an empty output directory",
"backup_output_directory_required": "You must provide an output directory for the backup", "backup_output_directory_required": "You must provide an output directory for the backup",

View file

@ -1047,6 +1047,13 @@ backup:
pattern: &pattern_backup_archive_name pattern: &pattern_backup_archive_name
- !!str ^[\w\-\._]{1,50}$ - !!str ^[\w\-\._]{1,50}$
- "pattern_backup_archive_name" - "pattern_backup_archive_name"
-p:
full: --prefix
help: Prefix of the backup archive
extra:
pattern: &pattern_backup_archive_prefix
- !!str ^[\w\-\._]{1,35}$
- "pattern_backup_archive_prefix"
-d: -d:
full: --description full: --description
help: Short description of the backup help: Short description of the backup
@ -1277,6 +1284,33 @@ backup:
--prefix: --prefix:
help: Prefix on which we prune help: Prefix on which we prune
nargs: "?" nargs: "?"
-H:
full: --keep-hourly
help: Number of hourly archives to keep
type: int
-d:
full: --keep-daily
help: Number of daily archives to keep
type: int
-w:
full: --keep-weekly
help: Number of weekly archives to keep
type: int
-m:
full: --keep-monthly
help: Number of monthly archives to keep
type: int
--keep-last:
help: Number of last archives to keep
type: int
--keep-within:
help: Keep all archives within this time interval
extra:
pattern: &pattern_interval
- !!str ^\d+[Hdwmy]$
- "pattern_interval"
timer: timer:
subcategory_help: Manage backup timer subcategory_help: Manage backup timer
actions: actions:

View file

@ -25,6 +25,7 @@ import csv
import tempfile import tempfile
import re import re
import urllib.parse import urllib.parse
import datetime
from packaging import version from packaging import version
from moulinette import Moulinette, m18n from moulinette import Moulinette, m18n
@ -260,7 +261,7 @@ class BackupManager:
backup_manager.backup() backup_manager.backup()
""" """
def __init__(self, name=None, description="", repositories=[], work_dir=None): def __init__(self, name=None, prefix="", description="", repositories=[], work_dir=None):
""" """
BackupManager constructor BackupManager constructor
@ -286,7 +287,7 @@ class BackupManager:
self.targets = BackupRestoreTargetsManager() self.targets = BackupRestoreTargetsManager()
# Define backup name if needed # Define backup name if needed
self.prefix = prefix
if not name: if not name:
name = self._define_backup_name() name = self._define_backup_name()
self.name = name self.name = name
@ -334,7 +335,7 @@ class BackupManager:
""" """
# FIXME: case where this name already exist # FIXME: case where this name already exist
return time.strftime("%Y%m%d-%H%M%S", time.gmtime()) return self.prefix + time.strftime("%Y%m%d-%H%M%S", time.gmtime())
def _init_work_dir(self): def _init_work_dir(self):
"""Initialize preparation directory """Initialize preparation directory
@ -1648,6 +1649,7 @@ class RestoreManager:
def backup_create( def backup_create(
operation_logger, operation_logger,
name=None, name=None,
prefix="",
description=None, description=None,
repositories=[], repositories=[],
system=[], system=[],
@ -1679,13 +1681,15 @@ def backup_create(
# Validate there is no archive with the same name # Validate there is no archive with the same name
archives = backup_list(repositories=repositories) archives = backup_list(repositories=repositories)
archives_already_exists = []
for repository in archives: for repository in archives:
if name and name in archives[repository]: if name and name in archives[repository]:
repositories.pop(repository) repositories.remove(repository)
archives_already_exists.append(repository)
logger.error(m18n.n("backup_archive_name_exists", repository=repository)) logger.error(m18n.n("backup_archive_name_exists", repository=repository))
if not repositories: if not repositories:
raise YunohostValidationError("backup_archive_name_exists") raise YunohostValidationError("backup_nowhere_to_backup")
# If no --system or --apps given, backup everything # If no --system or --apps given, backup everything
@ -1702,7 +1706,8 @@ def backup_create(
repositories = [BackupRepository(repo) for repo in repositories] repositories = [BackupRepository(repo) for repo in repositories]
# Prepare files to backup # Prepare files to backup
backup_manager = BackupManager(name, description, backup_manager = BackupManager(name, prefix=prefix,
description=description,
repositories=repositories) repositories=repositories)
# Add backup targets (system and apps) # Add backup targets (system and apps)
@ -1741,6 +1746,7 @@ def backup_create(
) )
) )
repo_results = backup_manager.backup() repo_results = backup_manager.backup()
repo_results.update({repo: "Not sent" for repo in archives_already_exists})
repo_states = [repo_result == "Sent" for repository, repo_result in repo_results.items()] repo_states = [repo_result == "Sent" for repository, repo_result in repo_results.items()]
if all(repo_states) and all(parts_states): if all(repo_states) and all(parts_states):
@ -1978,16 +1984,19 @@ def backup_repository_remove(operation_logger, shortname, purge=False):
@is_unit_operation() @is_unit_operation()
def backup_repository_prune(operation_logger, shortname, prefix=None, keep_hourly=0, keep_daily=10, keep_weekly=8, keep_monthly=8): def backup_repository_prune(operation_logger, shortname, prefix=None, keep_hourly=None, keep_daily=None, keep_weekly=None, keep_monthly=None, keep_last=None, keep_within=None):
""" """
Remove a backup repository Remove a backup repository
""" """
BackupRepository(shortname).prune( BackupRepository(shortname).prune(
prefix=prefix, prefix=prefix,
keep_hourly=keep_hourly, keep_hourly=keep_hourly,
keep_daily=keep_daily, keep_daily=keep_daily,
keep_weekly=keep_weekly, keep_weekly=keep_weekly,
keep_monthly=keep_monthly, keep_monthly=keep_monthly,
keep_last=keep_last,
keep_within=keep_within,
) )
@ -2119,7 +2128,7 @@ Group=root
def run(self): def run(self):
self._load_current_values() self._load_current_values()
backup_create( backup_create(
name=self.entity, prefix=f"{self.entity}_",
description=self.description, description=self.description,
repositories=self.repositories, repositories=self.repositories,
system=self.system, system=self.system,
@ -2128,7 +2137,7 @@ Group=root
for repository in self.repositories: for repository in self.repositories:
backup_repository_prune( backup_repository_prune(
shortname=repository, shortname=repository,
prefix=self.entity, prefix=f"{self.entity}_",
keep_hourly=self.keep_hourly, keep_hourly=self.keep_hourly,
keep_daily=self.keep_daily, keep_daily=self.keep_daily,
keep_weekly=self.keep_weekly, keep_weekly=self.keep_weekly,

View file

@ -176,45 +176,6 @@ class BorgBackupRepository(LocalBackupRepository):
response = self._call('info', cmd, json_output=True) response = self._call('info', cmd, json_output=True)
return response["cache"]["stats"]["unique_size"] return response["cache"]["stats"]["unique_size"]
def prune(self, prefix=None, **kwargs):
# List archives with creation date
archives = {}
for archive_name in self.list_archive_name(prefix):
archive = BackupArchive(repo=self, name=archive_name)
created_at = archive.info()["created_at"]
archives[created_at] = archive
if not archives:
return
# Generate periods in which keep one archive
now = datetime.utcnow()
now -= timedelta(
minutes=now.minute,
seconds=now.second,
microseconds=now.microsecond
)
periods = set([])
for unit, qty in kwargs:
if not qty:
continue
period = timedelta(**{unit: 1})
periods += set([(now - period * i, now - period * (i - 1))
for i in range(qty)])
# Delete unneeded archive
for created_at in sorted(archives, reverse=True):
created_at = datetime.utcfromtimestamp(created_at)
keep_for = set(filter(lambda period: period[0] <= created_at <= period[1], periods))
if keep_for:
periods -= keep_for
continue
archive.delete()
class BorgBackupArchive(BackupArchive): class BorgBackupArchive(BackupArchive):
""" Backup prepared files with borg """ """ Backup prepared files with borg """

View file

@ -283,7 +283,30 @@ class BackupRepository(ConfigPanel):
return archives return archives
def prune(self, prefix=None, **kwargs): def prune(self, prefix=None, keep_last=None, keep_within=None, keep_hourly=None, keep_daily=None, keep_weekly=None, keep_monthly=None):
# Default prune options
keeps = [value is None for key, value in locals().items() if key.startswith("keep_")]
if all(keeps):
keep_hourly = 0
keep_daily = 10
keep_weekly = 8
keep_monthly = 8
logger.debug(f"Prune and keep one per each {keep_hourly} last hours, {keep_daily} last days, {keep_weekly} last weeks, {keep_monthly} last months")
keep_last = keep_last if keep_last else 0
# Convert keep_within as a date
units = {
"H": "hours",
"d": "days",
"w": "weeks",
}
now = datetime.utcnow()
if keep_within:
keep_within = now - timedelta(**{units[keep_within[-1]]: int(keep_within[:-1])})
else:
keep_within = now
# List archives with creation date # List archives with creation date
archives = {} archives = {}
@ -303,24 +326,32 @@ class BackupRepository(ConfigPanel):
microseconds=now.microsecond microseconds=now.microsecond
) )
periods = set([]) periods = set([])
units = {
for unit, qty in kwargs: "keep_hourly": {"hours": 1},
"keep_daily": {"days": 1},
"keep_weekly": {"weeks": 1},
"keep_monthly": {"days": 30}
}
keeps_xly = {key: val for key, val in locals().items()
if key.startswith("keep_") and key.endswith("ly")}
for unit, qty in keeps_xly.items():
if not qty: if not qty:
continue continue
period = timedelta(**{unit: 1}) period = timedelta(**units[unit])
periods += set([(now - period * i, now - period * (i - 1)) periods.update(set([(now - period * i, now - period * (i - 1))
for i in range(qty)]) for i in range(qty)]))
# Delete unneeded archive # Delete unneeded archive
for created_at in sorted(archives, reverse=True): for created_at in sorted(archives, reverse=True):
created_at = datetime.utcfromtimestamp(created_at) date_created_at = datetime.utcfromtimestamp(created_at)
keep_for = set(filter(lambda period: period[0] <= created_at <= period[1], periods)) keep_for = set(filter(lambda period: period[0] <= date_created_at <= period[1], periods))
if keep_for:
periods -= keep_for periods -= keep_for
if keep_for or keep_last > 0 or date_created_at >= keep_within:
keep_last -= 1
continue continue
archive.delete() archives[created_at].delete()
# ================================================= # =================================================
# Repository abstract actions # Repository abstract actions