2018-11-22 03:26:26 +00:00
|
|
|
import logging
|
2021-11-12 07:05:31 +00:00
|
|
|
import threading
|
2022-02-06 11:21:17 +00:00
|
|
|
from datetime import datetime as dt
|
|
|
|
from datetime import timedelta
|
2022-02-20 06:04:12 +00:00
|
|
|
from typing import Dict, Union
|
2019-01-13 01:51:35 +00:00
|
|
|
|
2021-02-11 06:09:22 +00:00
|
|
|
from PyQt5 import QtCore
|
2021-11-12 07:05:31 +00:00
|
|
|
from PyQt5.QtWidgets import QApplication
|
2022-02-06 11:21:17 +00:00
|
|
|
|
2021-10-04 11:31:41 +00:00
|
|
|
from vorta.borg.check import BorgCheckJob
|
|
|
|
from vorta.borg.create import BorgCreateJob
|
|
|
|
from vorta.borg.list_repo import BorgListRepoJob
|
|
|
|
from vorta.borg.prune import BorgPruneJob
|
2019-01-20 03:50:10 +00:00
|
|
|
from vorta.i18n import translate
|
2020-06-01 23:58:09 +00:00
|
|
|
from vorta.notifications import VortaNotifications
|
2022-02-06 11:21:17 +00:00
|
|
|
from vorta.store.models import BackupProfileModel, EventLogModel
|
2018-11-06 05:13:49 +00:00
|
|
|
|
2019-01-13 01:51:35 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
2018-11-22 03:26:26 +00:00
|
|
|
|
2018-10-31 16:09:01 +00:00
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
class VortaScheduler(QtCore.QObject):
|
2022-02-09 10:55:26 +00:00
|
|
|
|
|
|
|
#: The schedule for the profile with the given id changed.
|
|
|
|
schedule_changed = QtCore.pyqtSignal(int)
|
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
def __init__(self):
|
2018-10-31 11:14:12 +00:00
|
|
|
super().__init__()
|
2022-02-20 06:04:12 +00:00
|
|
|
|
|
|
|
#: mapping of profiles to timers
|
|
|
|
self.timers: Dict[int, Dict[str, Union[QtCore.QTimer, dt]]] = dict()
|
|
|
|
|
2021-11-12 07:05:31 +00:00
|
|
|
self.app = QApplication.instance()
|
|
|
|
self.lock = threading.Lock()
|
2018-10-29 05:12:45 +00:00
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
# Set additional timer to make sure background tasks stay scheduled.
|
|
|
|
# E.g. after hibernation
|
2021-02-11 06:09:22 +00:00
|
|
|
self.qt_timer = QtCore.QTimer()
|
2021-10-27 04:37:28 +00:00
|
|
|
self.qt_timer.timeout.connect(self.reload_all_timers)
|
|
|
|
self.qt_timer.setInterval(15 * 60 * 1000)
|
2021-02-11 06:09:22 +00:00
|
|
|
self.qt_timer.start()
|
|
|
|
|
2019-01-20 03:50:10 +00:00
|
|
|
def tr(self, *args, **kwargs):
|
|
|
|
scope = self.__class__.__name__
|
|
|
|
return translate(scope, *args, **kwargs)
|
|
|
|
|
2022-02-20 06:04:12 +00:00
|
|
|
def set_timer_for_profile(self, profile_id: int):
|
2021-10-27 04:37:28 +00:00
|
|
|
"""
|
|
|
|
Set a timer for next scheduled backup run of this profile.
|
|
|
|
|
2021-11-15 11:02:27 +00:00
|
|
|
Removes existing jobs if set to manual only or no repo is assigned.
|
2021-10-27 04:37:28 +00:00
|
|
|
|
|
|
|
Else will look for previous scheduled backups and catch up if
|
|
|
|
schedule_make_up_missed is enabled.
|
|
|
|
|
|
|
|
Or, if catch-up is not enabled, will add interval to last run to find
|
|
|
|
next suitable backup time.
|
|
|
|
"""
|
|
|
|
profile = BackupProfileModel.get_or_none(id=profile_id)
|
2021-11-15 11:02:27 +00:00
|
|
|
if profile is None: # profile doesn't exist any more.
|
2021-10-27 04:37:28 +00:00
|
|
|
return
|
|
|
|
|
2022-02-20 06:04:12 +00:00
|
|
|
with self.lock: # Acquire lock
|
|
|
|
|
|
|
|
if profile_id in self.timers:
|
|
|
|
self.remove_job(profile_id) # reset schedule
|
|
|
|
|
|
|
|
if profile.repo is None: # No backups without repo set
|
|
|
|
logger.debug(
|
|
|
|
'Nothing scheduled for profile %s because of unset repo.',
|
|
|
|
profile_id)
|
|
|
|
|
|
|
|
if profile.schedule_mode == 'off':
|
|
|
|
logger.debug('Scheduler for profile %s is disabled.', profile_id)
|
|
|
|
return
|
|
|
|
|
|
|
|
logger.info('Setting timer for profile %s', profile_id)
|
|
|
|
|
|
|
|
# determine last backup time
|
|
|
|
last_run_log = EventLogModel.select().where(
|
|
|
|
EventLogModel.subcommand == 'create',
|
|
|
|
EventLogModel.category == 'scheduled',
|
|
|
|
EventLogModel.profile == profile.id,
|
|
|
|
0 <= EventLogModel.returncode <= 1,
|
|
|
|
).order_by(EventLogModel.end_time.desc()).first()
|
|
|
|
|
|
|
|
if last_run_log is None:
|
|
|
|
# look for non scheduled (manual) backup runs
|
|
|
|
last_run_log = EventLogModel.select().where(
|
|
|
|
EventLogModel.subcommand == 'create',
|
|
|
|
EventLogModel.profile == profile.id,
|
|
|
|
0 <= EventLogModel.returncode <= 1,
|
|
|
|
).order_by(EventLogModel.end_time.desc()).first()
|
|
|
|
|
|
|
|
# calculate next scheduled time
|
|
|
|
if profile.schedule_mode == 'interval':
|
|
|
|
if last_run_log is None:
|
|
|
|
last_time = dt.now()
|
|
|
|
else:
|
|
|
|
last_time = last_run_log.end_time
|
|
|
|
|
|
|
|
interval = {profile.schedule_interval_unit: profile.schedule_interval_count}
|
|
|
|
next_time = last_time + timedelta(**interval)
|
|
|
|
|
|
|
|
elif profile.schedule_mode == 'fixed':
|
|
|
|
if last_run_log is None:
|
|
|
|
last_time = dt.now()
|
|
|
|
else:
|
|
|
|
last_time = last_run_log.end_time + timedelta(days=1)
|
|
|
|
|
|
|
|
next_time = last_time.replace(
|
|
|
|
hour=profile.schedule_fixed_hour,
|
|
|
|
minute=profile.schedule_fixed_minute)
|
|
|
|
|
|
|
|
else:
|
|
|
|
# unknown schedule mode
|
|
|
|
raise ValueError(
|
|
|
|
"Unknown schedule mode '{}'".format(profile.schedule_mode))
|
|
|
|
|
|
|
|
# handle missing of a scheduled time
|
|
|
|
if next_time <= dt.now():
|
|
|
|
|
|
|
|
if profile.schedule_make_up_missed:
|
|
|
|
self.lock.release()
|
|
|
|
try:
|
|
|
|
logger.debug('Catching up by running job for %s (%s)',
|
|
|
|
profile.name, profile_id)
|
|
|
|
self.create_backup(profile_id)
|
|
|
|
finally:
|
|
|
|
self.lock.acquire() # with-statement will try to release
|
|
|
|
|
|
|
|
return # create_backup will lead to a call to this method
|
|
|
|
|
|
|
|
# calculate next time from now
|
|
|
|
if profile.schedule_mode == 'interval':
|
|
|
|
# next_time % interval should be 0
|
|
|
|
# while next_time > now
|
|
|
|
delta = dt.now() - last_time
|
|
|
|
next_time = dt.now() - delta % timedelta(**interval)
|
|
|
|
next_time += timedelta(**interval)
|
|
|
|
|
|
|
|
elif profile.schedule_mode == 'fixed':
|
|
|
|
if next_time.date() == dt.now().date():
|
|
|
|
# time for today has passed, schedule for tomorrow
|
|
|
|
next_time += timedelta(days=1)
|
|
|
|
else:
|
|
|
|
# schedule for today
|
|
|
|
next_time = dt.now().replace(
|
|
|
|
hour=profile.schedule_fixed_hour,
|
|
|
|
minute=profile.schedule_fixed_minute)
|
|
|
|
|
|
|
|
# start QTimer
|
|
|
|
logger.debug('Scheduling next run for %s', next_time)
|
|
|
|
|
|
|
|
timer_ms = (next_time - dt.now()).total_seconds() * 1000
|
|
|
|
|
|
|
|
timer = QtCore.QTimer()
|
|
|
|
timer.setSingleShot(True)
|
|
|
|
timer.setInterval(int(timer_ms))
|
|
|
|
timer.timeout.connect(lambda: self.create_backup(profile_id))
|
|
|
|
timer.start()
|
|
|
|
|
|
|
|
self.timers[profile_id] = {'qtt': timer, 'dt': next_time}
|
2021-11-12 07:05:31 +00:00
|
|
|
|
2022-02-09 10:55:26 +00:00
|
|
|
# Emit signal so that e.g. the GUI can react to the new schedule
|
|
|
|
self.schedule_changed.emit(profile_id)
|
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
def reload_all_timers(self):
|
|
|
|
logger.debug('Refreshing all scheduler timers')
|
2018-11-17 08:51:53 +00:00
|
|
|
for profile in BackupProfileModel.select():
|
2021-10-27 04:37:28 +00:00
|
|
|
self.set_timer_for_profile(profile.id)
|
2018-10-31 16:09:01 +00:00
|
|
|
|
|
|
|
def next_job(self):
|
2021-11-12 14:31:41 +00:00
|
|
|
next_job = now = dt.now()
|
2021-10-27 04:37:28 +00:00
|
|
|
next_profile = None
|
|
|
|
for profile_id, timer in self.timers.items():
|
2021-11-12 14:31:41 +00:00
|
|
|
if next_job == now and timer['dt'] > next_job and timer['qtt'].isActive():
|
|
|
|
next_job = timer['dt']
|
|
|
|
next_profile = profile_id
|
|
|
|
elif next_job > now and timer['dt'] < next_job and timer['qtt'].isActive():
|
2021-10-27 04:37:28 +00:00
|
|
|
next_job = timer['dt']
|
|
|
|
next_profile = profile_id
|
|
|
|
|
|
|
|
if next_profile is not None:
|
|
|
|
profile = BackupProfileModel.get_or_none(id=next_profile)
|
|
|
|
return f"{next_job.strftime('%H:%M')} ({profile.name})"
|
2018-11-17 08:51:53 +00:00
|
|
|
else:
|
2019-01-20 03:50:10 +00:00
|
|
|
return self.tr('None scheduled')
|
2018-11-17 08:51:53 +00:00
|
|
|
|
|
|
|
def next_job_for_profile(self, profile_id):
|
2021-10-27 04:37:28 +00:00
|
|
|
job = self.timers.get(profile_id)
|
2018-11-01 01:10:11 +00:00
|
|
|
if job is None:
|
2019-01-20 03:50:10 +00:00
|
|
|
return self.tr('None scheduled')
|
2018-11-01 01:10:11 +00:00
|
|
|
else:
|
2021-10-27 04:37:28 +00:00
|
|
|
return job['dt'].strftime('%Y-%m-%d %H:%M')
|
2018-10-31 16:09:01 +00:00
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
def create_backup(self, profile_id):
|
2019-01-13 01:51:35 +00:00
|
|
|
notifier = VortaNotifications.pick()
|
2021-10-27 04:37:28 +00:00
|
|
|
profile = BackupProfileModel.get_or_none(id=profile_id)
|
|
|
|
|
|
|
|
if profile is None:
|
|
|
|
logger.info('Profile not found. Maybe deleted?')
|
|
|
|
return
|
2019-01-13 01:51:35 +00:00
|
|
|
|
2021-11-12 07:05:31 +00:00
|
|
|
# Skip if a job for this profile (repo) is already in progress
|
|
|
|
if self.app.jobs_manager.is_worker_running(site=profile.repo.id):
|
|
|
|
logger.debug('A job for repo %s is already active.', profile.repo.id)
|
|
|
|
return
|
|
|
|
|
|
|
|
self.lock.acquire()
|
2018-11-22 03:26:26 +00:00
|
|
|
logger.info('Starting background backup for %s', profile.name)
|
2019-01-20 03:50:10 +00:00
|
|
|
notifier.deliver(self.tr('Vorta Backup'),
|
|
|
|
self.tr('Starting background backup for %s.') % profile.name,
|
|
|
|
level='info')
|
2021-10-04 11:31:41 +00:00
|
|
|
msg = BorgCreateJob.prepare(profile)
|
2018-10-31 16:09:01 +00:00
|
|
|
if msg['ok']:
|
2018-11-22 03:26:26 +00:00
|
|
|
logger.info('Preparation for backup successful.')
|
2021-10-27 04:37:28 +00:00
|
|
|
msg['category'] = 'scheduled'
|
|
|
|
job = BorgCreateJob(msg['cmd'], msg, profile.repo.id)
|
2021-10-04 11:31:41 +00:00
|
|
|
job.result.connect(self.notify)
|
2021-11-12 07:05:31 +00:00
|
|
|
self.app.jobs_manager.add_job(job)
|
2018-11-06 05:13:49 +00:00
|
|
|
else:
|
2018-11-22 03:26:26 +00:00
|
|
|
logger.error('Conditions for backup not met. Aborting.')
|
|
|
|
logger.error(msg['message'])
|
2019-01-20 03:50:10 +00:00
|
|
|
notifier.deliver(self.tr('Vorta Backup'), translate('messages', msg['message']), level='error')
|
2021-11-12 07:05:31 +00:00
|
|
|
self.lock.release()
|
2021-10-04 11:31:41 +00:00
|
|
|
|
|
|
|
def notify(self, result):
|
|
|
|
notifier = VortaNotifications.pick()
|
|
|
|
profile_name = result['params']['profile_name']
|
2021-11-15 11:18:11 +00:00
|
|
|
profile_id = result['params']['profile'].id
|
2021-10-04 11:31:41 +00:00
|
|
|
|
|
|
|
if result['returncode'] in [0, 1]:
|
|
|
|
notifier.deliver(self.tr('Vorta Backup'),
|
|
|
|
self.tr('Backup successful for %s.') % profile_name,
|
|
|
|
level='info')
|
|
|
|
logger.info('Backup creation successful.')
|
|
|
|
self.post_backup_tasks(profile_id)
|
|
|
|
else:
|
|
|
|
notifier.deliver(self.tr('Vorta Backup'), self.tr('Error during backup creation.'), level='error')
|
|
|
|
logger.error('Error during backup creation.')
|
2018-11-04 15:37:46 +00:00
|
|
|
|
2021-10-27 04:37:28 +00:00
|
|
|
self.set_timer_for_profile(profile_id)
|
|
|
|
|
2018-11-17 08:51:53 +00:00
|
|
|
def post_backup_tasks(self, profile_id):
|
2018-11-04 15:37:46 +00:00
|
|
|
"""
|
|
|
|
Pruning and checking after successful backup.
|
|
|
|
"""
|
2018-11-17 08:51:53 +00:00
|
|
|
profile = BackupProfileModel.get(id=profile_id)
|
2018-11-22 03:26:26 +00:00
|
|
|
logger.info('Doing post-backup jobs for %s', profile.name)
|
2018-11-04 15:37:46 +00:00
|
|
|
if profile.prune_on:
|
2021-10-04 11:31:41 +00:00
|
|
|
msg = BorgPruneJob.prepare(profile)
|
2018-11-04 15:37:46 +00:00
|
|
|
if msg['ok']:
|
2021-10-04 11:31:41 +00:00
|
|
|
job = BorgPruneJob(msg['cmd'], msg, profile.repo.id)
|
2021-11-12 07:05:31 +00:00
|
|
|
self.app.jobs_manager.add_job(job)
|
2018-11-04 15:37:46 +00:00
|
|
|
|
2019-01-20 03:50:10 +00:00
|
|
|
# Refresh archives
|
2021-10-04 11:31:41 +00:00
|
|
|
msg = BorgListRepoJob.prepare(profile)
|
2018-11-04 15:37:46 +00:00
|
|
|
if msg['ok']:
|
2021-10-04 11:31:41 +00:00
|
|
|
job = BorgListRepoJob(msg['cmd'], msg, profile.repo.id)
|
2021-11-12 07:05:31 +00:00
|
|
|
self.app.jobs_manager.add_job(job)
|
2018-11-04 08:23:17 +00:00
|
|
|
|
2022-02-20 06:04:12 +00:00
|
|
|
validation_cutoff = dt.now() - timedelta(days=7 * profile.validation_weeks)
|
2018-11-04 15:37:46 +00:00
|
|
|
recent_validations = EventLogModel.select().where(
|
2018-11-22 20:21:52 +00:00
|
|
|
(
|
|
|
|
EventLogModel.subcommand == 'check'
|
|
|
|
) & (
|
|
|
|
EventLogModel.start_time > validation_cutoff
|
|
|
|
) & (
|
|
|
|
EventLogModel.repo_url == profile.repo.url
|
|
|
|
)
|
2018-11-04 15:37:46 +00:00
|
|
|
).count()
|
|
|
|
if profile.validation_on and recent_validations == 0:
|
2021-10-04 11:31:41 +00:00
|
|
|
msg = BorgCheckJob.prepare(profile)
|
2018-11-04 15:37:46 +00:00
|
|
|
if msg['ok']:
|
2021-10-04 11:31:41 +00:00
|
|
|
job = BorgCheckJob(msg['cmd'], msg, profile.repo.id)
|
2021-11-12 07:05:31 +00:00
|
|
|
self.app.jobs_manager.add_job(job)
|
2018-11-22 03:26:26 +00:00
|
|
|
|
|
|
|
logger.info('Finished background task for profile %s', profile.name)
|
2021-11-15 11:18:11 +00:00
|
|
|
|
|
|
|
def remove_job(self, profile_id):
|
2021-11-19 13:26:59 +00:00
|
|
|
if profile_id in self.timers:
|
|
|
|
self.timers[profile_id]['qtt'].stop()
|
|
|
|
del self.timers[profile_id]
|