diff --git a/b2/_internal/b2v3/rm.py b/b2/_internal/b2v3/rm.py index bd33480a..841dfd72 100644 --- a/b2/_internal/b2v3/rm.py +++ b/b2/_internal/b2v3/rm.py @@ -56,4 +56,5 @@ class Rm(B2URIBucketNFolderNameArgMixin, BaseRm): - **listFiles** - **deleteFiles** + - **bypassGovernance** (if --bypass-governance is used) """ diff --git a/b2/_internal/console_tool.py b/b2/_internal/console_tool.py index 7447958c..7da72e73 100644 --- a/b2/_internal/console_tool.py +++ b/b2/_internal/console_tool.py @@ -1694,7 +1694,7 @@ def _run(self, args): return 0 -class DeleteFileVersion(FileIdAndOptionalFileNameMixin, Command): +class DeleteFileVersionBase(FileIdAndOptionalFileNameMixin, Command): """ Permanently and irrevocably deletes one version of a file. @@ -2535,7 +2535,8 @@ class BaseRm(ThreadsMixin, AbstractLsCommand, metaclass=ABCMeta): example if a file matching a pattern is uploaded during a run of ``rm`` command, it MIGHT be deleted (as "latest") instead of the one present when the ``rm`` run has started. - In order to safely delete a single file version, please use ``delete-file-version``. + If a file is in governance retention mode, and the retention period has not expired, + adding --bypass-governance is required. To list (but not remove) files to be deleted, use ``--dry-run``. You can also list files via ``ls`` command - the listing behaviour is exactly the same. @@ -2617,6 +2618,7 @@ def _run_removal(self, executor: Executor): self.runner.api.delete_file_version, file_version.id_, file_version.file_name, + self.args.bypass_governance, ) with self.mapping_lock: self.futures_mapping[future] = file_version @@ -2649,6 +2651,7 @@ def _removal_done(self, future: Future) -> None: @classmethod def _setup_parser(cls, parser): + add_normalized_argument(parser, '--bypass-governance', action='store_true', default=False) add_normalized_argument(parser, '--dry-run', action='store_true') add_normalized_argument(parser, '--queue-size', @@ -2738,6 +2741,7 @@ class Rm(B2IDOrB2URIMixin, BaseRm): - **listFiles** - **deleteFiles** + - **bypassGovernance** (if --bypass-governance is used) """ @@ -5112,6 +5116,11 @@ class GetDownloadUrlWithAuth(CmdReplacedByMixin, GetDownloadUrlWithAuthBase): replaced_by_cmd = (File, FileUrl) +class DeleteFileVersion(CmdReplacedByMixin, DeleteFileVersionBase): + __doc__ = DeleteFileVersionBase.__doc__ + replaced_by_cmd = Rm + + class ConsoleTool: """ Implements the commands available in the B2 command-line tool diff --git a/changelog.d/+command-delete-file-version.deprecated.md b/changelog.d/+command-delete-file-version.deprecated.md new file mode 100644 index 00000000..39c652db --- /dev/null +++ b/changelog.d/+command-delete-file-version.deprecated.md @@ -0,0 +1 @@ +Deprecated `delete-file-version`, use `rm` instead. Added `--bypass-governance` option to `rm`. \ No newline at end of file diff --git a/test/integration/test_b2_command_line.py b/test/integration/test_b2_command_line.py index 1423edba..c200c113 100755 --- a/test/integration/test_b2_command_line.py +++ b/test/integration/test_b2_command_line.py @@ -412,7 +412,12 @@ def test_basic(b2_tool, bucket_name, sample_file, tmp_path, b2_uri_args): } should_equal(expected_info, file_info['fileInfo']) - b2_tool.should_succeed(['delete-file-version', 'c', first_c_version['fileId']]) + b2_tool.should_succeed( + ['delete-file-version', 'c', first_c_version['fileId']], + expected_stderr_pattern=re.compile( + re.escape('WARNING: `delete-file-version` command is deprecated. Use `rm` instead.') + ) + ) b2_tool.should_succeed( ['ls', *b2_uri_args(bucket_name)], f'^a{os.linesep}b/{os.linesep}c{os.linesep}d{os.linesep}' ) @@ -2392,11 +2397,14 @@ def deleting_locked_files( "ERROR: Access Denied for application key " ) b2_tool.should_succeed([ # master key - 'delete-file-version', - locked_file['fileName'], - locked_file['fileId'], - '--bypass-governance' - ]) + 'delete-file-version', + locked_file['fileName'], + locked_file['fileId'], + '--bypass-governance' + ], expected_stderr_pattern=re.compile(re.escape( + 'WARNING: `delete-file-version` command is deprecated. Use `rm` instead.' + )) + ) locked_file = upload_locked_file(b2_tool, lock_enabled_bucket_name, sample_file) @@ -2414,6 +2422,76 @@ def deleting_locked_files( ], "ERROR: unauthorized for application key with capabilities '") +@pytest.mark.apiver(from_ver=4) +def test_deleting_locked_files_v4(b2_tool, sample_file, schedule_bucket_cleanup): + lock_enabled_bucket_name = b2_tool.generate_bucket_name() + schedule_bucket_cleanup(lock_enabled_bucket_name) + b2_tool.should_succeed( + [ + 'bucket', + 'create', + lock_enabled_bucket_name, + 'allPrivate', + '--file-lock-enabled', + *b2_tool.get_bucket_info_args(), + ], + ) + updated_bucket = b2_tool.should_succeed_json( + [ + 'bucket', + 'update', + lock_enabled_bucket_name, + 'allPrivate', + '--default-retention-mode', + 'governance', + '--default-retention-period', + '1 days', + ], + ) + assert updated_bucket['defaultRetention'] == { + 'mode': 'governance', + 'period': { + 'duration': 1, + 'unit': 'days', + }, + } + + locked_file = upload_locked_file(b2_tool, lock_enabled_bucket_name, sample_file) + b2_tool.should_fail( + [ # master key + 'rm', + f"b2id://{locked_file['fileId']}", + ], + " failed: Access Denied for application key " + ) + b2_tool.should_succeed( + [ # master key + 'rm', + '--bypass-governance' + f"b2id://{locked_file['fileId']}", + ] + ) + + locked_file = upload_locked_file(b2_tool, lock_enabled_bucket_name, sample_file) + + lock_disabled_key_id, lock_disabled_key = make_lock_disabled_key(b2_tool) + b2_tool.should_succeed( + [ + 'account', 'authorize', '--environment', b2_tool.realm, lock_disabled_key_id, + lock_disabled_key + ], + ) + + b2_tool.should_fail( + [ # lock disabled key + 'rm', + '--bypass-governance', + f"b2id://{locked_file['fileId']}", + ], + " failed: unauthorized for application key with capabilities '" + ) + + def test_profile_switch(b2_tool): # this test could be unit, but it adds a lot of complexity because of # necessity to pass mocked B2Api to ConsoleTool; it's much easier to @@ -2864,7 +2942,12 @@ def test_replication_monitoring(b2_tool, bucket_name, sample_file, schedule_buck ) # there is just one file, so clean after itself for faster execution - b2_tool.should_succeed(['delete-file-version', uploaded_a['fileName'], uploaded_a['fileId']]) + b2_tool.should_succeed( + ['delete-file-version', uploaded_a['fileName'], uploaded_a['fileId']], + expected_stderr_pattern=re.compile( + re.escape('WARNING: `delete-file-version` command is deprecated. Use `rm` instead.') + ) + ) # run stats command replication_status_deprecated_pattern = re.compile( diff --git a/test/unit/test_console_tool.py b/test/unit/test_console_tool.py index cb3645df..82f5e13c 100644 --- a/test/unit/test_console_tool.py +++ b/test/unit/test_console_tool.py @@ -994,6 +994,37 @@ def test_bucket_info_from_json(self): expected_json_in_stdout=expected_json, ) + @pytest.mark.apiver(from_ver=4) + def test_rm_fileid_v4(self): + + self._authorize_account() + self._run_command(['bucket', 'create', 'my-bucket', 'allPublic'], 'bucket_0\n', '', 0) + + with TempDir() as temp_dir: + local_file1 = self._make_local_file(temp_dir, 'file1.txt') + # For this test, use a mod time without millis. My mac truncates + # millis and just leaves seconds. + mod_time = 1500111222 + os.utime(local_file1, (mod_time, mod_time)) + self.assertEqual(1500111222, os.path.getmtime(local_file1)) + + # Upload a file + self._run_command( + [ + 'file', 'upload', '--no-progress', 'my-bucket', local_file1, 'file1.txt', + '--cache-control=private, max-age=3600' + ], + remove_version=True, + ) + + # Hide file + self._run_command(['file', 'hide', 'my-bucket', 'file1.txt'],) + + # Delete one file version + self._run_command(['rm', 'b2id://9998']) + # Delete one file version + self._run_command(['rm', 'b2id://9999']) + def test_files(self): self._authorize_account() @@ -1135,14 +1166,20 @@ def test_files(self): expected_json = {"action": "delete", "fileId": "9998", "fileName": "file1.txt"} self._run_command( - ['delete-file-version', 'file1.txt', '9998'], expected_json_in_stdout=expected_json + ['delete-file-version', 'file1.txt', '9998'], + expected_stderr= + 'WARNING: `delete-file-version` command is deprecated. Use `rm` instead.\n', + expected_json_in_stdout=expected_json ) # Delete one file version, not passing the name in expected_json = {"action": "delete", "fileId": "9999", "fileName": "file1.txt"} self._run_command( - ['delete-file-version', '9999'], expected_json_in_stdout=expected_json + ['delete-file-version', '9999'], + expected_stderr= + 'WARNING: `delete-file-version` command is deprecated. Use `rm` instead.\n', + expected_json_in_stdout=expected_json ) def test_files_encrypted(self): @@ -1337,7 +1374,9 @@ def test_files_encrypted(self): self._run_command( ['delete-file-version', 'file1.txt', '9998'], - expected_json_in_stdout=expected_json, + expected_stderr= + 'WARNING: `delete-file-version` command is deprecated. Use `rm` instead.\n', + expected_json_in_stdout=expected_json ) # Delete one file version, not passing the name in @@ -1345,6 +1384,8 @@ def test_files_encrypted(self): self._run_command( ['delete-file-version', '9999'], + expected_stderr= + 'WARNING: `delete-file-version` command is deprecated. Use `rm` instead.\n', expected_json_in_stdout=expected_json, )