class BackupRunFactory(DjangoModelFactory):
    fileset = factory.SubFactory(FilesetFactory)

    duration = factory.Faker('pyint')
    success = factory.Faker('pybool')
    total_size_mb = factory.Faker('pyint')

    @factory.lazy_attribute
    def snapshot_size_mb(self):
        return random.randint(0, self.total_size_mb)

    attributes = 'do_snapshot_size_listing: false'
    snapshot_size_listing = ''

    class Meta:
        model = 'planb.BackupRun' class FilesetFactory(DjangoModelFactory):
    host_prefix = factory.Faker('hostname', levels=0)
    host_suffix = factory.Faker('domain_word')
    tld = factory.Faker('tld')

    @factory.lazy_attribute
    def friendly_name(self):
        # Set friendly name as the full hostname within the hostgroup domain.
        return '.'.join((
            self.host_prefix, self.host_suffix, self.hostgroup.name, self.tld))

    storage_alias = 'dummy'
    hostgroup = factory.SubFactory(HostGroupFactory)

    class Meta:
        model = 'planb.Fileset'
        exclude = ['host_prefix', 'host_suffix', 'tld'] class Fileset(models.Model):
    friendly_name = models.CharField(
        verbose_name=_('Name'), max_length=63,
        help_text=_('Short name, should be unique per host group.'))
    hostgroup = models.ForeignKey(
        HostGroup, related_name='filesets', on_delete=models.PROTECT)
    notes = models.TextField(blank=True, help_text=_(
        'Quick description/tips. Use the first line for labels/tags.'))

    # The storage alias is selected when adding the Fileset. Available choices
    # are selected from the storage pools in the FilesetForm.
    storage_alias = models.CharField(_('Storage'), max_length=31)
    dataset_name = models.CharField(
        verbose_name=_('Dataset name'), editable=False, max_length=254,
        help_text=_('The complete dataset name for the storage.'))

    last_ok = models.DateTimeField(
        _('Last backup success'), blank=True, null=True)
    last_run = models.DateTimeField(
        _('Last backup attempt'), default=BOGODATE)
    first_fail = models.DateTimeField(
        _('First backup failure'), blank=True, null=True)

    total_size_mb = models.PositiveIntegerField(
        default=0, db_index=True,
        help_text=_('Estimated total backup size in MiB.'))
    average_duration = models.PositiveIntegerField(
        'Time', default=0,  # this value may vary..
        help_text=_('Average duration of successful jobs in seconds.'))

    do_snapshot_size_listing = models.BooleanField(
        _('Create disk usage summary'), blank=True, default=True,
        help_text=_(
            'Summarize disk usage after the transport. '
            'This can be slow if there are many files.'))

    is_enabled = models.BooleanField(default=True)
    is_running = models.BooleanField(default=False)
    is_queued = models.BooleanField(default=False)

    blacklist_hours = models.CharField(
        _('Blacklist hours'), max_length=31, blank=True,
        validators=[validate_blacklist_hours], help_text=_(
            'Specify hours during which backups are disabled using notation '
            'h,h-h or none to disable blacklist hours. When left empty the '
            'hostgroup blacklist hours are used.'))
    retention = models.CharField(
        max_length=31, blank=True, validators=[validate_retention],
        help_text=_(
            'The backup retention period using notation <n><period> separated '
            'by comma: 1y,6m,3w,15d. When left empty the hostgroup retention '
            'periods are used.'))

    def __str__(self):
        return '{} ({})'.format(self.friendly_name, self.id)

    @property
    def unique_name(self):
        return '{}-{}'.format(self.hostgroup.name, self.friendly_name)

    @staticmethod
    def with_lock(fileset_id):
        return FilesetLock(fileset_id)

    def get_transport(self):
        ret = []
        for transport_class_name in settings.PLANB_TRANSPORTS:
            transport_class = apps.get_model(transport_class_name)
            ret.extend(transport_class.objects.filter(fileset=self))
        if not ret:
            raise ObjectDoesNotExist(
                'no transport for {!r}'.format(self))
        if len(ret) > 1:
            raise MultipleObjectsReturned(
                    'multiple transports for {!r}'.format(self))
        return ret[0]

    @cached_property
    def storage(self):
        return storage_pools[self.storage_alias]

    def get_blacklist_hours(self):
        return (self.blacklist_hours
                or self.hostgroup.blacklist_hours
                or settings.PLANB_BLACKLIST_HOURS)
    get_blacklist_hours.short_description = _('Blacklist hours')

    @property
    def is_in_blacklist_hours(self):
        hours = self.get_blacklist_hours()
        if hours != 'none':
            now = timezone.now()  # XXX should use fileset hosts localtime?
            for hour in hours.split(','):
                if '-' in hour:
                    start, end = map(int, hour.split('-'))
                    if start <= now.hour < end:
                        return True
                elif now.hour == int(hour):
                    return True
        return False

    def get_retention(self):
        for retention in (self.retention, self.hostgroup.retention):
            if retention:
                return retention
        return settings.PLANB_RETENTION
    get_retention.short_description = _('Retention')

    @property
    def retention_map(self):
        if not hasattr(self, '_retention_map'):
            self._retention_map = dict(
                (i[-1], int(i[:-1]))
                for i in self.get_retention().split(',')
                if i
            )
        return self._retention_map

    @property
    def hourly_retention(self):
        return self.retention_map.get('h', 0)

    @property
    def daily_retention(self):
        return self.retention_map.get('d', 0)

    @property
    def weekly_retention(self):
        return self.retention_map.get('w', 0)

    @property
    def monthly_retention(self):
        return self.retention_map.get('m', 0)

    @property
    def yearly_retention(self):
        return self.retention_map.get('y', 0)

    @property
    def retention_display(self):
        name_map = {
            'h': (gettext_noop('%(n)d hour'), gettext_noop('%(n)d hours')),
            'd': (gettext_noop('%(n)d day'), gettext_noop('%(n)d days')),
            'w': (gettext_noop('%(n)d week'), gettext_noop('%(n)d weeks')),
            'm': (gettext_noop('%(n)d month'), gettext_noop('%(n)d months')),
            'y': (gettext_noop('%(n)d year'), gettext_noop('%(n)d years')),
        }
        order = 'hdwmy'
        return ', '.join(
            ngettext(*name_map[period], self.retention_map[period]) % {
                'n': self.retention_map[period]}
            for period in sorted(self.retention_map, key=order.index)
            if self.retention_map[period] > 0
        )

    @property
    def total_size(self):
        return self.total_size_mb << 20

    @property
    def snapshot_size(self):
        return self.last_successful_backuprun.snapshot_size

    @cached_property
    def snapshot_count(self):
        return len(self.snapshot_list())

    def snapshot_efficiency(self):
        try:
            worst_case = self.total_size / self.snapshot_count
            efficiency = (100 * (self.snapshot_size - worst_case)
                          / (self.total_size - worst_case))
            efficiency = int(max(0, min(100, efficiency)))
            return '{:d}%'.format(efficiency)
        except (ValueError, ZeroDivisionError):
            return _('N/A')

    @cached_property
    def last_backuprun(self):
        # If the backuprun has no duration it is still running.
        # The attributes on the Fileset still reflect that of the last
        # finished backuprun (success or failure) so we need to return that.
        return self.backuprun_set.exclude(duration=None).latest('started')

    @cached_property
    def last_successful_backuprun(self):
        return self.backuprun_set.filter(success=True).latest('started')

    def get_dataset(self):
        if not hasattr(self, '_get_dataset'):
            self._get_dataset = self.storage.get_dataset(self.dataset_name)
        return self._get_dataset

    def rename_dataset(self, new_dataset_name):
        self.get_dataset().rename_dataset(new_dataset_name)
        self.__class__.objects.filter(pk=self.pk).update(
            dataset_name=new_dataset_name)

        self.dataset_name = new_dataset_name
        if hasattr(self, '_get_dataset'):
            del self._get_dataset

    def clone(self, **override):
        # See: https://github.com/django/django/commit/a97ecfdea8
        copy = self.__class__.objects.get(pk=self.pk)
        copy.pk = None
        copy.last_ok = None
        copy.last_run = BOGODATE
        copy.first_fail = None
        copy.is_queued = copy.is_running = False
        copy.average_duration = 0
        copy.total_size_mb = 0
        copy.dataset_name = ''

        transport_overrides = {}
        # Use the overrides.
        for key, value in override.items():
            if key.startswith('transport__'):
                transport_overrides[key.replace('transport__', '')] = value
            else:
                setattr(copy, key, value)
        copy.save()

        try:
            transport = self.get_transport()
        except ObjectDoesNotExist:
            pass
        else:
            transport.clone(fileset=copy, **transport_overrides)

        return copy

    def should_backup(self):
        if not self.is_enabled:
            return False

        if self._has_recent_backup():
            return False

        self.refresh_from_db(fields=['is_running'])
        if self.is_running:
            return False

        return True

    def _has_recent_backup(self):
        # If the last backup failed, it is not recent.
        if self.first_fail is not None:
            return False

        # If there is no backup, it is not recent.
        if self.last_ok is None:
            return False

        order = 'hdwmy'
        for period in sorted(self.retention_map, key=order.index):
            if self.retention_map[period] > 0:
                period_has_advanced = RETENTION_PERIOD_ADVANCED[period]
                break
        else:
            logger.warning(
                '[%s] Backup disabled by retention policy: %s',
                self, self.retention)
            return True

        now = timezone.now()
        # Advances in the period value should trigger a backup.
        # This will cause backups to start at a similar time every interval.
        # e.g. dailies every day, hourlies every hour.
        if period_has_advanced(self.last_ok, now):
            return False

        return True

    def snapshot_rotate(self):
        dataset = self.get_dataset()
        if dataset.has_child_datasets():
            return dataset.child_dataset_snapshot_rotate(self.retention_map)
        return dataset.snapshot_rotate(self.retention_map)

    def snapshot_list(self):
        return self.get_dataset().snapshot_list()

    def snapshot_list_display(self):
        try:
            dataset = self.get_dataset()
        except DatasetNotFound:
            return ['(dataset not found in storage {!r})'.format(
                self.storage_alias)]
        snapshots = sorted(
            dataset.child_dataset_snapshot_list()
            if dataset.has_child_datasets()
            else dataset.snapshot_list())
        return _DecoratedSnapshot.iterator(snapshots)

    @property
    def has_child_datasets(self):
        return self.get_dataset().has_child_datasets()

    def get_next_snapshot_name(self):
        if not hasattr(self, '_next_snapshot_name'):
            snapname = datetime.utcnow().strftime('%Y%m%dT%H%MZ')  # yuck
            if settings.PLANB_PREFIX:
                snapname = '{}-{}'.format(settings.PLANB_PREFIX, snapname)
            # XXX: yuck
            self._next_snapshot_name = snapname
        return self._next_snapshot_name

    def snapshot_create(self):
        snapname = datetime.utcnow().strftime('%Y%m%dT%H%MZ')  # XXX: see yuck
        if settings.PLANB_PREFIX:
            snapname = '{}-{}'.format(settings.PLANB_PREFIX, snapname)
        self.get_dataset().snapshot_create(snapname)
        logger.info('[%s] Created snapshot %s', self, snapname)
        return snapname

    def signal_done(self, success):
        instance = Fileset.objects.get(pk=self.pk)
        # Using send_robust, because we do not want user-code to mess up
        # the rest of our state.
        backup_done.send_robust(
            sender=self.__class__, fileset=instance, success=success)

    def save(self, *args, **kwargs):
        # Notify the same users who get ERROR / Success for backups that
        # the job was disabled/re-enabled.
        if self.pk:
            old_enabled = Fileset.objects.values_list(
                'is_enabled', flat=True).get(pk=self.pk)
            if self.is_enabled != old_enabled:
                mail_admins(
                    'INFO: Backup {} of {}'.format(
                        'ENABLED' if self.is_enabled else 'DISABLED', self),
                    'Toggled is_enabled-flag on {}.\n'.format(self))

        if not self.dataset_name:
            self.dataset_name = self.storage.name_dataset(
                self.hostgroup.name, self.friendly_name)
        return super().save(*args, **kwargs)

    class Meta:
        unique_together = (
            ('hostgroup', 'friendly_name'),
            ('storage_alias', 'dataset_name'),
        ) class FilesetRunner:
    def __init__(self, fileset_id):
        self._fileset_id = fileset_id
        self._fileset_lock = FilesetLock(fileset_id)

    def __enter__(self):
        # Use blocking so the contained code is only executed when the lock is
        # acquired.
        self._fileset_lock.acquire(blocking=True)
        return self

    def __exit__(self, type, value, traceback):
        self._fileset_lock.release()

    def get_average_duration(self):
        # Take average of last 10 runs.
        durations = (
            BackupRun.objects
            .filter(fileset_id=self._fileset_id, success=True)
            .order_by('-id').values_list('duration', flat=True))[0:10]
        if not durations:
            return 0  # impossible.. we should have backupruns if we call this
        return sum(durations) // len(durations)

    def conditional_run(self):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')
        fileset = Fileset.objects.get(pk=self._fileset_id)
        if fileset.is_in_blacklist_hours:
            logger.info(
                '[%s] Skipped because of blacklist hours: %s',
                fileset, fileset.get_blacklist_hours())
            # We could retry this, but we don't need to. The jobs are
            # rescheduled every hour, so the next hour we'll arrive here
            # too and do the same time-check.
            # #self.retry(eta=now.replace(hour=17))  # @task(bind=True)
            # Instead, we do this:
            Fileset.objects.filter(pk=fileset.pk).update(
                is_queued=False, is_running=False)
            return

        return self.unconditional_run()

    def manual_run(self):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')
        fileset = Fileset.objects.get(pk=self._fileset_id)

        # The task is delayed, but it has been scheduled/queued.
        logger.info('[%s] Manually requested backup', fileset)
        if not fileset.is_running:
            # Hack so we get success mail. (Only update first_fail if it
            # was unset.)
            Fileset.objects.filter(pk=fileset.pk, first_fail=None).update(
                first_fail=BOGODATE)

            # Run fileset. May raise an error. Always restores queued/running.
            self.unconditional_run()

    def unconditional_run(self):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')
        fileset = Fileset.objects.get(pk=self._fileset_id)
        if getproctitle:
            oldproctitle = getproctitle()

        # Mark it as running.
        Fileset.objects.filter(pk=fileset.pk).update(is_running=True)
        t0 = time.time()
        logger.info('[%s] Starting backup', fileset)
        run = BackupRun.objects.create(fileset_id=fileset.pk)
        dataset = fileset.get_dataset()
        try:
            # Lock and open dataset for work.
            with dataset.workon():
                self._unconditional_run_work(fileset, dataset, run, t0)

        except Exception as e:
            if True:  # isinstance(e, DigestableError)
                # Raise log exception with traceback. We could pass it along
                # for Django-Q but it logs errors instead of exceptions and
                # then we don't have any useful tracebacks.
                logger.exception('Backing up %s failed', fileset)
            else:
                # If the error is digestable, log an error without mail and
                # have someone run a daily mail about this instead.
                pass

            # Close the DB connection because it may be stale.
            connection.close()

            # Store failure on the run fileset.
            BackupRun.objects.filter(pk=run.pk).update(
                duration=(time.time() - t0), success=False, error_text=str(e))

            # Cache values on the fileset.
            now = timezone.now()
            Fileset.objects.filter(pk=fileset.pk).update(
                last_run=now)    # don't overwrite last_ok
            (Fileset.objects.filter(pk=fileset.pk)
             .filter(Q(first_fail=None) | Q(first_fail=BOGODATE))
             .update(first_fail=now))  # overwrite first_fail only if unset

            # Don't re-raise exception. We'll handle it.
            # As far as the workers are concerned, this job is done.
            return

        else:
            logger.info('[%s] Completed successfully', fileset)

        finally:
            if getproctitle:
                setproctitle(oldproctitle)

        # And now, spawn the dutree listing when all previous work is done and
        # finalized.
        if fileset.do_snapshot_size_listing:
            async_task(
                'planb.tasks.dutree_run', fileset.pk, run.pk,
                broker=get_broker(settings.Q_DUTREE_QUEUE))

    def _unconditional_run_work(self, fileset, dataset, run, t0):
        # Set title, create log, get transport config.
        setproctitle('[backing up %d: %s]: transporting' % (
            fileset.pk, fileset.friendly_name))
        first_fail = fileset.first_fail
        transport = fileset.get_transport()
        # Fixate the snapshot name if the transport creates snapshots.
        planned_snapshot = fileset.get_next_snapshot_name()
        transport.run_transport()

        # Flush dataset properties so we get fresh ones. Do before
        # has_child_datasets().
        dataset.flush()

        # Update snapshots.
        setproctitle('[backing up %d: %s]: snapshots' % (
            fileset.pk, fileset.friendly_name))
        if not transport.can_rotate_snapshot:
            fileset.snapshot_rotate()
        if transport.can_create_snapshot:
            snapshot = planned_snapshot
        else:
            snapshot = fileset.snapshot_create()

        # Close the DB connection because it may be stale.
        connection.close()

        # Yay, we're done.
        fileset.refresh_from_db()

        # Get total size, snapshot size and listing.
        total_size = dataset.get_used_size()
        total_size_mb = (total_size + 524288) >> 20  # bytes to MiB
        snapshot_size = dataset.get_referenced_size()
        snapshot_size_mb = (snapshot_size + 524288) >> 20
        if fileset.do_snapshot_size_listing:
            snapshot_size_listing = 'summary_pending: 0'
        else:
            snapshot_size_listing = 'summary_disabled: 0'
        # XXX Include transport export in attributes.
        attributes = safe_dump(dict(
            snapshot=snapshot,
            do_snapshot_size_listing=fileset.do_snapshot_size_listing),
            default_flow_style=False)

        # Store run info.
        BackupRun.objects.filter(pk=run.pk).update(
            attributes=attributes,
            duration=(time.time() - t0),
            success=True,
            total_size_mb=total_size_mb,
            snapshot_size_mb=snapshot_size_mb,
            snapshot_size_listing=snapshot_size_listing)

        # Cache values on the fileset.
        now = timezone.now()
        Fileset.objects.filter(pk=fileset.pk).update(
            last_ok=now,                        # success
            last_run=now,                       # now
            first_fail=None,                    # no failure
            average_duration=self.get_average_duration(),
            total_size_mb=total_size_mb)       # "disk usage"

        # Mail if failed recently.
        if first_fail:  # last job was not okay
            if first_fail == BOGODATE:
                msg = 'Backing up {} was a success.\n'.format(fileset)
            else:
                msg = (
                    'Backing up {} which was failing since {}.\n\n'
                    'Now all is well again.\n'.format(
                        fileset, first_fail))
            mail_admins(
                'OK: Backup success of {}'.format(fileset), msg)

    def dutree_run(self, run_id):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')
        fileset = Fileset.objects.get(pk=self._fileset_id)
        logger.info('[%s] Starting dutree scan', fileset)
        run = BackupRun.objects.get(pk=run_id)
        assert run.fileset_id == fileset.id

        if getproctitle:
            oldproctitle = getproctitle()

        # Retrieve the name of the snapshot created the backup run.
        attributes = safe_load(run.attributes)
        try:
            snapshot = attributes['snapshot']
        except KeyError:
            # Old backupruns could create multiple snapshots.
            snapshot = attributes['snapshots'][0]

        # Lock and open dataset for work.
        dataset = fileset.get_dataset()
        try:
            if dataset.has_child_datasets():
                self._snapshot_info_from_child_datasets(dataset, snapshot, run)
            else:
                self._snapshot_info_from_dutree(
                    fileset, dataset, snapshot, run)
        finally:
            if getproctitle:
                setproctitle(oldproctitle)

    def _snapshot_info_from_child_datasets(self, dataset, snapshot, run):
        snapshot_size = 524288
        snapshot_size_listing = []
        for subset in dataset.get_child_datasets():
            # Take only last bit from tank/group-host/[remote-tank/remote-file]
            # ("remote-tank/remote-file" is "remote_x2dtank-remote_x2dfile")
            # NOTE: "_x2f" -> "\x2f" which gets unescaped to "/" (slash)
            # NOTE: An underscore without trailing "_" is undefined. Leave it,
            # so we don't get blah_non_cool turning into blah<LF>non<LF>cool.
            subset_name = systemd_unescape(
                subset.name[(len(dataset.name) + 1):].replace('_x', '\\x'))

            size = subset.get_referenced_size(snapshot)
            snapshot_size += size
            snapshot_size_listing.append(
                '{}: {}'.format(
                    yaml_safe_str(subset_name),
                    yaml_digits(size))
            )

        BackupRun.objects.filter(pk=run.pk).update(
            snapshot_size_mb=snapshot_size >> 20,
            snapshot_size_listing='\n'.join(snapshot_size_listing),
        )

    def _snapshot_info_from_dutree(self, fileset, dataset, snapshot, run):
        path = dataset.get_snapshot_path(snapshot)
        try:
            with dataset.workon(path):
                setproctitle('[backing up %d: %s]: dutree' % (
                    fileset.pk, fileset.friendly_name))
                dutree = Scanner(path).scan(use_apparent_size=False)

                # Get snapshot size and tree.
                snapshot_size_mb = (
                    dutree.use_size() + 524288) >> 20  # bytes to MiB
                snapshot_size_yaml = '\n'.join(
                    '{}: {}'.format(
                        yaml_safe_str(i.name()[len(path):]),
                        yaml_digits(i.use_size()))
                    for i in dutree.get_leaves())
                BackupRun.objects.filter(pk=run.pk).update(
                    snapshot_size_mb=snapshot_size_mb,
                    snapshot_size_listing=snapshot_size_yaml,
                )
        except Exception as e:
            logger.exception('[%s] Failed dutree scan', fileset)
            # Append dutree error to error_text, leave success flag as is.
            error_text = (
                BackupRun.objects.filter(pk=run.pk)
                .values_list('error_text', flat=True)[0])
            BackupRun.objects.filter(pk=run.pk).update(
                snapshot_size_listing='summary_error: 0',
                error_text=('{}\n{}'.format(error_text, e).strip())
            )
        else:
            logger.info('[%s] Completed dutree scan', fileset)

    def finalize_run(self, success, resultset):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')
        # Set the queued/running to False when we're done.
        fileset = Fileset.objects.get(pk=self._fileset_id)
        Fileset.objects.filter(pk=fileset.pk).update(
            is_queued=False, is_running=False)

        # This is never not success, as we handled all cases in the
        # unconditional_run, we hope.
        if not success:
            # This should mail someone.
            logger.error('[%s] Job run failure: %r', fileset, resultset)
            fileset.signal_done(success=False)
            return

        logger.info('[%s] Done', fileset)
        fileset.signal_done(success=True)

    def rename_run(self, old_dataset_name, new_dataset_name):
        if not self._fileset_lock.is_acquired():
            raise ValueError('Cannot use fileset without acquiring lock')

        fileset = Fileset.objects.get(pk=self._fileset_id)
        if fileset.dataset_name != old_dataset_name:
            # The fileset dataset name has changed since starting this job.
            logger.warning(
                '[%s] Fileset name to %r cancelled, dataset %r does '
                'not match current %r', fileset, new_dataset_name,
                old_dataset_name, fileset.dataset_name)
            return

        logger.info(
            '[%s] Rename from %r to %r',
            fileset, old_dataset_name, new_dataset_name)
        fileset.rename_dataset(new_dataset_name)
        logger.info('[%s] Rename to %r complete', fileset, new_dataset_name) def conditional_run(fileset_id):
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.conditional_run() def dutree_run(fileset_id, run_id):
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.dutree_run(run_id) def finalize_run(task):
    fileset_id = task.args[0]
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.finalize_run(task.success, task.result) def manual_run(fileset_id):
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.manual_run() def rename_run(fileset_id, old_dataset_name, new_dataset_name):
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.rename_run(old_dataset_name, new_dataset_name) def unconditional_run(fileset_id):
    with handle_exit_signals(), FilesetRunner(fileset_id) as runner:
        runner.unconditional_run() class PlanbTestCase(TestCase):
    def setUp(self):
        super().setUp()

        # Reset storage_pools, otherwise we might get stale data from
        # previous dummy's. This is a dict, that everyone has loaded
        # already. Flush the contents of the dict.
        for storage in storage_pools.values():
            storage.close()
        storage_pools.clear()
        storage_pools.update(load_storage_pools()) class RsyncConfigFactory(DjangoModelFactory):
    fileset = factory.SubFactory(FilesetFactory)
    host = factory.SelfAttribute('fileset.friendly_name')

    transport = 0  # SSH

    class Meta:
        model = 'transport_rsync.Config'
from contextlib import contextmanager
from unittest.mock import Mock, patch
from django.test import override_settings
from django.utils.timezone import make_aware
from planb.factories import BackupRunFactory, FilesetFactory
from planb.models import Fileset
from planb.tasks import (
    FilesetRunner, conditional_run, dutree_run, finalize_run, manual_run,
    rename_run, unconditional_run)
from planb.signals import backup_done
from planb.tests.base import PlanbTestCase
from planb.transport_rsync.factories import RsyncConfigFactory
import datetime



RSYNC_BIN = '/not/rsync'


def message(
        *args, message_fmt='{level}:{module}:[{}] {}', level='INFO',
        module='planb.tasks'):
    return message_fmt.format(*args, level=level, module=module)


# These tests use the dummy storage because zfs is complicated to mock and
# not the focus of this test.
@override_settings(PLANB_RSYNC_BIN=RSYNC_BIN)
class TaskTestCase(PlanbTestCase):
    maxDiff = 8192

    def test_runner(self):
        fileset = FilesetFactory()

        runner = FilesetRunner(fileset.pk)