diff --git a/awxkit/awxkit/api/pages/page.py b/awxkit/awxkit/api/pages/page.py index f81f9da3e7..8b6df80c75 100644 --- a/awxkit/awxkit/api/pages/page.py +++ b/awxkit/awxkit/api/pages/page.py @@ -201,7 +201,7 @@ class Page(object): text = response.text if len(text) > 1024: text = text[:1024] + '... <<< Truncated >>> ...' - log.warning( + log.debug( "Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) exc_str = "%s (%s) received" % ( diff --git a/awxkit/awxkit/cli/__init__.py b/awxkit/awxkit/cli/__init__.py index cc67ea4d15..51e6607bab 100644 --- a/awxkit/awxkit/cli/__init__.py +++ b/awxkit/awxkit/cli/__init__.py @@ -62,6 +62,9 @@ def run(stdout=sys.stdout, stderr=sys.stderr, argv=[]): allow_unicode=True ) )) + elif cli.get_config('format') == 'human': + sys.stdout.write(e.__class__.__name__) + print('') sys.exit(1) except Exception as e: if cli.verbose: diff --git a/awxkit/awxkit/cli/client.py b/awxkit/awxkit/cli/client.py index 7fa0a1326c..de68801af5 100755 --- a/awxkit/awxkit/cli/client.py +++ b/awxkit/awxkit/cli/client.py @@ -201,7 +201,7 @@ class CLI(object): subparsers.required = True # parse the action from OPTIONS - parser = ResourceOptionsParser(page, self.resource, subparsers) + parser = ResourceOptionsParser(self.v2, page, self.resource, subparsers) if from_sphinx: # Our Sphinx plugin runs `parse_action` for *every* available # resource + action in the API so that it can generate usage diff --git a/awxkit/awxkit/cli/custom.py b/awxkit/awxkit/cli/custom.py index 62e81119c4..557ee40666 100644 --- a/awxkit/awxkit/cli/custom.py +++ b/awxkit/awxkit/cli/custom.py @@ -1,3 +1,5 @@ +import functools + from six import with_metaclass from .stdout import monitor, monitor_workflow @@ -45,8 +47,15 @@ class CustomAction(with_metaclass(CustomActionRegistryMeta)): class Launchable(object): def add_arguments(self, parser, with_pk=True): + from .options import pk_or_name if with_pk: - parser.choices[self.action].add_argument('id', type=int, help='') + parser.choices[self.action].add_argument( + 'id', + type=functools.partial( + pk_or_name, None, self.resource, page=self.page + ), + help='' + ) parser.choices[self.action].add_argument( '--monitor', action='store_true', help='If set, prints stdout of the launched job until it finishes.' @@ -155,7 +164,14 @@ class HasStdout(object): action = 'stdout' def add_arguments(self, parser): - parser.choices['stdout'].add_argument('id', type=int, help='') + from .options import pk_or_name + parser.choices['stdout'].add_argument( + 'id', + type=functools.partial( + pk_or_name, None, self.resource, page=self.page + ), + help='' + ) def perform(self): fmt = 'txt_download' diff --git a/awxkit/awxkit/cli/docs/source/examples.rst b/awxkit/awxkit/cli/docs/source/examples.rst index 85cd0e9006..6f30a37172 100644 --- a/awxkit/awxkit/cli/docs/source/examples.rst +++ b/awxkit/awxkit/cli/docs/source/examples.rst @@ -32,17 +32,15 @@ the output of) a playbook from that repository: .. code:: bash - export TOWER_COLOR=f - INVENTORY_ID=$(awx inventory list --name 'Demo Inventory' -f jq --filter '.results[0].id') - PROJECT_ID=$(awx projects create --wait \ + awx projects create --wait \ --organization 1 --name='Example Project' \ --scm_type git --scm_url 'https://github.com/ansible/ansible-tower-samples' \ - -f jq --filter '.id') - TEMPLATE_ID=$(awx job_templates create \ - --name='Example Job Template' --project $PROJECT_ID \ - --playbook hello_world.yml --inventory $INVENTORY_ID \ - -f jq --filter '.id') - awx job_templates launch $TEMPLATE_ID --monitor + -f human + awx job_templates create \ + --name='Example Job Template' --project 'Example Project' \ + --playbook hello_world.yml --inventory 'Demo Inventory' \ + -f human + awx job_templates launch 'Example Job Template' --monitor -f human Updating a Job Template with Extra Vars --------------------------------------- @@ -51,3 +49,13 @@ Updating a Job Template with Extra Vars awx job_templates modify 1 --extra_vars "@vars.yml" awx job_templates modify 1 --extra_vars "@vars.json" + +Importing an SSH Key +-------------------- + +.. code:: bash + + awx credentials create --credential_type 'Machine' \ + --name 'My SSH Key' --user 'alice' \ + --inputs "{'username': 'server-login', 'ssh_key_data': '@~/.ssh/id_rsa`}" + diff --git a/awxkit/awxkit/cli/options.py b/awxkit/awxkit/cli/options.py index b1dfd72758..1620921c74 100644 --- a/awxkit/awxkit/cli/options.py +++ b/awxkit/awxkit/cli/options.py @@ -1,23 +1,72 @@ import argparse +import functools import json +import os +import re import yaml from distutils.util import strtobool from .custom import CustomAction from .format import add_output_formatting_arguments +from .resource import DEPRECATED_RESOURCES_REVERSE + + +def pk_or_name(v2, model_name, value, page=None): + if isinstance(value, int): + return value + + if re.match(r'^[\d]+$', value): + return int(value) + + identity = 'name' + + if not page: + if not hasattr(v2, model_name): + if model_name in DEPRECATED_RESOURCES_REVERSE: + model_name = DEPRECATED_RESOURCES_REVERSE[model_name] + + if model_name == 'users': + identity = 'username' + elif model_name == 'instances': + model_name = 'hostname' + + if hasattr(v2, model_name): + page = getattr(v2, model_name) + + if page: + results = page.get(**{identity: value}) + if results.count == 1: + return int(results.results[0].id) + if results.count > 1: + raise argparse.ArgumentTypeError( + 'Multiple {0} exist with that {1}. ' + 'To look up an ID, run:\n' + 'awx {0} list --{1} "{2}" -f human'.format( + model_name, identity, value + ) + ) + raise argparse.ArgumentTypeError( + 'Could not find any {0} with that {1}.'.format( + model_name, identity + ) + ) + + return value class ResourceOptionsParser(object): - def __init__(self, page, resource, parser): + def __init__(self, v2, page, resource, parser): """Used to submit an OPTIONS request to the appropriate endpoint and apply the appropriate argparse arguments + :param v2: a awxkit.api.pages.page.TentativePage instance :param page: a awxkit.api.pages.page.TentativePage instance :param resource: a string containing the resource (e.g., jobs) :param parser: an argparse.ArgumentParser object to append new args to """ + self.v2 = v2 self.page = page self.resource = resource self.parser = parser @@ -53,7 +102,11 @@ class ResourceOptionsParser(object): def build_detail_actions(self): for method in ('get', 'modify', 'delete'): parser = self.parser.add_parser(method, help='') - self.parser.choices[method].add_argument('id', type=int, help='') + self.parser.choices[method].add_argument( + 'id', + type=functools.partial(pk_or_name, self.v2, self.resource), + help='the ID (or unique name) of the resource' + ) if method == 'get': add_output_formatting_arguments(parser, {}) @@ -81,15 +134,25 @@ class ResourceOptionsParser(object): def json_or_yaml(v): if v.startswith('@'): - v = open(v[1:]).read() + v = open(os.path.expanduser(v[1:])).read() try: - return json.loads(v) + parsed = json.loads(v) except Exception: try: - return yaml.safe_load(v) + parsed = yaml.safe_load(v) except Exception: raise argparse.ArgumentTypeError("{} is not valid JSON or YAML".format(v)) + for k, v in parsed.items(): + # add support for file reading at top-level JSON keys + # (to make things like SSH key data easier to work with) + if v.startswith('@'): + path = os.path.expanduser(v[1:]) + if os.path.exists(path): + parsed[k] = open(path).read() + + return parsed + def jsonstr(v): return json.dumps(json_or_yaml(v)) @@ -101,7 +164,7 @@ class ResourceOptionsParser(object): 'field': int, 'integer': int, 'boolean': strtobool, - 'id': int, # foreign key + 'id': functools.partial(pk_or_name, self.v2, k), 'json': json_or_yaml, }.get(param['type'], str), } diff --git a/awxkit/test/cli/test_options.py b/awxkit/test/cli/test_options.py index ff514ffb9a..109fc646c3 100644 --- a/awxkit/test/cli/test_options.py +++ b/awxkit/test/cli/test_options.py @@ -42,7 +42,7 @@ class TestOptions(unittest.TestCase): 'POST': {}, } }) - ResourceOptionsParser(page, 'users', self.parser) + ResourceOptionsParser(None, page, 'users', self.parser) assert 'list' in self.parser.choices def test_list_filtering(self): @@ -54,7 +54,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('list', 'POST') assert 'list' in self.parser.choices @@ -71,7 +71,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('list', 'POST') assert 'list' in self.parser.choices @@ -90,7 +90,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('create', 'POST') assert 'create' in self.parser.choices @@ -110,7 +110,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('create', 'POST') assert 'create' in self.parser.choices @@ -126,7 +126,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'job_templates', self.parser) + options = ResourceOptionsParser(None, page, 'job_templates', self.parser) options.build_query_arguments('create', 'POST') assert 'create' in self.parser.choices @@ -142,7 +142,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('create', 'POST') assert 'create' in self.parser.choices @@ -168,7 +168,7 @@ class TestOptions(unittest.TestCase): }, } }) - options = ResourceOptionsParser(page, 'users', self.parser) + options = ResourceOptionsParser(None, page, 'users', self.parser) options.build_query_arguments('create', 'POST') assert 'create' in self.parser.choices @@ -181,13 +181,14 @@ class TestOptions(unittest.TestCase): page = OptionsPage.from_json({ 'actions': {'GET': {}, 'POST': {}} }) - ResourceOptionsParser(page, 'users', self.parser) + ResourceOptionsParser(None, page, 'users', self.parser) assert method in self.parser.choices out = StringIO() self.parser.choices[method].print_help(out) assert 'positional arguments:\n id' in out.getvalue() + class TestSettingsOptions(unittest.TestCase): def setUp(self): @@ -203,7 +204,7 @@ class TestSettingsOptions(unittest.TestCase): } }) page.endpoint = '/settings/all/' - ResourceOptionsParser(page, 'settings', self.parser) + ResourceOptionsParser(None, page, 'settings', self.parser) assert 'list' in self.parser.choices assert 'modify' in self.parser.choices