diff --git a/data/Dockerfiles/dovecot/restore_runner.py b/data/Dockerfiles/dovecot/restore_runner.py index 47dc25d7b..4561b2aba 100644 --- a/data/Dockerfiles/dovecot/restore_runner.py +++ b/data/Dockerfiles/dovecot/restore_runner.py @@ -168,6 +168,9 @@ def own_folders(user, prefixes): guids[row['mailbox']] = row['guid'] return guids +def masked(user): + return '*' in user or '?' in user + def free_name(base, existing): name, count = base, 1 while name in existing or any(folder.startswith(name + '/') for folder in existing): @@ -451,7 +454,7 @@ class AclRestore: return identifier def add(self, owner, folder, identifier, rights, result): - if not self.in_scope(owner): + if not self.in_scope(owner) or masked(owner): result['skipped'] += 1 return try: @@ -549,6 +552,10 @@ def count_errors(result): def restore_mailbox(job_id, user, components, mode, staging, snapshot_time, prefixes, acl, granted, check_quota, result): local, _, domain = user.rpartition('@') home = '%s/var/vmail/%s/%s' % (staging, domain, local) + if masked(user): + for key in components & {'mails', 'imap_acl', 'filters'}: + merge(result, key, component(['username with * or ?, doveadm reads it as a mask'])) + return location = mail_location(user) if not location: for key in components & {'mails', 'imap_acl', 'filters'}: @@ -654,6 +661,9 @@ def run_batch(job): def main(): global conn + if os.geteuid() == 0: + # as vmail like the ofelia job: a run as root would leave a lock file the runs as vmail cannot open + os.execv('/usr/local/bin/gosu', ['gosu', 'vmail', sys.executable, os.path.abspath(__file__)] + sys.argv[1:]) os.umask(0o077) # released by the kernel when the process ends, a crashed run leaves no stale lock lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600) diff --git a/data/Dockerfiles/restic/restic_runner.py b/data/Dockerfiles/restic/restic_runner.py index 093b8678d..2fd4c5717 100644 --- a/data/Dockerfiles/restic/restic_runner.py +++ b/data/Dockerfiles/restic/restic_runner.py @@ -863,7 +863,7 @@ def install_info(): info = {} def git(*args): - result = subprocess.run(['git', '-c', 'safe.directory=*', '--no-optional-locks', '-C', install_dir] + list(args), + result = subprocess.run(['git', '-c', 'safe.directory=*', '-c', 'core.fsmonitor=false', '--no-optional-locks', '-C', install_dir] + list(args), capture_output=True, text=True, timeout=300) return result.stdout if result.returncode == 0 else None @@ -1128,10 +1128,19 @@ def make_dir(path, uid=0, gid=0, mode=0o711): def component_error(text): return {'added': 0, 'skipped': 0, 'errors': [text]} -def free_space(job, path, need, what): - free = shutil.disk_usage(path).free - if need + space_reserve > free: - raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(need + space_reserve), mib(free))) +# imported: what the dovecot runner imports into /var/vmail afterwards, keeping disk_reserve() free like import_space_ok() +def free_space(job, path, need, what, imported=0): + usage = shutil.disk_usage(path) + required = need + space_reserve + if imported: + vmail = shutil.disk_usage('/var/vmail') + if os.stat(path).st_dev == os.stat('/var/vmail').st_dev: + required = need + imported * 11 // 10 + disk_reserve(usage.total) + elif imported * 11 // 10 + disk_reserve(vmail.total) > vmail.free: + raise JobError('not enough free disk space for the import of the mails: %s needed, %s free' % ( + mib(imported * 11 // 10 + disk_reserve(vmail.total)), mib(vmail.free))) + if required > usage.free: + raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(required), mib(usage.free))) # Sizes of the files below the given directories of a snapshot (bytes per directory); listed directories that do not # exist are missing in the result @@ -1180,8 +1189,8 @@ def snapshot_names(job, repo, snap_id, folder): raise RepoError('the snapshot could not be listed: ' + restic_error(rc, err)) return names -def restic_restore(job, repo, snap_id, target, includes, need, what): - free_space(job, restore_dir, need, what) +def restic_restore(job, repo, snap_id, target, includes, need, what, imported=0): + free_space(job, restore_dir, need, what, imported) args = [] for include in includes: args += ['--include', include] @@ -1274,8 +1283,11 @@ def restore_settings(job, batch, sections, components, results): try: for component, value in post({'action': 'restore', 'job': job.id, 'username': username, 'data': section}).items(): result[component] = value - job.log('%s: %s' % (username, 'created and settings restored' if (result.get('mailbox') or {}).get('created') - else 'settings restored')) + parts = ['created'] if (result.get('mailbox') or {}).get('created') else [] + restored = [component for component in sorted(components) if component in result] + if restored: + parts.append(', '.join(restored) + ' restored') + job.log('%s: %s' % (username, '; '.join(parts) or 'nothing to restore')) except Exception as e: for component in components: result[component] = component_error('the restore request failed: %s' % http_error(e)) @@ -1464,11 +1476,12 @@ def job_restore(job, s, repo): job.save() batch_dir = '%s/b%d' % (target, number) includes, need = batch_includes(ready, homes, components) + # also without files: the dovecot runner then reports "no mails" instead of missing files + make_dir(batch_dir) if includes: - make_dir(batch_dir) job.step('files') started = time.monotonic() - restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore') + restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore', imported=need) job.log('files of %d mailboxes restored in %d s (batch %d of %d)' % (len(ready), time.monotonic() - started, number, len(batches))) detached = False diff --git a/data/web/inc/lib/mailcow/Backup/Service/JobService.php b/data/web/inc/lib/mailcow/Backup/Service/JobService.php index 8ff4b5005..b57117fe2 100644 --- a/data/web/inc/lib/mailcow/Backup/Service/JobService.php +++ b/data/web/inc/lib/mailcow/Backup/Service/JobService.php @@ -102,6 +102,13 @@ class JobService $entry[$key] = self::restricted($entry[$key], $domains); } } + // the account of an admin who requested the restore stays hidden, the UI shows "Administrator" + if (($job->params['requested_role'] ?? '') !== 'domainadmin') { + $entry['requested_by'] = ''; + if (isset($entry['log'])) { + $entry['log'] = str_replace('(requested by ' . $job->requestedBy . ')', '(requested by an administrator)', $entry['log']); + } + } return $entry; } @@ -550,6 +557,9 @@ class JobService private function domainScope(Snapshot $snapshot, mixed $value): array { $domain = strtolower((string)Input::text($value)); + if ($domain !== '' && !$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) { + throw new AccessDenied(); + } $exists = $domain !== '' && $this->jobs->isDomain($domain); // a deleted domain is created from the snapshot's export: only when the content lists it if (!$exists && !($this->context->hasAdminRights() && $snapshot->mailboxesOf($domain) !== null)) { @@ -580,6 +590,9 @@ class JobService } $domain = strtolower((string)Input::text($domainValue)); foreach ($mailboxes as $username) { + if (str_contains($username, '@') && !$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($username))) { + throw new AccessDenied(); + } // a deleted mailbox can be restored when the snapshot knows it $mailboxDomain = $this->jobs->mailboxDomain($username) ?? (isset($snapshot->mailboxesOf(self::domainOf($username))[$username]) ? self::domainOf($username) : null); if ($mailboxDomain === null) { diff --git a/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php index deedd545e..21c80d182 100644 --- a/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php +++ b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php @@ -330,6 +330,11 @@ class RestoreService continue; } $current = $this->repo->rows('alias', ['address' => $address])[0] ?? null; + // the own alias of another mailbox is its forwarding, it stays as its owner set it + if ($current !== null && $this->mailbox($address) !== null) { + $run->skipped('aliases'); + continue; + } if ($current !== null) { $this->aliasGoto($run, $current, $gotos); continue; @@ -676,9 +681,9 @@ class RestoreService $run->skipped('syncjobs'); continue; } - $source = $this->repo->rows('imapsync_source', ['id' => (int)($row['source_id'] ?? 0)])[0] ?? null; - $sourceId = ($source !== null && strtolower((string)$source['host1']) === strtolower($host) && (int)$source['port1'] === $port) - ? (int)$source['id'] : ($this->repo->syncSourcesFor($host, $port, $run->username, $run->domain)[0] ?? null); + // the backup's source when the mailbox may still use it, else another one it may use + $usable = $this->repo->syncSourcesFor($host, $port, $run->username, $run->domain); + $sourceId = in_array((int)($row['source_id'] ?? 0), $usable, true) ? (int)$row['source_id'] : ($usable[0] ?? null); if ($sourceId === null || $user1 === '') { $run->error('syncjobs', "$user1 ($host:$port): sync source does not exist"); continue; diff --git a/data/web/js/site/backup.js b/data/web/js/site/backup.js index 05af191c3..5e9e4e2e7 100644 --- a/data/web/js/site/backup.js +++ b/data/web/js/site/backup.js @@ -155,6 +155,8 @@ var mailcowBackup = (function($) { return job.state === 'queued' || job.state === 'running'; } function isCancellable(job) { + // domain admins cancel only restores domain admins requested (JobService::cancel) + if (typeof role !== 'undefined' && role === 'domainadmin' && (job.params || {}).requested_role !== 'domainadmin') return false; return job.state === 'queued' || (job.state === 'running' && !(job.type === 'restore' && DOVECOT_STEPS.indexOf(job.step) !== -1)); } function stepText(job) { diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 304cf7442..82d6a164d 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -880,6 +880,7 @@ "backup_password_not_confirmed": "Bitte zuerst bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt wird", "backup_password_too_short": "Das Repository-Passwort muss mindestens %s Zeichen lang sein", "backup_restore_active": "Eine Wiederherstellung von %s ist bereits eingereiht oder läuft", + "backup_restore_domain_active": "In %s ist bereits eine Wiederherstellung eingereiht oder läuft, Domain-Administratoren können je Domain eine Wiederherstellung gleichzeitig starten", "backup_scope_invalid": "Ungültiger Umfang der Wiederherstellung (Mailbox oder Domain)", "backup_secrets_key_missing": "Der Backup-Container hat seinen Schlüssel noch nicht bereitgestellt: Bitte den Container (restic-mailcow) starten und erneut speichern", "backup_setting_invalid": "Ungültiger Wert für %s", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 7a564e4fe..dd55f0c6f 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -871,6 +871,7 @@ "backup_password_not_confirmed": "Confirm first that the repository password is stored outside of mailcow", "backup_password_too_short": "The repository password must have at least %s characters", "backup_restore_active": "A restore of %s is already queued or running", + "backup_restore_domain_active": "A restore in %s is already queued or running, domain administrators can run one restore per domain at a time", "backup_scope_invalid": "Invalid restore scope (mailbox or domain)", "backup_secrets_key_missing": "The backup container has not provided its key yet: start it (restic-mailcow) and save again", "backup_setting_invalid": "Invalid value for %s", diff --git a/helper-scripts/restic.sh b/helper-scripts/restic.sh index 5b247905e..5c939c1c1 100755 --- a/helper-scripts/restic.sh +++ b/helper-scripts/restic.sh @@ -301,9 +301,17 @@ function schedule_decision() { } function restore_volumes() { - local snapshot=${1} manifest_arch=${2} includes=() mounts=() path volume - # the backed up paths of the snapshot, one per line - local paths=$(restic_run -- snapshots --json ${snapshot} | grep -oE '"paths":\[[^]]*\]' | grep -oE '"/[^"]*"' | tr -d '"') + local snapshot=${1} manifest_arch=${2} includes=() mounts=() restored=() path volume json paths + # the backed up paths of the snapshot, one per line; without them --delete below would act on the whole target / + if ! json=$(restic_run -- snapshots --json ${snapshot}); then + echo -e "\e[31mThe paths of the snapshot could not be read\e[0m" + return 1 + fi + paths=$(grep -oE '"paths":\[[^]]*\]' <<< "${json}" | grep -oE '"/[^"]*"' | tr -d '"') + if [[ -z ${paths} ]]; then + echo -e "\e[31mThe snapshot lists no paths\e[0m" + return 1 + fi for path in "${!SNAPSHOT_VOLUMES[@]}"; do volume=$(docker volume ls -qf name=^${CMPS_PRJ}_${SNAPSHOT_VOLUMES[${path}]}$) if [[ -z ${volume} ]]; then @@ -326,8 +334,13 @@ function restore_volumes() { fi includes+=(--include ${path}) mounts+=(-v ${volume}:${path}) + restored+=(${path}) done - echo "Restoring the volumes (${includes[*]//--include /})..." + if [[ ${#includes[@]} -eq 0 ]]; then + echo -e "\e[31mNone of the volumes is in the snapshot\e[0m" + return 1 + fi + echo "Restoring the volumes (${restored[*]})..." # --delete: the volumes get exactly the content of the snapshot restic_run "${mounts[@]}" -- restore ${snapshot} --target / --delete "${includes[@]}" } @@ -432,6 +445,8 @@ function failed_after_move() { echo "and start it with docker compose up -d (data volumes restored so far stay restored)." if [[ -n ${OLD_DUMP} ]]; then echo "The database as it was before the restore: ${ASIDE_DIR}/${OLD_DUMP} (import it with mariadb into ${DBNAME:-the database})." + elif [[ ${DB_REMOVED} == "y" ]]; then + echo "The database volume was recreated and the database before the restore was not saved: it is gone." fi exit 1 } @@ -441,9 +456,17 @@ function failed_data_only() { echo "Fix the problem shown above and run the restore again, or start mailcow as it is with" echo " ${COMPOSE_COMMAND} up -d" echo "(volumes and database restored so far stay restored)." + if [[ -n ${OLD_DUMP} ]]; then + echo "The database as it was before the restore: ${MAILCOW_DIR}/${OLD_DUMP} (import it with mariadb into ${DBNAME:-the database})." + fi exit 1 } +# The mailcow directory is renamed for a full restore: not possible for a mount point or without write access to its parent +function movable() { + ! awk -v dir="${1}" '$5 == dir { found = 1 } END { exit !found }' /proc/self/mountinfo && [[ -w $(dirname "${1}") ]] +} + # The snapshots made by mailcow; asks which one to restore (Enter: the newest) function choose_snapshot() { local latest choice @@ -458,12 +481,16 @@ function choose_snapshot() { fi } -# The database before a full restore, into the directory that is moved aside (needs the running database) +# The database before a restore, into the mailcow directory (a full restore moves it aside; needs the running database) function dump_current_database() { local mysql=$(docker ps -qf name=mysql-mailcow -f label=com.docker.compose.project=${1} | head -n 1) - [[ -z ${mysql} || -z ${DBROOT} || -z ${DBNAME} ]] && return 0 + if [[ -z ${mysql} || -z ${DBROOT} || -z ${DBNAME} ]]; then + confirm "WARNING: the current database cannot be saved before the restore (MariaDB does not run). +The restore deletes it, there is no way back to it." + return 0 + fi OLD_DUMP="mailcow-before-restore-$(date +%Y%m%d-%H%M%S).sql" - echo "Saving the current database to ${OLD_DUMP} (moves aside with the directory)..." + echo "Saving the current database to ${OLD_DUMP}..." ( umask 077; MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} mariadb-dump -uroot --single-transaction --routines \ --triggers --events --hex-blob "${DBNAME}" > "${MAILCOW_DIR}/${OLD_DUMP}" ) if [[ $? -ne 0 ]]; then @@ -521,6 +548,10 @@ function restore_full() { schedule_decision ${snap_prj} ASIDE_DIR="${MAILCOW_DIR}.before-restore-$(date +%Y%m%d-%H%M)" [[ -e ${ASIDE_DIR} ]] && ASIDE_DIR="${ASIDE_DIR}$(date +%S)" + if ! movable "${MAILCOW_DIR}"; then + echo -e "\e[31m${MAILCOW_DIR} cannot be moved aside for the restore (it is a mount point or its parent folder is not writable)\e[0m" + exit 1 + fi confirm "WARNING: this restores mailcow completely as it was in snapshot ${snapshot:0:8}: the mailcow directory (version and configuration), all mails, the mail index and crypt keys, Redis, Rspamd, the SOGo backup files and the database. ${MAILCOW_DIR} is moved to ${ASIDE_DIR}, the database volume is recreated. @@ -558,7 +589,7 @@ Everything newer than the snapshot is lost. mailcow is stopped during the restor compose "${MAILCOW_DIR}" pull || failed_after_move echo "Creating volumes and containers, the database volume is recreated..." compose "${MAILCOW_DIR}" down - docker volume rm ${CMPS_PRJ}_mysql-vol-1 > /dev/null 2>&1 + docker volume rm ${CMPS_PRJ}_mysql-vol-1 > /dev/null 2>&1 && DB_REMOVED=y compose "${MAILCOW_DIR}" up --no-start || failed_after_move restore_volumes ${snapshot} "${MANIFEST[arch]}" || failed_after_move echo "Starting the database..." @@ -646,12 +677,15 @@ Everything newer than the snapshot is lost. mailcow is stopped during the restor read -p "Also restore mailcow.conf from the snapshot (the current one is kept as a copy)? [y|N] " RESTORE_MAILCOW_CONF fi check_dump ${snapshot} || exit 1 + OLD_DUMP= + dump_current_database ${CMPS_PRJ} echo "Stopping mailcow..." compose "${MAILCOW_DIR}" down echo "Starting the database..." compose "${MAILCOW_DIR}" up -d mysql-mailcow if ! fetch_dump ${snapshot}; then + [[ -n ${OLD_DUMP} ]] && rm -f "${MAILCOW_DIR}/${OLD_DUMP}" echo "Nothing was changed, starting mailcow again..." compose "${MAILCOW_DIR}" up -d exit 1 @@ -677,6 +711,9 @@ Everything newer than the snapshot is lost. mailcow is stopped during the restor fi echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm fts rescan -A" echo "- Run an \"init\" job in the backup settings, it refreshes the list of snapshots." + if [[ -n ${OLD_DUMP} ]]; then + echo "- The database before the restore is ${MAILCOW_DIR}/${OLD_DUMP}, delete it when you no longer need it." + fi if [[ ${conf_dir} != "${MAILCOW_DIR}/data/conf" && ${RESTORE_CONF,,} =~ ^(yes|y)$ ]]; then echo "- Compare ${conf_dir} with data/conf and take over what you need." fi diff --git a/tests/php/Backup/Service/JobServiceTest.php b/tests/php/Backup/Service/JobServiceTest.php index e79432676..5a252749c 100644 --- a/tests/php/Backup/Service/JobServiceTest.php +++ b/tests/php/Backup/Service/JobServiceTest.php @@ -192,7 +192,7 @@ class JobServiceTest extends TestCase // domain admins create mailboxes in their domains, never domains $da = self::domainAdmin(); $this->assertSame(['dora@firma.de'], $this->service($da)->restore(self::restore(['mailboxes' => ['dora@firma.de']]))->params['create_mailboxes']); - $this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de']))); + $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de']))); $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@alt.de']]))); } @@ -253,6 +253,10 @@ class JobServiceTest extends TestCase // mailboxes and domains of others $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['carl@tochter.de']]))); $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'tochter.de']))); + // unknown ones look the same + $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['nobody@tochter.de']]))); + $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@nirgends.de']]))); + $this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'nirgends.de']))); } public function testUsersAndReadOnlyKeysCannotRestore(): void @@ -494,8 +498,14 @@ class JobServiceTest extends TestCase $view['result']['mailboxes']['anna@firma.de']['aliases']['errors']); $this->assertSame('restic: Fatal: unable to open repository at …', $view['error']); $this->assertSame("09:00 restore from … by …\n09:01 anna@firma.de done", $view['log']); + // and not the account of the admin who requested it + $job->requestedBy = 'superuser'; + $job->log = "09:00 restore job started (requested by superuser)"; + $view = $this->service(self::domainAdmin())->view($job, true); + $this->assertSame(['', '09:00 restore job started (requested by an administrator)'], [$view['requested_by'], $view['log']]); // admins see everything $this->assertSame($job->error, $this->service()->view($job, true)['error']); + $this->assertSame('superuser', $this->service()->view($job, true)['requested_by']); } public function testADomainAdminRestoresADomainOnceAtATime(): void diff --git a/tests/php/Backup/Service/RestoreServiceTest.php b/tests/php/Backup/Service/RestoreServiceTest.php index d635f57d4..b5265ac94 100644 --- a/tests/php/Backup/Service/RestoreServiceTest.php +++ b/tests/php/Backup/Service/RestoreServiceTest.php @@ -588,6 +588,31 @@ class RestoreServiceTest extends TestCase $this->assertSame([], $this->groupUpdates); } + public function testTheOwnAliasOfAnotherMailboxIsLeftAlone(): void + { + // bob forwarded to anna when the backup was made; info2@ was an alias then and is a mailbox now + $this->job(['aliases']); + $this->repo->tables['mailbox'][] = self::mailbox('info2@firma.de'); + $this->repo->tables['alias'][] = ['id' => 5, 'address' => 'info2@firma.de', 'goto' => 'info2@firma.de', 'domain' => 'firma.de', 'active' => 1]; + $result = $this->merge(self::backup(['aliases' => [ + ['address' => 'bob@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de'], + ['address' => 'info2@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'], + ]]))['aliases']; + $this->assertSame(['bob@firma.de', 'info2@firma.de'], [$this->alias('bob@firma.de')['goto'], $this->alias('info2@firma.de')['goto']]); + $this->assertSame(['added' => 0, 'skipped' => 2], array_slice($result, 0, 2)); + } + + public function testASyncJobUsesItsSourceOnlyWhileTheMailboxMayUseIt(): void + { + // source 2 no longer covers firma.de since the backup + $this->job(['syncjobs']); + $this->repo->tables['imapsync_source_domain'] = []; + $result = $this->merge(self::backup(['imapsync' => [['user1' => 'anna@legacy.de', 'source_id' => 2, 'source_host1' => 'imap.legacy.de', + 'source_port1' => 143, 'mins_interval' => 60]]]))['syncjobs']; + $this->assertSame([], $this->repo->rows('imapsync', ['user1' => 'anna@legacy.de'])); + $this->assertSame(['anna@legacy.de (imap.legacy.de:143): sync source does not exist'], $result['errors']); + } + public function testAliasLimitAndAddressesInUse(): void { $this->job(['aliases']); diff --git a/tests/python/backup/test_restic_runner.py b/tests/python/backup/test_restic_runner.py index cb00abfa5..7863498c5 100644 --- a/tests/python/backup/test_restic_runner.py +++ b/tests/python/backup/test_restic_runner.py @@ -108,6 +108,25 @@ class Space(Patch): rr.check_local_space(types.SimpleNamespace(log=logs.append), dict(rr.defaults, target='local')) return logs + def staging(self, free, need, imported, same_disk=True): + usage = types.SimpleNamespace(total=100 * GiB, free=free) + disk_usage, stat = rr.shutil.disk_usage, os.stat + rr.shutil.disk_usage = lambda path: usage + os.stat = lambda path, *args, **kwargs: (types.SimpleNamespace(st_dev=1 if same_disk or path != '/var/vmail' else 2) + if path in ('/restore', '/var/vmail') else stat(path, *args, **kwargs)) + self.addCleanup(setattr, rr.shutil, 'disk_usage', disk_usage) + self.addCleanup(setattr, os, 'stat', stat) + rr.free_space(None, '/restore', need, 'the files', imported) + + def test_staging_keeps_room_for_the_import(self): + # 10 GiB staged and imported on one disk: 10 + 11 + 5 (5 % of 100 GiB) GiB + self.staging(26 * GiB, 10 * GiB, 10 * GiB) + with self.assertRaisesRegex(rr.JobError, 'the files: 26624.0 MiB needed, 25600.0 MiB free'): + self.staging(25 * GiB, 10 * GiB, 10 * GiB) + with self.assertRaisesRegex(rr.JobError, 'import of the mails: 16384.0 MiB needed'): + self.staging(15 * GiB, 10 * GiB, 10 * GiB, same_disk=False) + self.staging(GiB, GiB // 2, 0) + def test_check(self): self.assertIn('50.0 GiB free, about 1.0 GiB needed plus 5.0 GiB reserve', self.check(50 * GiB, 100 * GiB, added=1)[0]) with self.assertRaisesRegex(rr.JobError, 'not enough free space for the local repository: 10.0 GiB free'): diff --git a/tests/python/backup/test_restore_runner.py b/tests/python/backup/test_restore_runner.py index ebb011a49..ff2cf601f 100644 --- a/tests/python/backup/test_restore_runner.py +++ b/tests/python/backup/test_restore_runner.py @@ -56,6 +56,15 @@ class Acl(unittest.TestCase): self.assertEqual(acl.shown('user=a@x.de'), 'user=a@x.de') self.assertTrue(rr.AclRestore(('Shared/',), None).in_scope('a@y.de')) + def test_masks(self): + # doveadm -u reads * and ? as wildcards: such owners are skipped before doveadm runs + self.assertTrue(rr.masked('a*@x.de')) + self.assertTrue(rr.masked('a?b@x.de')) + self.assertFalse(rr.masked('a.b+c@x.de')) + result = rr.component() + rr.AclRestore(('Shared/',), None).add('a*@x.de', 'INBOX', 'user=b@x.de', ['lookup'], result) + self.assertEqual(result['skipped'], 1) + class Messages(unittest.TestCase): def test_message_key(self):