Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delete yum artifacts regex #72

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/nexuscli/api/repository/collection.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import json

from nexuscli import exception
from nexuscli import nexus_util
from nexuscli.api.repository import model

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'


Expand Down Expand Up @@ -225,6 +227,49 @@ 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 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
"""
content = nexus_util.groovy_script(SCRIPT_NAME_DELETE_ASSETS)
try:
self._client.scripts.delete(SCRIPT_NAME_DELETE_ASSETS) # in case an older version is present
except:
pass
self._client.scripts.create_if_missing(SCRIPT_NAME_DELETE_ASSETS, content)

# prepare JSON for Groovy:
jsonData = {}
f18m marked this conversation as resolved.
Show resolved Hide resolved
jsonData['repoName']=reponame
jsonData['assetName']=assetName
jsonData['assetMatchType']=assetMatchType.name
jsonData['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)
script_result = json.loads(groovy_returned_json['result']) # this is actually a JSON: convert to Python dict
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe there's a .json method in the requests' response that would be preferable to calling json.loads directly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that ScriptCollection.run() is already doing .json() on the HTTP response from the Nexus. However I think that the response.result field is interpreted as a string and does not handle the case whether that string is another JSON... hence the json.loads()...

if script_result == None or 'assets' not in script_result:
raise exception.NexusClientAPIError(groovy_returned_json)

if 'success' in script_result and script_result['success']==False:
f18m marked this conversation as resolved.
Show resolved Hide resolved
raise exception.NexusClientAPIError(script_result['error'])

assets_list = script_result['assets']
if assets_list == None:
assets_list = []
return assets_list

def create(self, repository):
"""
Creates a Nexus repository with the given format and type.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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 != '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<Asset> 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()
log.info(log_prefix + "Transaction committed successfully")

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()
}
12 changes: 9 additions & 3 deletions src/nexuscli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
nexus3 (list|ls) <repository_path>
nexus3 (upload|up) <from_src> <to_repository> [--flatten] [--norecurse]
nexus3 (download|dl) <from_repository> <to_dst> [--flatten] [--nocache]
nexus3 (delete|del) <repository_path>
nexus3 (delete|del) <repository_path> [--regex|--wildcard] [--force]
nexus3 <subcommand> [<arguments>...]

Options:
Expand All @@ -20,14 +20,20 @@
[default: False]
--norecurse Don't process subdirectories on `nexus3 up` transfers
[default: False]
--regex Intepret what follows the first '/' in the <repository_path>
as a regular expression [default: False]
--wildcard Intepret what follows the first '/' in the <repository_path>
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.
Expand Down
71 changes: 60 additions & 11 deletions src/nexuscli/cli/root_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
import sys
import types

from nexuscli import exception
from nexuscli import nexus_config
from nexuscli import nexus_util
from nexuscli.nexus_client import NexusClient
from nexuscli.cli import errors, util

from nexuscli.nexus_util import AssetMatchOptions
import json
import sys

PLURAL = inflect.engine().plural
YESNO_OPTIONS = {
"true": True, "t": True, "yes": True, "y": True,
"false": False, "f": False, "no": False, "n": False,
}


def _input_yesno(prompt, default):
"""
Prompts for a yes/true/no/false answer.
Expand Down Expand Up @@ -54,7 +57,7 @@ def cmd_login(_, __):

config.dump()

sys.stderr.write(f'\nConfiguration saved to {config.config_file}\n')
sys.stderr.write(f'\nLogged in successfully. Configuration saved to {config.config_file}\n')


def cmd_list(nexus_client, args):
Expand Down Expand Up @@ -137,16 +140,62 @@ def cmd_dl(*args, **kwargs):
return cmd_download(*args, **kwargs)


def cmd_delete(nexus_client, options):
"""Performs ``nexus3 delete``"""
repository_path = options['<repository_path>']
delete_count = nexus_client.delete(repository_path)
def _cmd_del_assets(nexus_client, repoName, assetName, assetMatchOption, doForce):
"""Performs ``nexus3 repository delete_assets``"""

nl = '\n' # see https://stackoverflow.com/questions/44780357/how-to-use-newline-n-in-f-string-to-format-output-in-python-3-6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this being used. It looks pretty hacky anyway, so probably for the best :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right - was used in a previous attempt - deleted now

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually this is used: in 2 print() commands to show the list of deleted files I'm doing: nl.join(assets_list) to get a newline-separated list of files...


if not doForce:
sys.stdout.write(f'Retrieving assets matching {assetMatchOption.name} "{assetName}" from repository "{repoName}"\n')
f18m marked this conversation as resolved.
Show resolved Hide resolved

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:
sys.stdout.write('Found 0 matching assets: aborting delete\n')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the existing message still applies here. The "aborting" wording is a bit confusing as it didn't abort but merely didn't find anything to delete.

Existing message:

    file_word = PLURAL('file', delete_count)
    sys.stderr.write(f'Deleted {delete_count} {file_word}\n')

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I changed that - however I'm not sure why "stderr" is used here... if that's not an error it should probably go in stdout like other messages...

return errors.CliReturnCode.SUCCESS.value

sys.stdout.write(f'Found {len(assets_list)} matching assets:\n{nl.join(assets_list)}\n')
util.input_with_default(
f18m marked this conversation as resolved.
Show resolved Hide resolved
'Press ENTER to confirm deletion', 'ctrl+c to cancel')

assets_list = nexus_client.repositories.delete_assets(repoName, assetName, assetMatchOption, False)
if len(assets_list) == 0:
sys.stdout.write('Found 0 matching assets: aborting delete\n')
return errors.CliReturnCode.SUCCESS.value

sys.stdout.write(f'Deleted {len(assets_list)} matching assets:\n{nl.join(assets_list)}\n')
return errors.CliReturnCode.SUCCESS.value

_cmd_up_down_errors(delete_count, 'delete')

file_word = PLURAL('file', delete_count)
sys.stderr.write(f'Deleted {delete_count} {file_word}\n')
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['<repository_path>'])

if repoDir != None and assetName != None:
# we don't need to keep repoDir separated from the assetName
assetName = repoDir + '/' + assetName
elif repoDir == None and assetName == None:
sys.stderr.write(
f'Invalid <repository_path> provided\n')
return errors.CliReturnCode.INVALID_SUBCOMMAND.value

assetMatch = AssetMatchOptions.EXACT_NAME
if options.get('--wildcard') and options.get('--regex'):
sys.stderr.write(
f'Cannot provide both --regex and --wildcard\n')
f18m marked this conversation as resolved.
Show resolved Hide resolved
return errors.CliReturnCode.INVALID_SUBCOMMAND.value
elif 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):
Expand Down
6 changes: 5 additions & 1 deletion src/nexuscli/cli/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
31 changes: 0 additions & 31 deletions src/nexuscli/nexus_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions src/nexuscli/nexus_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import mmap
import os
import pkg_resources
from enum import Enum

class AssetMatchOptions(Enum):
f18m marked this conversation as resolved.
Show resolved Hide resolved
EXACT_NAME = 1
WILDCARD = 2
REGEX = 3

def _resource_filename(resource_name):
"""wrapper for pkg_resources.resource_filename"""
Expand Down