diff --git a/src/nexuscli/api/repository/collection.py b/src/nexuscli/api/repository/collection.py index fb457c6..1f0a7e9 100755 --- a/src/nexuscli/api/repository/collection.py +++ b/src/nexuscli/api/repository/collection.py @@ -1,13 +1,22 @@ import json from nexuscli import exception +from nexuscli import nexus_util from nexuscli.api.repository import model +from enum import Enum SCRIPT_NAME_CREATE = 'nexus3-cli-repository-create' SCRIPT_NAME_DELETE = 'nexus3-cli-repository-delete' +SCRIPT_NAME_DELETE_ASSETS = 'nexus3-cli-repository-delete-assets' SCRIPT_NAME_GET = 'nexus3-cli-repository-get' +class AssetMatchOptions(Enum): + EXACT_NAME = 1 + WILDCARD = 2 + REGEX = 3 + + def get_repository_class(raw_configuration): """ Given a raw repository configuration, returns its corresponding class. @@ -152,6 +161,7 @@ class RepositoryCollection: must provide this at instantiation or set it before calling any methods that require connectivity to Nexus. """ + def __init__(self, client=None): self._client = client self._repositories_json = None @@ -225,6 +235,60 @@ def delete(self, name): self._client.scripts.create_if_missing(SCRIPT_NAME_DELETE) self._client.scripts.run(SCRIPT_NAME_DELETE, data=name) + def delete_assets(self, reponame, assetName, assetMatchType, dryRun): + """ + Delete assets from a repository through a Groovy script + + :param reponame: name of the repository to delete assets from. + :type reponame: str + :param assetName: name of the asset(s) to delete + :type assetName: str + :param assetMatchType: is the assetName string an exact name, a regex + or a wildcard? + :type assetMatchType: AssetMatchOptions + :param dryRun: do a dry run or delete for real? + :type dryRun: bool + + Returns: + list: assets that have been found and deleted (if dryRun==false) + """ + content = nexus_util.groovy_script(SCRIPT_NAME_DELETE_ASSETS) + try: + # in case an older version is present + self._client.scripts.delete(SCRIPT_NAME_DELETE_ASSETS) + except exception.NexusClientAPIError: + # can't delete the script -- probably it's not there at all (yet) + pass + self._client.scripts.create_if_missing( + SCRIPT_NAME_DELETE_ASSETS, content) + + # prepare JSON for Groovy: + jsonData = { + 'repoName': reponame, + 'assetName': assetName, + 'assetMatchType': assetMatchType.name, + 'dryRun': dryRun + } + groovy_returned_json = self._client.scripts.run( + SCRIPT_NAME_DELETE_ASSETS, data=json.dumps(jsonData)) + + # parse the JSON we got back + if 'result' not in groovy_returned_json: + raise exception.NexusClientAPIError(groovy_returned_json) + + # this is actually a JSON: convert to Python dict + script_result = json.loads(groovy_returned_json['result']) + if script_result is None or 'assets' not in script_result: + raise exception.NexusClientAPIError(groovy_returned_json) + + if not script_result.get('success', False): + raise exception.NexusClientAPIError(script_result['error']) + + assets_list = script_result['assets'] + if assets_list is None: + assets_list = [] + return assets_list + def create(self, repository): """ Creates a Nexus repository with the given format and type. diff --git a/src/nexuscli/api/script/groovy/nexus3-cli-repository-delete-assets.groovy b/src/nexuscli/api/script/groovy/nexus3-cli-repository-delete-assets.groovy new file mode 100644 index 0000000..8ea01b8 --- /dev/null +++ b/src/nexuscli/api/script/groovy/nexus3-cli-repository-delete-assets.groovy @@ -0,0 +1,115 @@ +// Original from: +// https://github.com/hlavki/nexus-scripts +// Modified to include some improvements to +// - logging +// - option to do a "dry run" +// - support for EXACT_NAME, WILDCARD or REGEX matching methods + +import org.sonatype.nexus.repository.storage.Asset +import org.sonatype.nexus.repository.storage.Query +import org.sonatype.nexus.repository.storage.StorageFacet +import org.sonatype.nexus.repository.raw.internal.RawFormat + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper + +def log_prefix = "nexus3-cli GROOVY SCRIPT: " + +// https://gist.github.com/kellyrob99/2d1483828c5de0e41732327ded3ab224 +// https://gist.github.com/emexelem/bcf6b504d81ea9019ad4ab2369006e66 + +def request = new JsonSlurper().parseText(args) +assert request.repoName: 'repoName parameter is required' +assert request.assetName: 'name regular expression parameter is required, format: regexp' +assert request.assetMatchType != null: 'assetMatchType parameter is required' +assert request.assetMatchType == 'EXACT_NAME' || request.assetMatchType == 'WILDCARD' || request.assetMatchType == 'REGEX': 'assetMatchType parameter value is invalid: ${request.assetName}' +assert request.dryRun != null: 'dryRun parameter is required' + +def repo = repository.repositoryManager.get(request.repoName) +if (repo == null) { + log.warn(log_prefix + "Repository ${request.repoName} does not exist") + + def result = JsonOutput.toJson([ + success : false, + error : "Repository '${request.repoName}' does not exist.", + assets : null + ]) + return result +} +else if (!repo.type.toString().equals('hosted')) { + log.warn(log_prefix + "Repository ${request.repoName} has type ${repo.type}; only HOSTED repositories are supported for delete operations.") + + def result = JsonOutput.toJson([ + success : false, + error : "Repository '${request.repoName}' has invalid type '${repo.type}'; expecting an 'hosted' repository.", + assets : null + ]) + return result +} + +log.info(log_prefix + "Valid repository: ${request.repoName}, of type: ${repo.type} and format: ${repo.format}") + +StorageFacet storageFacet = repo.facet(StorageFacet) +def tx = storageFacet.txSupplier().get() + +try { + tx.begin() + + log.info(log_prefix + "Gathering list of assets from repository: ${request.repoName} matching pattern: ${request.assetName} assetMatchType: ${request.assetMatchType}") + Iterable assets + if (request.assetMatchType == 'EXACT_NAME') + assets = tx.findAssets(Query.builder().where('name = ').param(request.assetName).build(), [repo]) + else if (request.assetMatchType == 'WILDCARD') + assets = tx.findAssets(Query.builder().where('name like ').param(request.assetName).build(), [repo]) + else if (request.assetMatchType == 'REGEX') + assets = tx.findAssets(Query.builder().where('name MATCHES ').param(request.assetName).build(), [repo]) + + def urls = assets.collect { "/${repo.name}/${it.name()}" } + + if (request.dryRun == false) { + // add in the transaction a delete command for each asset + assets.each { asset -> + log.info(log_prefix + "Deleting asset ${asset.name()}") + tx.deleteAsset(asset); + + def assetId = asset.componentId() + if (assetId != null) { + def component = tx.findComponent(assetId); + if (component != null) { + log.info(log_prefix + "Deleting component with ID ${assetId} that belongs to asset ${asset.name()}") + tx.deleteComponent(component); + } + } + } + } + + tx.commit() + + numAssets = urls.size() + log.info(log_prefix + "Transaction committed successfully; number of assets matched: ${numAssets}") + + def result = JsonOutput.toJson([ + success : true, + error : "", + assets : urls + ]) + return result + +} catch (all) { + log.warn(log_prefix + "Exception: ${all}") + all.printStackTrace() + log.info(log_prefix + "Rolling back changes...") + tx.rollback() + log.info(log_prefix + "Rollback done.") + + def result = JsonOutput.toJson([ + success : false, + error : "Exception during processing.", + assets : null + ]) + return result + +} finally { + // @todo Fix me! Danger Will Robinson! + tx.close() +} diff --git a/src/nexuscli/cli/__init__.py b/src/nexuscli/cli/__init__.py index a4ab750..33c6c4a 100644 --- a/src/nexuscli/cli/__init__.py +++ b/src/nexuscli/cli/__init__.py @@ -7,7 +7,7 @@ nexus3 (list|ls) nexus3 (upload|up) [--flatten] [--norecurse] nexus3 (download|dl) [--flatten] [--nocache] - nexus3 (delete|del) + nexus3 (delete|del) [--regex|--wildcard] [--force] nexus3 [...] Options: @@ -20,14 +20,23 @@ [default: False] --norecurse Don't process subdirectories on `nexus3 up` transfers [default: False] + --regex Interpret what follows the first '/' in the + as a regular expression + [default: False] + --wildcard Interpret what follows the first '/' in the + as a wildcard expression (wildcard + is '%' symbol but note it will only match artefacts + prefixes or postfixes) [default: False] + --force When deleting, do not ask for confirmation first + [default: False] Commands: login Test login and save credentials to ~/.nexus-cli list List all files within a path in the repository upload Upload file(s) to designated repository download Download an artefact or a directory to local file system - delete Delete artefact(s) from repository - + delete Delete artefact(s) from a repository; optionally use regex or + wildcard expressions to match artefact names Sub-commands: cleanup_policy Cleanup Policy management. repository Repository management. diff --git a/src/nexuscli/cli/root_commands.py b/src/nexuscli/cli/root_commands.py index 52b5b5a..6493acb 100644 --- a/src/nexuscli/cli/root_commands.py +++ b/src/nexuscli/cli/root_commands.py @@ -4,10 +4,11 @@ import sys import types +from nexuscli import exception from nexuscli import nexus_config from nexuscli.nexus_client import NexusClient from nexuscli.cli import errors, util - +from nexuscli.api.repository.collection import AssetMatchOptions PLURAL = inflect.engine().plural YESNO_OPTIONS = { @@ -54,7 +55,8 @@ def cmd_login(_, __): config.dump() - sys.stderr.write(f'\nConfiguration saved to {config.config_file}\n') + sys.stderr.write(f'\nLogged in successfully. ' + f'Configuration saved to {config.config_file}\n') def cmd_list(nexus_client, args): @@ -96,9 +98,9 @@ def cmd_upload(nexus_client, args): sys.stderr.write(f'Uploading {source} to {destination}\n') upload_count = nexus_client.upload( - source, destination, - flatten=args.get('--flatten'), - recurse=(not args.get('--norecurse'))) + source, destination, + flatten=args.get('--flatten'), + recurse=(not args.get('--norecurse'))) _cmd_up_down_errors(upload_count, 'upload') @@ -120,9 +122,9 @@ def cmd_download(nexus_client, args): sys.stderr.write(f'Downloading {source} to {destination}\n') download_count = nexus_client.download( - source, destination, - flatten=args.get('--flatten'), - nocache=args.get('--nocache')) + source, destination, + flatten=args.get('--flatten'), + nocache=args.get('--nocache')) _cmd_up_down_errors(download_count, 'download') @@ -137,18 +139,76 @@ def cmd_dl(*args, **kwargs): return cmd_download(*args, **kwargs) -def cmd_delete(nexus_client, options): - """Performs ``nexus3 delete``""" - repository_path = options[''] - delete_count = nexus_client.delete(repository_path) - - _cmd_up_down_errors(delete_count, 'delete') +def _cmd_del_assets(nexus_client, repoName, assetName, assetMatchOption, + doForce): + """Performs ``nexus3 repository delete_assets``""" + + # see https://stackoverflow.com/questions/44780357/ + # how-to-use-newline-n-in-f-string-to-format-output-in-python-3-6 + nl = '\n' + + if not doForce: + print(f'Retrieving assets matching {assetMatchOption.name} ' + f'"{assetName}" from repository "{repoName}"') + + assets_list = [] + try: + assets_list = nexus_client.repositories.delete_assets( + repoName, assetName, assetMatchOption, True) + except exception.NexusClientAPIError as e: + sys.stderr.write(f'Error while running API: {e}\n') + return errors.CliReturnCode.API_ERROR.value + + if len(assets_list) == 0: + print('Found 0 matching assets: aborting delete') + return errors.CliReturnCode.SUCCESS.value + + print(f'Found {len(assets_list)} matching assets:' + f'\n{nl.join(assets_list)}') + util.input_with_default( + 'Press ENTER to confirm deletion', 'ctrl+c to cancel') + + assets_list = nexus_client.repositories.delete_assets( + repoName, assetName, assetMatchOption, False) + delete_count = len(assets_list) + if delete_count == 0: + file_word = PLURAL('file', delete_count) + sys.stderr.write(f'Deleted {delete_count} {file_word}\n') + return errors.CliReturnCode.SUCCESS.value - file_word = PLURAL('file', delete_count) - sys.stderr.write(f'Deleted {delete_count} {file_word}\n') + print( + f'Deleted {len(assets_list)} matching assets:\n{nl.join(assets_list)}') return errors.CliReturnCode.SUCCESS.value +def cmd_delete(nexus_client, options): + """Performs ``nexus3 repository delete_assets``""" + + [repoName, repoDir, assetName] = nexus_client.split_component_path( + options['']) + + if repoDir is not None and assetName is not None: + # we don't need to keep repoDir separated from the assetName + assetName = repoDir + '/' + assetName + elif repoDir is None or assetName is None: + sys.stderr.write( + f'Invalid provided\n') + return errors.CliReturnCode.INVALID_SUBCOMMAND.value + + assetMatch = AssetMatchOptions.EXACT_NAME + if options.get('--wildcard') and options.get('--regex'): + sys.stderr.write('Cannot provide both --regex and --wildcard\n') + return errors.CliReturnCode.INVALID_SUBCOMMAND.value + + if options.get('--wildcard'): + assetMatch = AssetMatchOptions.WILDCARD + elif options.get('--regex'): + assetMatch = AssetMatchOptions.REGEX + + return _cmd_del_assets(nexus_client, repoName, assetName, + assetMatch, options.get('--force')) + + def cmd_del(*args, **kwargs): """Alias for :func:`cmd_delete`""" return cmd_delete(*args, **kwargs) diff --git a/src/nexuscli/cli/util.py b/src/nexuscli/cli/util.py index 491fb45..9331db4 100644 --- a/src/nexuscli/cli/util.py +++ b/src/nexuscli/cli/util.py @@ -61,7 +61,11 @@ def input_with_default(prompt, default=None): :return: user-provided answer or None, if default not provided. :rtype: Union[str,None] """ - value = input(f'{prompt} ({default}):') + try: + value = input(f'{prompt} ({default}):') + except KeyboardInterrupt: + print('\nInterrupted') + sys.exit(1) if value: return str(value) diff --git a/src/nexuscli/nexus_client.py b/src/nexuscli/nexus_client.py index 7834bee..48a39f4 100755 --- a/src/nexuscli/nexus_client.py +++ b/src/nexuscli/nexus_client.py @@ -605,34 +605,3 @@ def download(self, source, destination, flatten=False, nocache=False): continue return download_count - - def delete(self, repository_path): - """ - Delete artefacts, recursively if ``repository_path`` is a directory. - - :param repository_path: location on the repository service. - :type repository_path: str - :return: number of deleted files. Negative number for errors. - :rtype: int - """ - - delete_count = 0 - death_row = self.list_raw(repository_path) - - death_row = progress.bar([a for a in death_row], label='Deleting') - - for artefact in death_row: - id_ = artefact['id'] - artefact_path = artefact['path'] - - response = self.http_delete(f'assets/{id_}') - LOG.info('Deleted: %s (%s)', artefact_path, id_) - delete_count += 1 - if response.status_code == 404: - LOG.warning('File disappeared while deleting') - LOG.debug(response.reason) - elif response.status_code != 204: - LOG.error(response.reason) - return -1 - - return delete_count diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index f46e10a..777c819 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -69,21 +69,28 @@ def test_download(hosted_raw_repo_empty, deep_file_tree, faker, tmpdir): @pytest.mark.integration -def test_delete(hosted_raw_repo_empty, deep_file_tree, faker): +def test_delete(nexus_client, hosted_raw_repo_empty, deep_file_tree, faker): """Ensure that `nexus3 delete` command works""" src_dir, x_file_set = deep_file_tree - dst_dir = faker.uri_path() + '/' + dst_dir = faker.uri_path() repo_name = hosted_raw_repo_empty - dest_repo_path = '{}/{}/'.format(repo_name, dst_dir) - upload_command = f'nexus3 upload {src_dir} {dest_repo_path}' + dest_repo_path = '{}/{}'.format(repo_name, dst_dir) + upload_command = f'nexus3 upload {src_dir} {dest_repo_path}/' retcode = check_call(upload_command.split()) assert retcode == 0 + # delete all files, one by one: + for file in x_file_set: + delete_command = \ + f'nexus3 delete --force {dest_repo_path}{src_dir}/{file}' + retcode = check_call(delete_command.split()) + assert retcode == 0 + # FIXME: force Nexus 3 to reindex so there's no need to sleep sleep(5) - delete_command = f'nexus3 delete {dest_repo_path}' - retcode = check_call(delete_command.split()) - assert retcode == 0 + # now check that the repo is actually empty: + file_list = list(nexus_client.list(repo_name)) + assert len(file_list) == 0 diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py index 060e419..1b11112 100644 --- a/tests/cli/test_delete.py +++ b/tests/cli/test_delete.py @@ -1,6 +1,7 @@ -import pytest +from nexuscli.api.repository.collection import AssetMatchOptions +# unit test for repository.delete_assets() def test_delete(faker, nexus_mock_client, mocker): """ Given a repository_path and a response from the service, ensure that the @@ -9,24 +10,25 @@ def test_delete(faker, nexus_mock_client, mocker): nexus = nexus_mock_client x_repository = faker.uri_path() x_count = faker.random_int(20, 100) + # list with random count of artefact paths without the leading / x_artefacts = [ faker.file_path( depth=faker.random_int(2, 10))[1:] for _ in range(x_count) ] - # Use list instead of generator so we can inspect contents - raw_response = [ - a for a in pytest.helpers.nexus_raw_response(x_artefacts) - ] - nexus.list_raw = mocker.Mock(return_value=raw_response) - - ResponseMock = pytest.helpers.get_ResponseMock() - nexus.http_delete = mocker.Mock(return_value=ResponseMock(204, 'All OK')) + # patch the function that should run the Groovy script: + nexus.scripts.run = mocker.Mock(return_value={ + 'name': 'nexus3-cli-repository-delete-assets', + 'result': '{"success":true,"error":"","assets":["/reponame/assetname"]}' + }) - # call actual method being tested - delete_count = nexus.delete(x_repository) + matchMode = AssetMatchOptions.EXACT_NAME + for artifact in x_artefacts: + # call actual method being tested + deleted = nexus.repositories.delete_assets(x_repository, artifact, + matchMode, False) + delete_count = len(deleted) + assert delete_count == 1 - assert delete_count == x_count - nexus.list_raw.assert_called_with(x_repository) - nexus.http_delete.assert_called() + nexus.scripts.run.assert_called()