From 34b6248d755784048353de256faf0119804fe184 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 21 Jun 2022 02:10:48 +0200 Subject: [PATCH] borg delete -a ARCH_GLOB, borg rdelete --- src/borg/archiver.py | 205 +++++++++++++++++---------------- src/borg/testsuite/archiver.py | 39 ++++--- 2 files changed, 125 insertions(+), 119 deletions(-) diff --git a/src/borg/archiver.py b/src/borg/archiver.py index a1ad72b67..436aa8ddc 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -679,7 +679,7 @@ class Archiver: # now build files cache rc1 = self.do_create(self.parse_args([f'--repo={repo}', 'create', compression, 'borg-benchmark-crud2', path])) - rc2 = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '--name=borg-benchmark-crud2'])) + rc2 = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '-a', 'borg-benchmark-crud2'])) assert rc1 == rc2 == 0 # measure a no-change update (archive1 is still present) t_start = time.monotonic() @@ -687,7 +687,7 @@ class Archiver: 'borg-benchmark-crud3', path])) t_end = time.monotonic() dt_update = t_end - t_start - rc2 = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '--name=borg-benchmark-crud3'])) + rc2 = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '-a', 'borg-benchmark-crud3'])) assert rc1 == rc2 == 0 # measure extraction (dry-run: without writing result to disk) t_start = time.monotonic() @@ -698,7 +698,7 @@ class Archiver: assert rc == 0 # measure archive deletion (of LAST present archive with the data) t_start = time.monotonic() - rc = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '--name=borg-benchmark-crud1'])) + rc = self.do_delete(self.parse_args([f'--repo={repo}', 'delete', '-a', 'borg-benchmark-crud1'])) t_end = time.monotonic() dt_delete = t_end - t_start assert rc == 0 @@ -1515,35 +1515,80 @@ class Archiver: return self.exit_code @with_repository(exclusive=True, manifest=False) - def do_delete(self, args, repository): - """Delete an existing repository or archives""" - archive_filter_specified = any((args.first, args.last, args.prefix is not None, args.glob_archives)) - explicit_archives_specified = args.name or args.archives + def do_rdelete(self, args, repository): + """Delete a repository""" self.output_list = args.output_list - if archive_filter_specified and explicit_archives_specified: - self.print_error('Mixing archive filters and explicitly named archives is not supported.') - return self.exit_code - if archive_filter_specified or explicit_archives_specified: - return self._delete_archives(args, repository) - else: - return self._delete_repository(args, repository) - - def _delete_archives(self, args, repository): - """Delete archives""" dry_run = args.dry_run + keep_security_info = args.keep_security_info - manifest, key = Manifest.load(repository, (Manifest.Operation.DELETE,)) + if not args.cache_only: + if args.forced == 0: # without --force, we let the user see the archives list and confirm. + id = bin_to_hex(repository.id) + location = repository._location.canonical_path() + msg = [] + try: + manifest, key = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + n_archives = len(manifest.archives) + msg.append(f"You requested to completely DELETE the following repository " + f"*including* {n_archives} archives it contains:") + except NoManifestError: + n_archives = None + msg.append("You requested to completely DELETE the following repository " + "*including* all archives it may contain:") - if args.name or args.archives: - archives = list(args.archives) - if args.name: - archives.insert(0, args.name) - archive_names = tuple(archives) + msg.append(DASHES) + msg.append(f"Repository ID: {id}") + msg.append(f"Location: {location}") + + if self.output_list: + msg.append("") + msg.append("Archives:") + + if n_archives is not None: + if n_archives > 0: + for archive_info in manifest.archives.list(sort_by=['ts']): + msg.append(format_archive(archive_info)) + else: + msg.append("This repository seems to not have any archives.") + else: + msg.append("This repository seems to have no manifest, so we can't " + "tell anything about its contents.") + + msg.append(DASHES) + msg.append("Type 'YES' if you understand this and want to continue: ") + msg = '\n'.join(msg) + if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES',), + retry=False, env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'): + self.exit_code = EXIT_ERROR + return self.exit_code + if not dry_run: + repository.destroy() + logger.info("Repository deleted.") + if not keep_security_info: + SecurityManager.destroy(repository) + else: + logger.info("Would delete repository.") + logger.info("Would %s security info." % ("keep" if keep_security_info else "delete")) + if not dry_run: + Cache.destroy(repository) + logger.info("Cache deleted.") else: - args.consider_checkpoints = True - archive_names = tuple(x.name for x in manifest.archives.list_considering(args)) - if not archive_names: - return self.exit_code + logger.info("Would delete cache.") + return self.exit_code + + @with_repository(exclusive=True, manifest=False) + def do_delete(self, args, repository): + """Delete archives""" + self.output_list = args.output_list + dry_run = args.dry_run + manifest, key = Manifest.load(repository, (Manifest.Operation.DELETE,)) + archive_names = tuple(x.name for x in manifest.archives.list_considering(args)) + if not archive_names: + return self.exit_code + if args.glob_archives is None and args.first == 0 and args.last == 0: + self.print_error("Aborting: if you really want to delete all archives, please use -a '*' " + "or just delete the whole repository (might be much faster).") + return EXIT_ERROR if args.forced == 2: deleted = False @@ -1605,66 +1650,6 @@ class Archiver: return self.exit_code - def _delete_repository(self, args, repository): - """Delete a repository""" - dry_run = args.dry_run - keep_security_info = args.keep_security_info - - if not args.cache_only: - if args.forced == 0: # without --force, we let the user see the archives list and confirm. - id = bin_to_hex(repository.id) - location = repository._location.canonical_path() - msg = [] - try: - manifest, key = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) - n_archives = len(manifest.archives) - msg.append(f"You requested to completely DELETE the following repository " - f"*including* {n_archives} archives it contains:") - except NoManifestError: - n_archives = None - msg.append("You requested to completely DELETE the following repository " - "*including* all archives it may contain:") - - msg.append(DASHES) - msg.append(f"Repository ID: {id}") - msg.append(f"Location: {location}") - - if self.output_list: - msg.append("") - msg.append("Archives:") - - if n_archives is not None: - if n_archives > 0: - for archive_info in manifest.archives.list(sort_by=['ts']): - msg.append(format_archive(archive_info)) - else: - msg.append("This repository seems to not have any archives.") - else: - msg.append("This repository seems to have no manifest, so we can't " - "tell anything about its contents.") - - msg.append(DASHES) - msg.append("Type 'YES' if you understand this and want to continue: ") - msg = '\n'.join(msg) - if not yes(msg, false_msg="Aborting.", invalid_msg='Invalid answer, aborting.', truish=('YES',), - retry=False, env_var_override='BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'): - self.exit_code = EXIT_ERROR - return self.exit_code - if not dry_run: - repository.destroy() - logger.info("Repository deleted.") - if not keep_security_info: - SecurityManager.destroy(repository) - else: - logger.info("Would delete repository.") - logger.info("Would %s security info." % ("keep" if keep_security_info else "delete")) - if not dry_run: - Cache.destroy(repository) - logger.info("Cache deleted.") - else: - logger.info("Would delete cache.") - return self.exit_code - def do_mount(self, args): """Mount archive or an entire repository as a FUSE filesystem""" # Perform these checks before opening the repository and asking for a passphrase. @@ -4062,18 +4047,42 @@ class Archiver: subparser.add_argument('output', metavar='OUTPUT', type=argparse.FileType('wb'), help='Output file') - # borg delete - delete_epilog = process_epilog(""" - This command deletes an archive from the repository or the complete repository. - - Important: When deleting archives, repository disk space is **not** freed until - you run ``borg compact``. + # borg rdelete + rdelete_epilog = process_epilog(""" + This command deletes the complete repository. When you delete a complete repository, the security info and local cache for it (if any) are also deleted. Alternatively, you can delete just the local cache with the ``--cache-only`` option, or keep the security info with the ``--keep-security-info`` option. + Always first use ``--dry-run --list`` to see what would be deleted. + """) + subparser = subparsers.add_parser('rdelete', parents=[common_parser], add_help=False, + description=self.do_rdelete.__doc__, + epilog=rdelete_epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + help='delete repository') + subparser.set_defaults(func=self.do_rdelete) + subparser.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', + help='do not change repository') + subparser.add_argument('--list', dest='output_list', action='store_true', + help='output verbose list of archives') + subparser.add_argument('--force', dest='forced', action='count', default=0, + help='force deletion of corrupted archives, ' + 'use ``--force --force`` in case ``--force`` does not work.') + subparser.add_argument('--cache-only', dest='cache_only', action='store_true', + help='delete only the local cache for the given repository') + subparser.add_argument('--keep-security-info', dest='keep_security_info', action='store_true', + help='keep the local security info when deleting a repository') + + # borg delete + delete_epilog = process_epilog(""" + This command deletes archives from the repository. + + Important: When deleting archives, repository disk space is **not** freed until + you run ``borg compact``. + When in doubt, use ``--dry-run --list`` to see what would be deleted. When using ``--stats``, you will get some statistics about how much data was @@ -4087,9 +4096,7 @@ class Archiver: (for more info on these patterns, see :ref:`borg_patterns`). Note that these two options are mutually exclusive. - To avoid accidentally deleting archives, especially when using glob patterns, - it might be helpful to use the ``--dry-run`` to test out the command without - actually making any changes to the repository. + Always first use ``--dry-run --list`` to see what would be deleted. """) subparser = subparsers.add_parser('delete', parents=[common_parser], add_help=False, description=self.do_delete.__doc__, @@ -4101,6 +4108,8 @@ class Archiver: help='do not change repository') subparser.add_argument('--list', dest='output_list', action='store_true', help='output verbose list of archives') + subparser.add_argument('--consider-checkpoints', action='store_true', dest='consider_checkpoints', + help='consider checkpoint archives for deletion (default: not considered).') subparser.add_argument('-s', '--stats', dest='stats', action='store_true', help='print statistics for the deleted archive') subparser.add_argument('--cache-only', dest='cache_only', action='store_true', @@ -4112,10 +4121,6 @@ class Archiver: help='keep the local security info when deleting a repository') subparser.add_argument('--save-space', dest='save_space', action='store_true', help='work slower, but using less space') - subparser.add_argument('--name', dest='name', metavar='NAME', type=NameSpec, - help='specify the archive name') - subparser.add_argument('archives', metavar='ARCHIVE', nargs='*', - help='archives to delete') define_archive_filters_group(subparser) # borg transfer diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index c5b5c10a5..11587efef 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -710,7 +710,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'init', '--encryption=none') self._set_repository_id(self.repository_path, repository_id) self.assert_equal(repository_id, self._extract_repository_id(self.repository_path)) - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') if self.FORK_DEFAULT: self.cmd(f'--repo={self.repository_location}', 'create', 'test.2', 'input', exit_code=EXIT_ERROR) else: @@ -723,8 +723,8 @@ class ArchiverTestCase(ArchiverTestCaseBase): os.environ['BORG_PASSPHRASE'] = 'passphrase' self.cmd(f'--repo={self.repository_location}_encrypted', 'init', '--encryption=repokey') self.cmd(f'--repo={self.repository_location}_encrypted', 'create', 'test', 'input') - self.cmd(f'--repo={self.repository_location}_unencrypted', 'delete', '--cache-only') - self.cmd(f'--repo={self.repository_location}_encrypted', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}_unencrypted', 'rdelete', '--cache-only') + self.cmd(f'--repo={self.repository_location}_encrypted', 'rdelete', '--cache-only') shutil.rmtree(self.repository_path + '_encrypted') os.rename(self.repository_path + '_unencrypted', self.repository_path + '_encrypted') if self.FORK_DEFAULT: @@ -744,7 +744,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): with environment_variable(BORG_PASSPHRASE=''): self.cmd(f'--repo={self.repository_location}', 'init', '--encryption=repokey') # Delete cache & security database, AKA switch to user perspective - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') shutil.rmtree(self.get_security_dir()) with environment_variable(BORG_PASSPHRASE=None): # This is the part were the user would be tricked, e.g. she assumes that BORG_PASSPHRASE @@ -1276,7 +1276,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_create_no_cache_sync(self): self.create_test_files() self.cmd(f'--repo={self.repository_location}', 'init', '--encryption=repokey') - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') create_json = json.loads(self.cmd(f'--repo={self.repository_location}', 'create', '--no-cache-sync', '--json', '--error', 'test', 'input')) # ignore experimental warning @@ -1284,7 +1284,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): create_stats = create_json['cache']['stats'] info_stats = info_json['cache']['stats'] assert create_stats == info_stats - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') self.cmd(f'--repo={self.repository_location}', 'create', '--no-cache-sync', 'test2', 'input') self.cmd(f'--repo={self.repository_location}', 'rinfo') self.cmd(f'--repo={self.repository_location}', 'check') @@ -1601,9 +1601,9 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'extract', 'test.2', '--dry-run') self.cmd(f'--repo={self.repository_location}', 'delete', '--prefix', 'another_') self.cmd(f'--repo={self.repository_location}', 'delete', '--last', '1') - self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test') self.cmd(f'--repo={self.repository_location}', 'extract', 'test.2', '--dry-run') - output = self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test.2', '--stats') + output = self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test.2', '--stats') self.assert_in('Deleted data:', output) # Make sure all data except the manifest has been deleted with Repository(self.repository_path) as repository: @@ -1615,9 +1615,10 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'create', 'test1', 'input') self.cmd(f'--repo={self.repository_location}', 'create', 'test2', 'input') self.cmd(f'--repo={self.repository_location}', 'create', 'test3', 'input') - self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test1', 'test2') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test1') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test2') self.cmd(f'--repo={self.repository_location}', 'extract', 'test3', '--dry-run') - self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test3') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test3') assert not self.cmd(f'--repo={self.repository_location}', 'rlist') def test_delete_repo(self): @@ -1627,10 +1628,10 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'create', 'test', 'input') self.cmd(f'--repo={self.repository_location}', 'create', 'test.2', 'input') os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'no' - self.cmd(f'--repo={self.repository_location}', 'delete', exit_code=2) + self.cmd(f'--repo={self.repository_location}', 'rdelete', exit_code=2) assert os.path.exists(self.repository_path) os.environ['BORG_DELETE_I_KNOW_WHAT_I_AM_DOING'] = 'YES' - self.cmd(f'--repo={self.repository_location}', 'delete') + self.cmd(f'--repo={self.repository_location}', 'rdelete') # Make sure the repo is gone self.assertFalse(os.path.exists(self.repository_path)) @@ -1647,7 +1648,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): else: assert False # missed the file repository.commit(compact=False) - output = self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test', '--force') + output = self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test', '--force') self.assert_in('deleted archive was corrupted', output) self.cmd(f'--repo={self.repository_location}', 'check', '--repair') output = self.cmd(f'--repo={self.repository_location}', 'rlist') @@ -1662,7 +1663,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): id = archive.metadata.items[0] repository.put(id, b'corrupted items metadata stream chunk') repository.commit(compact=False) - self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test', '--force', '--force') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test', '--force', '--force') self.cmd(f'--repo={self.repository_location}', 'check', '--repair') output = self.cmd(f'--repo={self.repository_location}', 'rlist') self.assert_not_in('test', output) @@ -1831,7 +1832,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_unknown_feature_on_cache_sync(self): self.cmd(f'--repo={self.repository_location}', 'init', '--encryption=repokey') - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') self.add_unknown_feature(Manifest.Operation.READ) self.cmd_raises_unknown_feature([f'--repo={self.repository_location}', 'create', 'test', 'input']) @@ -1861,10 +1862,10 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'create', 'test', 'input') self.add_unknown_feature(Manifest.Operation.DELETE) # delete of an archive raises - self.cmd_raises_unknown_feature([f'--repo={self.repository_location}', 'delete', '--name=test']) + self.cmd_raises_unknown_feature([f'--repo={self.repository_location}', 'delete', '-a', 'test']) self.cmd_raises_unknown_feature([f'--repo={self.repository_location}', 'prune', '--keep-daily=3']) # delete of the whole repository ignores features - self.cmd(f'--repo={self.repository_location}', 'delete') + self.cmd(f'--repo={self.repository_location}', 'rdelete') @unittest.skipUnless(llfuse, 'llfuse not installed') def test_unknown_feature_on_mount(self): @@ -2784,7 +2785,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): verify_uniqueness() self.cmd(f'--repo={self.repository_location}', 'create', 'test.2', 'input') verify_uniqueness() - self.cmd(f'--repo={self.repository_location}', 'delete', '--name=test.2') + self.cmd(f'--repo={self.repository_location}', 'delete', '-a', 'test.2') verify_uniqueness() def test_aes_counter_uniqueness_keyfile(self): @@ -4042,7 +4043,7 @@ class ArchiverCorruptionTestCase(ArchiverTestCaseBase): self.cmd(f'--repo={self.repository_location}', 'create', 'test2', 'input') # Force cache sync, creating archive chunks of test1 and test2 in chunks.archive.d - self.cmd(f'--repo={self.repository_location}', 'delete', '--cache-only') + self.cmd(f'--repo={self.repository_location}', 'rdelete', '--cache-only') self.cmd(f'--repo={self.repository_location}', 'rinfo', '--json') chunks_archive = os.path.join(self.cache_path, 'chunks.archive.d')