Merge pull request #11 from vexxhost/update-backup-service
Use openstack cloud api directly
diff --git a/staffeln/common/constants.py b/staffeln/common/constants.py
index 6de73f6..d0ec6fe 100644
--- a/staffeln/common/constants.py
+++ b/staffeln/common/constants.py
@@ -1,7 +1,5 @@
-BACKUP_COMPLETED=2
-BACKUP_WIP=1
-BACKUP_PLANNED=0
-
-BACKUP_ENABLED_KEY = 'true'
-
-DEFAULT_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
\ No newline at end of file
+BACKUP_COMPLETED=2
+BACKUP_WIP=1
+BACKUP_PLANNED=0
+
+BACKUP_ENABLED_KEY = 'true'
diff --git a/staffeln/common/notify.py b/staffeln/common/notify.py
deleted file mode 100644
index 19f8440..0000000
--- a/staffeln/common/notify.py
+++ /dev/null
@@ -1,49 +0,0 @@
-# Email notification package
-# This should be upgraded by integrating with mail server to send batch
-import smtplib
-from email.mime.text import MIMEText
-from email.mime.multipart import MIMEMultipart
-import staffeln.conf
-
-CONF = staffeln.conf.CONF
-
-
-def sendEmail(src_email, src_pwd, dest_email, subject, content, smtp_server_domain, smtp_server_port):
- try:
- message = MIMEMultipart("alternative")
- message["Subject"] = subject
- message["From"] = src_email
- message["To"] = dest_email
- part = MIMEText(content, "html")
- message.attach(part)
-
- s = smtplib.SMTP(host=smtp_server_domain, port=smtp_server_port)
- s.ehlo()
- s.starttls()
- # we can comment this auth func when use the trusted ip without authentication against the smtp server
- s.login(src_email, src_pwd)
- s.sendmail(src_email, dest_email, message.as_string())
- s.close()
- return True
- except Exception as e:
- print(str(e))
- return False
-
-def SendNotification(content, receiver=None):
- subject = "Backup result"
-
- html = "<h3>${CONTENT}</h3>"
- html = html.replace("${CONTENT}", content)
-
- if receiver == None:
- return
- if len(receiver) == 0:
- return
-
- res = sendEmail(src_email=CONF.notification.sender_email,
- src_pwd=CONF.notification.sender_pwd,
- dest_email=CONF.notification.receiver,
- subject=subject,
- content=html,
- smtp_server_domain=CONF.notification.smtp_server_domain,
- smtp_server_port=CONF.notification.smtp_server_port)
diff --git a/staffeln/common/time.py b/staffeln/common/time.py
index 6ebdee7..3059085 100644
--- a/staffeln/common/time.py
+++ b/staffeln/common/time.py
@@ -1,48 +1,55 @@
-import re
-from datetime import datetime
-from dateutil.relativedelta import relativedelta
-
-regex = re.compile(
- r'((?P<years>\d+?)y)?((?P<months>\d+?)m)?((?P<weeks>\d+?)w)?((?P<days>\d+?)d)?'
-)
-
-
-# parse_time parses timedelta string to time dict
-# input: <string> 1y2m3w5d - all values should be integer
-# output: <dict> {year: 1, month: 2, week: 3, day: 5}
-def parse_timedelta_string(time_str):
- empty_flag = True
- try:
- parts = regex.match(time_str)
- if not parts:
- return None
- parts = parts.groupdict()
- time_params = {}
- for key in parts:
- if parts[key]:
- time_params[key] = int(parts[key])
- empty_flag = False
- else:
- time_params[key] = 0
- if empty_flag: return None
- return time_params
- except:
- return None
-
-
-def timeago(years, months, weeks, days, from_date=None):
- if from_date is None:
- from_date = datetime.now()
- return from_date - relativedelta(years=years, months=months, weeks=weeks, days=days)
-
-## yearsago using Standard library
-# def yearsago(years, from_date=None):
-# if from_date is None:
-# from_date = datetime.now()
-# try:
-# return from_date.replace(year=from_date.year - years)
-# except ValueError:
-# # Must be 2/29!
-# assert from_date.month == 2 and from_date.day == 29 # can be removed
-# return from_date.replace(month=2, day=28,
-# year=from_date.year-years)
+import re
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+DEFAULT_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
+
+regex = re.compile(
+ r'((?P<years>\d+?)y)?((?P<months>\d+?)m)?((?P<weeks>\d+?)w)?((?P<days>\d+?)d)?'
+)
+
+
+# parse_time parses timedelta string to time dict
+# input: <string> 1y2m3w5d - all values should be integer
+# output: <dict> {year: 1, month: 2, week: 3, day: 5}
+def parse_timedelta_string(time_str):
+ empty_flag = True
+ try:
+ parts = regex.match(time_str)
+ if not parts:
+ return None
+ parts = parts.groupdict()
+ time_params = {}
+ for key in parts:
+ if parts[key]:
+ time_params[key] = int(parts[key])
+ empty_flag = False
+ else:
+ time_params[key] = 0
+ if empty_flag: return None
+ return time_params
+ except:
+ return None
+
+
+def get_current_strtime():
+ now = datetime.now()
+ return now.strftime(DEFAULT_TIME_FORMAT)
+
+
+def timeago(years, months, weeks, days, from_date=None):
+ if from_date is None:
+ from_date = datetime.now()
+ return from_date - relativedelta(years=years, months=months, weeks=weeks, days=days)
+
+## yearsago using Standard library
+# def yearsago(years, from_date=None):
+# if from_date is None:
+# from_date = datetime.now()
+# try:
+# return from_date.replace(year=from_date.year - years)
+# except ValueError:
+# # Must be 2/29!
+# assert from_date.month == 2 and from_date.day == 29 # can be removed
+# return from_date.replace(month=2, day=28,
+# year=from_date.year-years)
diff --git a/staffeln/conductor/backup.py b/staffeln/conductor/backup.py
index ada7faa..f358c8a 100755
--- a/staffeln/conductor/backup.py
+++ b/staffeln/conductor/backup.py
@@ -2,7 +2,8 @@
import collections
from staffeln.common import constants
-from openstack import exceptions
+from openstack.exceptions import ResourceNotFound as OpenstackResourceNotFound
+from openstack.exceptions import SDKException as OpenstackSDKException
from oslo_log import log
from staffeln.common import auth
from staffeln.common import context
@@ -51,11 +52,19 @@
queues = objects.Queue.list(self.ctx, filters=filters)
return queues
- def create_queue(self):
+ def create_queue(self, old_tasks):
"""Create the queue of all the volumes for backup"""
+ # 1. get the old task list, not finished in the last cycle
+ # and keep till now
+ old_task_volume_list = []
+ for old_task in old_tasks:
+ old_task_volume_list.append(old_task.volume_id)
+
+ # 2. add new tasks in the queue which are not existing in the old task list
queue_list = self.check_instance_volumes()
for queue in queue_list:
- self._volume_queue(queue)
+ if not queue.volume_id in old_task_volume_list:
+ self._volume_queue(queue)
# Backup the volumes attached to which has a specific metadata
def filter_server(self, metadata):
@@ -75,7 +84,7 @@
LOG.info(_("Volume %s is not backed because it is in %s status" % (volume_id, volume['status'])))
return res
- except exceptions.ResourceNotFound:
+ except OpenstackResourceNotFound:
return False
def remove_volume_backup(self, backup_object):
@@ -95,7 +104,7 @@
LOG.info(_("Rotation for the backup %s is skipped in this cycle "
"because it is in %s status") % (backup_object.backup_id, backup["status"]))
- except exceptions.ResourceNotFound:
+ except OpenstackResourceNotFound:
LOG.info(_("Backup %s is not existing in Openstack."
"Or cinder-backup is not existing in the cloud." % backup_object.backup_id))
# remove from the backup table
@@ -151,14 +160,15 @@
backup_id = queue.backup_id
if backup_id == "NULL":
try:
- volume_backup = conn.block_storage.create_backup(
+ volume_backup = conn.create_volume_backup(
volume_id=queue.volume_id, force=True
)
queue.backup_id = volume_backup.id
queue.backup_status = constants.BACKUP_WIP
queue.save()
- except exceptions as error:
- print(error)
+ except OpenstackSDKException as error:
+ LOG.info(_("Backup creation for the volume %s failled. %s"
+ % (queue.volume_id, str(error))))
else:
pass
# TODO(Alex): remove this task from the task list
@@ -199,20 +209,27 @@
status checked.
Call the backups api to see if the backup is successful.
"""
- for backup_gen in conn.block_storage.backups(volume_id=queue.volume_id):
- if backup_gen.id == queue.backup_id:
- if backup_gen.status == "error":
- self.process_failed_backup(queue)
- elif backup_gen.status == "available":
- self.process_available_backup(queue)
- elif backup_gen.status == "creating":
- # TODO(Alex): Need to escalate discussion
- # How to proceed WIP bakcup generators?
- # To make things worse, the last backup generator is in progress till
- # the new backup cycle
- LOG.info("Waiting for backup of %s to be completed" % queue.volume_id)
- else: # "deleting", "restoring", "error_restoring" status
- self.process_using_backup(queue)
+ # for backup_gen in conn.block_storage.backups(volume_id=queue.volume_id):
+ try:
+ backup_gen = conn.get_volume_backup(queue.backup_id)
+ if backup_gen == None:
+ # TODO(Alex): need to check when it is none
+ LOG.info(_("Backup status of %s is returning none."%(queue.backup_id)))
+ return
+ if backup_gen.status == "error":
+ self.process_failed_backup(queue)
+ elif backup_gen.status == "available":
+ self.process_available_backup(queue)
+ elif backup_gen.status == "creating":
+ # TODO(Alex): Need to escalate discussion
+ # How to proceed WIP bakcup generators?
+ # To make things worse, the last backup generator is in progress till
+ # the new backup cycle
+ LOG.info("Waiting for backup of %s to be completed" % queue.volume_id)
+ else: # "deleting", "restoring", "error_restoring" status
+ self.process_using_backup(queue)
+ except OpenstackResourceNotFound as e:
+ self.process_failed_backup(queue)
def _volume_backup(self, task):
# matching_backups = [
diff --git a/staffeln/conductor/manager.py b/staffeln/conductor/manager.py
index 4493001..6898348 100755
--- a/staffeln/conductor/manager.py
+++ b/staffeln/conductor/manager.py
@@ -1,164 +1,174 @@
-import cotyledon
-import datetime
-from futurist import periodics
-from oslo_log import log
-import staffeln.conf
-import threading
-import time
-
-from staffeln.common import constants
-from staffeln.conductor import backup
-from staffeln.common import context
-from staffeln.common import time as xtime
-from staffeln.i18n import _
-
-LOG = log.getLogger(__name__)
-CONF = staffeln.conf.CONF
-
-
-class BackupManager(cotyledon.Service):
- name = "Staffeln conductor backup controller"
-
- def __init__(self, worker_id, conf):
- super(BackupManager, self).__init__(worker_id)
- self._shutdown = threading.Event()
- self.conf = conf
- self.ctx = context.make_context()
- LOG.info("%s init" % self.name)
-
- def run(self):
- LOG.info("%s run" % self.name)
- periodic_callables = [
- (self.backup_engine, (), {}),
- ]
- periodic_worker = periodics.PeriodicWorker(periodic_callables, schedule_strategy="last_finished")
- periodic_thread = threading.Thread(target=periodic_worker.start)
- periodic_thread.daemon = True
- periodic_thread.start()
-
- def terminate(self):
- LOG.info("%s terminate" % self.name)
- super(BackupManager, self).terminate()
-
- def reload(self):
- LOG.info("%s reload" % self.name)
-
- # Check if the backup count is over the limit
- # TODO(Alex): how to count the backup number
- # only available backups are calculated?
- def _over_limitation(self):
- LOG.info(_("Checking the backup limitation..."))
- max_count = CONF.conductor.max_backup_count
- current_count = len(backup.Backup().get_backups())
- if max_count <= current_count:
- # TODO(Alex): Send notification
- LOG.info(_("The backup limit is over."))
- return True
- LOG.info(_("The max limit is %s, and current backup count is %s" % (max_count, current_count)))
- return False
-
- # Manage active backup generators
- def _process_wip_tasks(self):
- LOG.info(_("Processing WIP backup generators..."))
- queues_started = backup.Backup().get_queues(
- filters={"backup_status": constants.BACKUP_WIP}
- )
- if len(queues_started) != 0:
- for queue in queues_started: backup.Backup().check_volume_backup_status(queue)
-
- # Create backup generators
- def _process_new_tasks(self):
- LOG.info(_("Creating new backup generators..."))
- queues_to_start = backup.Backup().get_queues(
- filters={"backup_status": constants.BACKUP_PLANNED}
- )
- if len(queues_to_start) != 0:
- for queue in queues_to_start:
- backup.Backup().create_volume_backup(queue)
-
- # Refresh the task queue
- # TODO(Alex): need to escalate discussion
- # how to manage last backups not finished yet
- def _update_task_queue(self):
- LOG.info(_("Updating backup task queue..."))
- all_tasks = backup.Backup().get_queues()
- if len(all_tasks) == 0:
- backup.Backup().create_queue()
- else:
- LOG.info(_("The last backup cycle is not finished yet."
- "So the new backup cycle is skipped."))
-
- @periodics.periodic(spacing=CONF.conductor.backup_service_period, run_immediately=True)
- def backup_engine(self):
- LOG.info("backing... %s" % str(time.time()))
- LOG.info("%s periodics" % self.name)
-
- if self._over_limitation(): return
- self._update_task_queue()
- self._process_wip_tasks()
- self._process_new_tasks()
-
-
-class RotationManager(cotyledon.Service):
- name = "Staffeln conductor rotation controller"
-
- def __init__(self, worker_id, conf):
- super(RotationManager, self).__init__(worker_id)
- self._shutdown = threading.Event()
- self.conf = conf
- LOG.info("%s init" % self.name)
-
- def run(self):
- LOG.info("%s run" % self.name)
-
- periodic_callables = [
- (self.rotation_engine, (), {}),
- ]
- periodic_worker = periodics.PeriodicWorker(periodic_callables, schedule_strategy="last_finished")
- periodic_thread = threading.Thread(target=periodic_worker.start)
- periodic_thread.daemon = True
- periodic_thread.start()
-
- def terminate(self):
- LOG.info("%s terminate" % self.name)
- super(RotationManager, self).terminate()
-
- def reload(self):
- LOG.info("%s reload" % self.name)
-
- def get_backup_list(self):
- threshold_strtime = self.get_threshold_strtime()
- if threshold_strtime == None: return False
- self.backup_list = backup.Backup().get_backups(filters={"created_at__lt": threshold_strtime})
- return True
-
- def remove_backups(self):
- print(self.backup_list)
- for retention_backup in self.backup_list:
- # 1. check the backup status and delete only available backups
- backup.Backup().remove_volume_backup(retention_backup)
-
- @periodics.periodic(spacing=CONF.conductor.retention_service_period, run_immediately=True)
- def rotation_engine(self):
- LOG.info("%s rotation_engine" % self.name)
- # 1. get the list of backups to remove based on the retention time
- if not self.get_backup_list(): return
-
- # 2. remove the backups
- self.remove_backups()
-
- # get the threshold time str
- def get_threshold_strtime(self):
- time_delta_dict = xtime.parse_timedelta_string(CONF.conductor.retention_time)
- if time_delta_dict == None: return None
-
- res = xtime.timeago(
- years=time_delta_dict["years"],
- months=time_delta_dict["months"],
- weeks=time_delta_dict["weeks"],
- days=time_delta_dict["days"],
- )
- if res == None: LOG.info(_("Retention time format is invalid. "
- "Follow <YEARS>y<MONTHS>m<WEEKS>w<DAYS>d."))
-
- return res.strftime(constants.DEFAULT_TIME_FORMAT)
+import cotyledon
+import datetime
+from futurist import periodics
+from oslo_log import log
+import staffeln.conf
+import threading
+import time
+
+from staffeln.common import constants
+from staffeln.common import context
+from staffeln.common import time as xtime
+from staffeln.conductor import backup
+from staffeln.conductor import notify
+from staffeln.i18n import _
+
+LOG = log.getLogger(__name__)
+CONF = staffeln.conf.CONF
+
+
+class BackupManager(cotyledon.Service):
+ name = "Staffeln conductor backup controller"
+
+ def __init__(self, worker_id, conf):
+ super(BackupManager, self).__init__(worker_id)
+ self._shutdown = threading.Event()
+ self.conf = conf
+ self.ctx = context.make_context()
+ LOG.info("%s init" % self.name)
+
+ def run(self):
+ LOG.info("%s run" % self.name)
+ periodic_callables = [
+ (self.backup_engine, (), {}),
+ ]
+ periodic_worker = periodics.PeriodicWorker(periodic_callables, schedule_strategy="last_finished")
+ periodic_thread = threading.Thread(target=periodic_worker.start)
+ periodic_thread.daemon = True
+ periodic_thread.start()
+
+ def terminate(self):
+ LOG.info("%s terminate" % self.name)
+ super(BackupManager, self).terminate()
+
+ def reload(self):
+ LOG.info("%s reload" % self.name)
+
+ # Check if the backup count is over the limit
+ # TODO(Alex): how to count the backup number
+ # only available backups are calculated?
+ def _check_quota(self):
+ LOG.info(_("Checking the backup limitation..."))
+ max_count = CONF.conductor.max_backup_count
+ current_count = len(backup.Backup().get_backups())
+ if max_count <= current_count:
+ # TODO(Alex): Send notification
+ LOG.info(_("The backup limit is over."))
+ return True
+ LOG.info(_("The max limit is %s, and current backup count is %s" % (max_count, current_count)))
+ return False
+
+ # Manage active backup generators
+ # TODO(Alex): need to discuss
+ # Need to wait until all backups are finished?
+ # That is required to make the backup report
+ def _process_wip_tasks(self):
+ LOG.info(_("Processing WIP backup generators..."))
+ queues_started = backup.Backup().get_queues(
+ filters={"backup_status": constants.BACKUP_WIP}
+ )
+ if len(queues_started) != 0:
+ for queue in queues_started: backup.Backup().check_volume_backup_status(queue)
+
+ # Create backup generators
+ def _process_todo_tasks(self):
+ LOG.info(_("Creating new backup generators..."))
+ queues_to_start = backup.Backup().get_queues(
+ filters={"backup_status": constants.BACKUP_PLANNED}
+ )
+ if len(queues_to_start) != 0:
+ for queue in queues_to_start:
+ backup.Backup().create_volume_backup(queue)
+
+ # Refresh the task queue
+ def _update_task_queue(self):
+ LOG.info(_("Updating backup task queue..."))
+ current_tasks = backup.Backup().get_queues()
+ backup.Backup().create_queue(current_tasks)
+
+ def _report_backup_result(self):
+ # TODO(Alex): Need to update these list
+ self.success_backup_list = []
+ self.failed_backup_list = []
+ notify.SendBackupResultEmail(self.success_backup_list, self.failed_backup_list)
+
+
+ @periodics.periodic(spacing=CONF.conductor.backup_service_period, run_immediately=True)
+ def backup_engine(self):
+ LOG.info("backing... %s" % str(time.time()))
+ LOG.info("%s periodics" % self.name)
+
+ if self._check_quota(): return
+ # NOTE(Alex): If _process_wip_tasks() waits tiil no WIP tasks
+ # exist, no need to repeat this function before and after queue update.
+ self._process_wip_tasks()
+ self._update_task_queue()
+ self._process_todo_tasks()
+ self._process_wip_tasks()
+ self._report_backup_result()
+
+
+
+class RotationManager(cotyledon.Service):
+ name = "Staffeln conductor rotation controller"
+
+ def __init__(self, worker_id, conf):
+ super(RotationManager, self).__init__(worker_id)
+ self._shutdown = threading.Event()
+ self.conf = conf
+ LOG.info("%s init" % self.name)
+
+ def run(self):
+ LOG.info("%s run" % self.name)
+
+ periodic_callables = [
+ (self.rotation_engine, (), {}),
+ ]
+ periodic_worker = periodics.PeriodicWorker(periodic_callables, schedule_strategy="last_finished")
+ periodic_thread = threading.Thread(target=periodic_worker.start)
+ periodic_thread.daemon = True
+ periodic_thread.start()
+
+ def terminate(self):
+ LOG.info("%s terminate" % self.name)
+ super(RotationManager, self).terminate()
+
+ def reload(self):
+ LOG.info("%s reload" % self.name)
+
+ def get_backup_list(self):
+ threshold_strtime = self.get_threshold_strtime()
+ if threshold_strtime == None: return False
+ self.backup_list = backup.Backup().get_backups(filters={"created_at__lt": threshold_strtime})
+ return True
+
+ def remove_backups(self):
+ print(self.backup_list)
+ for retention_backup in self.backup_list:
+ # 1. check the backup status and delete only available backups
+ backup.Backup().remove_volume_backup(retention_backup)
+
+ @periodics.periodic(spacing=CONF.conductor.retention_service_period, run_immediately=True)
+ def rotation_engine(self):
+ LOG.info("%s rotation_engine" % self.name)
+ # 1. get the list of backups to remove based on the retention time
+ if not self.get_backup_list(): return
+
+ # 2. remove the backups
+ self.remove_backups()
+
+ # get the threshold time str
+ def get_threshold_strtime(self):
+ time_delta_dict = xtime.parse_timedelta_string(CONF.conductor.retention_time)
+ if time_delta_dict == None: return None
+
+ res = xtime.timeago(
+ years=time_delta_dict["years"],
+ months=time_delta_dict["months"],
+ weeks=time_delta_dict["weeks"],
+ days=time_delta_dict["days"],
+ )
+ if res == None: LOG.info(_("Retention time format is invalid. "
+ "Follow <YEARS>y<MONTHS>m<WEEKS>w<DAYS>d."))
+
+ return res.strftime(xtime.DEFAULT_TIME_FORMAT)
diff --git a/staffeln/conductor/notify.py b/staffeln/conductor/notify.py
new file mode 100644
index 0000000..d80da4e
--- /dev/null
+++ b/staffeln/conductor/notify.py
@@ -0,0 +1,57 @@
+# Email notification package
+# This should be upgraded by integrating with mail server to send batch
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from oslo_log import log
+import staffeln.conf
+from staffeln.common import time as xtime
+from staffeln.i18n import _
+
+CONF = staffeln.conf.CONF
+LOG = log.getLogger(__name__)
+
+
+def _sendEmail(src_email, src_pwd, dest_email, subject, content, smtp_server_domain, smtp_server_port):
+ message = MIMEMultipart("alternative")
+ message["Subject"] = subject
+ message["From"] = src_email
+ message["To"] = dest_email
+ part = MIMEText(content, "html")
+ message.attach(part)
+
+ s = smtplib.SMTP(host=smtp_server_domain, port=smtp_server_port)
+ s.ehlo()
+ s.starttls()
+ # we can comment this auth func when use the trusted ip without authentication against the smtp server
+ s.login(src_email, src_pwd)
+ s.sendmail(src_email, dest_email, message.as_string())
+ s.close()
+
+
+def SendBackupResultEmail(success_backup_list, failed_backup_list):
+ subject = "Backup result"
+
+ html = "<h3>${TIME}</h3>" \
+ "<h3>Success List</h3>" \
+ "<h4>${SUCCESS_VOLUME_LIST}</h4>" \
+ "<h3>Failed List</h3>" \
+ "<h4>${FAILED_VOLUME_LIST}</h4>"
+
+ success_volumes = '<br>'.join([str(elem) for elem in success_backup_list])
+ failed_volumes = '<br>'.join([str(elem) for elem in failed_backup_list])
+ html = html.replace("${TIME}", xtime.get_current_strtime())
+ html = html.replace("${SUCCESS_VOLUME_LIST}", success_volumes)
+ html = html.replace("${FAILED_VOLUME_LIST}", failed_volumes)
+ try:
+ _sendEmail(src_email=CONF.notification.sender_email,
+ src_pwd=CONF.notification.sender_pwd,
+ dest_email=CONF.notification.receiver,
+ subject=subject,
+ content=html,
+ smtp_server_domain=CONF.notification.smtp_server_domain,
+ smtp_server_port=CONF.notification.smtp_server_port)
+ LOG.info(_("Backup result email sent"))
+ except Exception as e:
+ LOG.error(_("Backup result email send failed. Please check email configuration. %s" % (str(e))))
+
diff --git a/staffeln/conf/notify.py b/staffeln/conf/notify.py
index 42daefe..c292e51 100644
--- a/staffeln/conf/notify.py
+++ b/staffeln/conf/notify.py
@@ -9,11 +9,6 @@
)
email_opts = [
- cfg.StrOpt(
- "template",
- default="<h3>\${CONTENT}</h3>",
- help=_("This html template is used to email the backup result."),
- ),
cfg.ListOpt(
"receiver",
default=[],