borg delete -a ARCH_GLOB, borg rdelete

This commit is contained in:
Thomas Waldmann 2022-06-21 02:10:48 +02:00
parent 9e5a8a352f
commit 34b6248d75
2 changed files with 125 additions and 119 deletions

View file

@ -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

View file

@ -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')