Skip to content
This repository has been archived by the owner on Sep 16, 2020. It is now read-only.

[try2] Add shortcut fields for credential inputs #552

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions 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-----
53 changes: 53 additions & 0 deletions 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.
5 changes: 3 additions & 2 deletions tests/test_models_base.py
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tower_cli/cli/resource.py
Expand Up @@ -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)

Expand Down
15 changes: 10 additions & 5 deletions tower_cli/models/base.py
Expand Up @@ -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=====
Expand Down Expand Up @@ -385,18 +388,20 @@ 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)
answer = OrderedDict((('changed', False), ('id', pk)))
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'
Expand Down
3 changes: 2 additions & 1 deletion tower_cli/models/fields.py
Expand Up @@ -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.
Expand All @@ -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

Expand Down
83 changes: 82 additions & 1 deletion tower_cli/resources/credential.py
Expand Up @@ -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):
Expand All @@ -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
4 changes: 2 additions & 2 deletions tower_cli/resources/setting.py
Expand Up @@ -14,7 +14,6 @@

import ast
import json
from distutils.util import strtobool

import click
import six
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions tower_cli/utils/__init__.py
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.

import functools
from distutils.util import strtobool

import click

Expand Down Expand Up @@ -44,3 +45,7 @@ def supports_oauth():
except exceptions.NotFound:
return False
return resp.ok


def str_to_bool(value):
return bool(strtobool(value))