diff --git a/docs/source/cli_ref/examples/data/insecure_private_key b/docs/source/cli_ref/examples/data/insecure_private_key new file mode 100644 index 00000000..059582b8 --- /dev/null +++ b/docs/source/cli_ref/examples/data/insecure_private_key @@ -0,0 +1,13 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUp +wmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ5 +1s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQABAoGAFijko56+qGyN8M0RVyaRAXz++xTqHBLh +3tx4VgMtrQ+WEgCjhoTwo23KMBAuJGSYnRmoBZM3lMfTKevIkAidPExvYCdm5dYq3XToLkkLv5L2 +pIIVOFMDG+KESnAFV7l2c+cnzRMW0+b6f8mR1CJzZuxVLL6Q02fvLi55/mbSYxECQQDeAw6fiIQX +GukBI4eMZZt4nscy2o12KyYner3VpoeE+Np2q+Z3pvAMd/aNzQ/W9WaI+NRfcxUJrmfPwIGm63il +AkEAxCL5HQb2bQr4ByorcMWm/hEP2MZzROV73yF41hPsRC9m66KrheO9HPTJuo3/9s5p+sqGxOlF +L0NDt4SkosjgGwJAFklyR1uZ/wPJjj611cdBcztlPdqoxssQGnh85BzCj/u3WqBpE2vjvyyvyI5k +X6zk7S0ljKtt2jny2+00VsBerQJBAJGC1Mg5Oydo5NwD6BiROrPxGo2bpTbu/fhrT8ebHkTz2epl +U9VQQSQzY1oZMVX8i1m5WUTLPz2yLJIBQVdXqhMCQBGoiuSoSjafUhV7i1cEGpb88h5NBYZzWXGZ +37sJ5QsW+sJyoNde3xH8vdXhzU7eT82D6X/scw9RZz+/6rCJ4p0= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/docs/source/cli_ref/usage/CREDENTIALS.rst b/docs/source/cli_ref/usage/CREDENTIALS.rst new file mode 100644 index 00000000..f3a97cdc --- /dev/null +++ b/docs/source/cli_ref/usage/CREDENTIALS.rst @@ -0,0 +1,53 @@ +.. _cli_ref: + +Credential Management +===================== + +Credential Types and Inputs +----------------------------- + +Starting in Ansible Tower 3.2, credential have types defined by a +related table of credential types. Credential types have a name, +kind, and primary key, and can be referenced uniquely by either the +primary key or combination of (name, kind). + +Data that the credential contains is embedded in the JSON-type +field ``inputs``. The way to create a credential via the type and +inputs pattern is the following: + +:: + + tower-cli credential create --name="new_cred" --inputs="{username: foo, password: bar}" --credential-type="Machine" --organization="Default" + +This method of specifying fields is most congruent with the modern Tower API. + + +Field Shortcuts +--------------- + +There are some drawbacks to specifying fields inside of YAML / JSON content +inside of another field. Shortcuts are offered as a way around those. + +The most important problem this solves is specifying multi-line input +from a file. This example can be ran from the project root: + +:: + + tower-cli credential modify --name="new_cred" --subinput ssh_key_data @docs/source/cli_ref/examples/data/insecure_private_key + tower-cli credential create --name="only_ssh_key" --subinput ssh_key_data @docs/source/cli_ref/examples/data/insecure_private_key --credential-type="Machine" --organization="Default" + +Doing this will put data defined in the file into the `ssh_key_data` key in the +inputs. + +The ``--subinput`` option will also perform some conditional type coercion. +At present time, this only matters for boolean type inputs, allowing actions +like the following. + +:: + + tower-cli credential create --name="tower_cred" --inputs="{host: foo.invalid, username: foo, password: bar}" --credential-type="Ansible Tower" --organization=Default + tower-cli credential modify --name=tower_cred --subinput verify_ssl true + +In both cases, the point of the ``--subinput`` field is that changing one +field will still perserve the others. For instance, toggling the value of +``verify_ssl`` will not change the value of the ``host`` input. diff --git a/tests/test_models_base.py b/tests/test_models_base.py index e9dcbf71..3ee8af9c 100644 --- a/tests/test_models_base.py +++ b/tests/test_models_base.py @@ -471,11 +471,12 @@ def test_write_file_like_object(self): def test_write_with_null_field(self): """Establish that a resource with 'null' field is written.""" with client.test_mode as t: - t.register_json('/foo/42/', {'id': 42, 'name': 'bar', + t.register_json('/foo/42/', {'id': 42, 'name': 'bar', 'inventory': 49, 'description': 'baz'}, method='GET') t.register_json('/foo/42/', {'name': 'bar', 'id': 42, 'inventory': 'null'}, method='PATCH') - self.res.write(42, inventory='null') + r = self.res.write(42, inventory='null') + assert r['changed'] self.assertEqual(json.loads(t.requests[1].body)['inventory'], None) def test_delete_with_pk(self): diff --git a/tower_cli/cli/resource.py b/tower_cli/cli/resource.py index bc6f9bfc..90a69260 100644 --- a/tower_cli/cli/resource.py +++ b/tower_cli/cli/resource.py @@ -362,6 +362,7 @@ def get_command(self, ctx, name): type=field.type, show_default=field.show_default, multiple=field.multiple, + nargs=field.nargs, is_eager=False )(new_method) diff --git a/tower_cli/models/base.py b/tower_cli/models/base.py index 74b785e1..5f87c864 100644 --- a/tower_cli/models/base.py +++ b/tower_cli/models/base.py @@ -318,6 +318,9 @@ def _get_patch_url(self, url, pk): """Overwrite this method to handle specific corner cases to the url passed to PATCH method.""" return url + '%s/' % pk + def update_from_existing(self, new_data, existing_data): + pass + def write(self, pk=None, create_on_missing=False, fail_on_found=False, force_on_exists=True, **kwargs): """ =====API DOCS===== @@ -385,6 +388,13 @@ def write(self, pk=None, create_on_missing=False, fail_on_found=False, force_on_ answer.update(existing_data) return answer + # Reinsert None for special case of null association + for key in kwargs: + if kwargs[key] == 'null': + kwargs[key] = None + + self.update_from_existing(kwargs, existing_data) + # Similarly, if all existing data matches our write parameters, there's no need to do anything. if all([kwargs[k] == existing_data.get(k, None) for k in kwargs.keys()]): debug.log('All provided fields match existing data; do nothing.', header='decision', nl=2) @@ -392,11 +402,6 @@ def write(self, pk=None, create_on_missing=False, fail_on_found=False, force_on_ answer.update(existing_data) return answer - # Reinsert None for special case of null association - for key in kwargs: - if kwargs[key] == 'null': - kwargs[key] = None - # Get the URL and method to use for the write. url = self.endpoint method = 'POST' diff --git a/tower_cli/models/fields.py b/tower_cli/models/fields.py index e21759fb..cf2114b7 100644 --- a/tower_cli/models/fields.py +++ b/tower_cli/models/fields.py @@ -45,7 +45,7 @@ def __init__(self, key=None, type=six.text_type, default=None, display=True, filterable=True, help_text=None, is_option=True, password=False, read_only=False, required=True, show_default=False, unique=False, - multiple=False, no_lookup=False, col_width=None): + multiple=False, nargs=1, no_lookup=False, col_width=None): # Init the name to blank. # What's going on here: This is set by the ResourceMeta metaclass # when the **resource** is instantiated. @@ -67,6 +67,7 @@ def __init__(self, key=None, type=six.text_type, default=None, self.show_default = show_default self.unique = unique self.multiple = multiple + self.nargs = nargs self.no_lookup = no_lookup self.col_width = col_width diff --git a/tower_cli/resources/credential.py b/tower_cli/resources/credential.py index 10fd2b4e..a2c1b87c 100644 --- a/tower_cli/resources/credential.py +++ b/tower_cli/resources/credential.py @@ -13,8 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tower_cli import models +import six +import os + +from tower_cli import models, exceptions as exc, get_resource from tower_cli.cli import types +from tower_cli.utils import debug, str_to_bool class Resource(models.Resource): @@ -32,5 +36,82 @@ class Resource(models.Resource): team = models.Field(display=False, type=types.Related('team'), required=False, no_lookup=True) organization = models.Field(display=False, type=types.Related('organization'), required=False) + # Core functionality credential_type = models.Field(type=types.Related('credential_type')) inputs = models.Field(type=types.StructuredInput(), required=False, display=False) + + # Fields for reverse compatibility + subinput = models.Field( + required=False, nargs=2, multiple=True, display=False, + help_text='A key and value to be combined into the credential inputs JSON data.' + ' Start the value with "@" to obtain from a file.\n' + 'Example: `--subinput ssh_key_data @filename` would apply an SSH private key' + ) + + def update_from_existing(self, kwargs, existing_data): + subinputs = kwargs.pop('subinput', ()) + if not subinputs: + return super(Resource, self).update_from_existing(kwargs, existing_data) + + inputs = {} + if kwargs.get('inputs'): + inputs = kwargs['inputs'].copy() + elif existing_data.get('inputs'): + inputs = existing_data['inputs'].copy() + + ct_pk = None + if existing_data: + ct_pk = existing_data.get('credential_type') + else: + ct_pk = kwargs.get('credential_type') + if not ct_pk: + debug.log('Could not apply subinputs because of unknown credential type') + return super(Resource, self).update_from_existing(kwargs, existing_data) + + ct_res = get_resource('credential_type') + schema = ct_res.get(ct_pk)['inputs'].get('fields', []) + schema_map = {} + for element in schema: + schema_map[element.get('id', '')] = element + + for key, raw_value in subinputs: + if kwargs and kwargs.get(key, {}).get(key): + raise exc.BadRequest( + 'Field {} specified in both --subinput and --inputs.'.format(key) + ) + + # Read from a file if starts with "@" + if raw_value.startswith('@'): + filename = os.path.expanduser(raw_value[1:]) + with open(filename, 'r') as f: + value = f.read() + else: + value = raw_value + + # Type conversion + if key not in schema_map: + debug.log('Credential type inputs:\n{}'.format(schema)) + raise exc.BadRequest( + 'Field {} is not allowed by credential type inputs.'.format(key) + ) + type_str = schema_map[key].get('type', 'string') + + converters = { + 'string': six.text_type, + 'boolean': str_to_bool + } + if type_str not in converters: + raise exc.BadRequest( + 'Credential type {} input {} uses an unrecognized type: {}.'.format( + ct_pk, key, type_str) + ) + converter = converters[type_str] + try: + value = converter(value) + except Exception as e: + raise exc.BadRequest( + 'Field {} in --subinput is not type {} specified by credential type ' + '{} inputs.\n(error: {})'.format(key, type_str, ct_pk, e) + ) + inputs[key] = value + kwargs['inputs'] = inputs diff --git a/tower_cli/resources/setting.py b/tower_cli/resources/setting.py index 572d94e8..05612a08 100644 --- a/tower_cli/resources/setting.py +++ b/tower_cli/resources/setting.py @@ -14,7 +14,6 @@ import ast import json -from distutils.util import strtobool import click import six @@ -23,6 +22,7 @@ from tower_cli.api import client from tower_cli.conf import pop_option from tower_cli.cli import types +from tower_cli.utils import str_to_bool from tower_cli.utils.data_structures import OrderedDict @@ -153,7 +153,7 @@ def coerce_type(self, key, value): if to_type == 'integer': return int(value) elif to_type == 'boolean': - return bool(strtobool(value)) + return str_to_bool(value) elif to_type in ('list', 'nested object'): return ast.literal_eval(value) return value diff --git a/tower_cli/utils/__init__.py b/tower_cli/utils/__init__.py index ab57fcf5..4389f0cd 100644 --- a/tower_cli/utils/__init__.py +++ b/tower_cli/utils/__init__.py @@ -14,6 +14,7 @@ # limitations under the License. import functools +from distutils.util import strtobool import click @@ -44,3 +45,7 @@ def supports_oauth(): except exceptions.NotFound: return False return resp.ok + + +def str_to_bool(value): + return bool(strtobool(value))