コード例 #1
0
ファイル: test_utils_types.py プロジェクト: tangrm/tower-cli
    def test_variables_file(self):
        """Establish that file with variables is opened in this type."""
        f = types.Variables()
        with mock.patch.object(click.File, 'convert') as convert:
            convert.return_value = "foo: bar"

            foo_converted = f.convert('@foobar.yml', 'myfile', None)

            convert.assert_called_once_with("foobar.yml", 'myfile', None)
            self.assertEqual(foo_converted, 'foo: bar')
コード例 #2
0
ファイル: inventory_script.py プロジェクト: tangrm/tower-cli
class Resource(models.Resource):
    cli_help = 'Manage inventory scripts within Ansible Tower.'
    endpoint = '/inventory_scripts/'

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    script = models.Field(
        type=types.Variables(), display=False,
        help_text='Script code to fetch inventory, prefix with "@" to '
                  'use contents of file for this field.')
    organization = models.Field(type=types.Related('organization'),
                                display=False)
コード例 #3
0
class Resource(models.Resource):
    cli_help = 'Manage inventory within Ansible Tower.'
    endpoint = '/inventories/'
    identity = ('organization', 'name')

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    organization = models.Field(type=types.Related('organization'))
    variables = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        help_text='Inventory variables, use "@" to get from file.')
コード例 #4
0
class Resource(models.Resource):
    cli_help = 'Manage schedules within Ansible Tower.'
    endpoint = '/schedules/'

    # General fields.
    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)

    # Unified jt fields. note these fields will only be used during creation.
    # Plus, one and only one field should be provided.
    job_template = models.Field(type=types.Related('job_template'),
                                required=False,
                                display=False)
    inventory_source = models.Field(type=types.Related('inventory_source'),
                                    required=False,
                                    display=False)
    project = models.Field(type=types.Related('project'),
                           required=False,
                           display=False)

    # Schedule-specific fields.
    unified_job_template = models.Field(required=False,
                                        type=int,
                                        help_text='Integer used to display'
                                        ' unified job template in result, '
                                        'Please don\'t use it for create/'
                                        'modify.')
    enabled = models.Field(required=False,
                           type=click.BOOL,
                           default=True,
                           help_text='Whether this schedule will be used',
                           show_default=True)
    rrule = models.Field(required=False,
                         display=False,
                         help_text='Schedule rules specifications which is'
                         ' less than 255 characters.')
    extra_data = models.Field(type=types.Variables(),
                              required=False,
                              display=False,
                              help_text='Extra data for '
                              'schedule rules in the form of a .json file.')

    def _get_patch_url(self, url, pk):
        urlTokens = url.split('/')
        if len(urlTokens) > 3:
            # reconstruct url to prevent a rare corner case where resources
            # cannot be constructed independently. Open to modification if
            # API convention changes.
            url = '/'.join(urlTokens[:1] + urlTokens[-2:])
        return super(Resource, self)._get_patch_url(url, pk)
コード例 #5
0
ファイル: schedule.py プロジェクト: tangrm/tower-cli
class Resource(models.Resource):
    cli_help = 'Manage schedules within Ansible Tower.'
    endpoint = '/schedules/'

    # General fields.
    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)

    # Unified jt fields. note these fields will only be used during creation.
    # Plus, one and only one field should be provided.
    job_template = models.Field(type=types.Related('job_template'),
                                required=False,
                                display=False)
    inventory_source = models.Field(type=types.Related('inventory_source'),
                                    required=False,
                                    display=False)
    project = models.Field(type=types.Related('project'),
                           required=False,
                           display=False)

    # Schedule-specific fields.
    unified_job_template = models.Field(required=False,
                                        type=int,
                                        help_text='Integer used to display'
                                        ' unified job template in result, '
                                        'Please don\'t use it for create/'
                                        'modify.')
    enabled = models.Field(required=False,
                           type=click.BOOL,
                           default=True,
                           help_text='Whether this schedule will be used',
                           show_default=True)
    rrule = models.Field(required=False,
                         display=False,
                         help_text='Schedule rules specifications which is'
                         ' less than 255 characters.')
    extra_data = models.Field(type=types.Variables(),
                              required=False,
                              display=False,
                              help_text='Extra data for '
                              'schedule rules in the form of a .json file.')
コード例 #6
0
ファイル: host.py プロジェクト: tangrm/tower-cli
class Resource(models.Resource):
    cli_help = 'Manage hosts belonging to a group within an inventory.'
    endpoint = '/hosts/'
    identity = ('inventory', 'name')

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    inventory = models.Field(type=types.Related('inventory'))
    enabled = models.Field(type=bool, required=False)
    variables = models.Field(
        type=types.Variables(), required=False, display=False,
        help_text='Host variables, use "@" to get from file.')

    @resources.command(use_fields_as_options=False)
    @click.option('--host', type=types.Related('host'))
    @click.option('--group', type=types.Related('group'))
    def associate(self, host, group):
        """Associate a group with this host."""
        return self._assoc('groups', host, group)

    @resources.command(use_fields_as_options=False)
    @click.option('--host', type=types.Related('host'))
    @click.option('--group', type=types.Related('group'))
    def disassociate(self, host, group):
        """Disassociate a group from this host."""
        return self._disassoc('groups', host, group)

    @resources.command(ignore_defaults=True, no_args_is_help=False)
    @click.option('--group', type=types.Related('group'),
                  help='List hosts that are children of this group.')
    def list(self, group=None, **kwargs):
        """Return a list of hosts.
        """
        if group:
            kwargs['query'] = (kwargs.get('query', ()) +
                               (('groups__in', group),))
        return super(Resource, self).list(**kwargs)
コード例 #7
0
ファイル: test_utils_types.py プロジェクト: tangrm/tower-cli
 def test_variables_backup_option(self):
     """Establish that non-string input is protected against."""
     f = types.Variables()
     foo_converted = f.convert(54, 'myfile', None)
     self.assertEqual(foo_converted, 54)
コード例 #8
0
ファイル: test_utils_types.py プロジェクト: tangrm/tower-cli
 def test_variables_no_file(self):
     """Establish that plain variables are passed as-is."""
     f = types.Variables()
     foo_converted = f.convert('foo: barz', 'myfile', None)
     self.assertEqual(foo_converted, 'foo: barz')
コード例 #9
0
ファイル: setting.py プロジェクト: samdoran/tower-cli
class Resource(models.Resource):
    cli_help = 'Manage settings within Ansible Tower.'
    custom_category = None

    value = models.Field(required=True, type=types.Variables())

    @resources.command(ignore_defaults=True, no_args_is_help=False)
    @click.option('category',
                  '-c',
                  '--category',
                  help='If set, filter settings by a specific category')
    def list(self, **kwargs):
        """Return a list of objects."""
        self.custom_category = kwargs.get('category', 'all')
        try:
            result = super(Resource, self).list(**kwargs)
        except exc.NotFound as e:
            categories = map(lambda category: category['slug'],
                             client.get('/settings/').json()['results'])
            e.message = '%s is not a valid category.  Choose from [%s]' % (
                kwargs['category'], ', '.join(categories))
            raise e
        finally:
            self.custom_category = None
        return {'results': [{'id': k, 'value': v} for k, v in result.items()]}

    @resources.command(use_fields_as_options=False)
    def get(self, pk):
        """Return one and exactly one object"""
        # The Tower API doesn't provide a mechanism for retrieving a single
        # setting value at a time, so fetch them all and filter
        try:
            return next(s for s in self.list()['results'] if s['id'] == pk)
        except StopIteration:
            raise exc.NotFound('The requested object could not be found.')

    @resources.command(use_fields_as_options=False)
    @click.argument('setting')
    @click.argument('value',
                    default=None,
                    required=False,
                    type=types.Variables())
    def modify(self, setting, value):
        """Modify an already existing object."""
        prev_value = new_value = self.get(setting)['value']
        answer = OrderedDict()
        encrypted = '$encrypted$' in six.text_type(prev_value)

        if encrypted or six.text_type(prev_value) != six.text_type(value):
            if setting == 'LICENSE':
                r = client.post('/config/',
                                data=self.coerce_type(setting, value))
                new_value = r.json()
            else:
                r = client.patch(
                    self.endpoint,
                    data={setting: self.coerce_type(setting, value)})
                new_value = r.json()[setting]
            answer.update(r.json())

        changed = encrypted or (prev_value != new_value)

        answer.update({
            'changed': changed,
            'id': setting,
            'value': new_value,
        })
        return answer

    @property
    def endpoint(self):
        return '/settings/%s/' % (self.custom_category or 'all')

    def coerce_type(self, key, value):
        if key == 'LICENSE':
            return json.loads(value)
        r = client.options(self.endpoint)
        to_type = r.json()['actions']['PUT'].get(key, {}).get('type')
        if to_type == 'integer':
            return int(value)
        elif to_type == 'boolean':
            return bool(strtobool(value))
        elif to_type in ('list', 'nested object'):
            return ast.literal_eval(value)
        return value

    def __getattribute__(self, name):
        """Disable inherited methods that cannot be applied to this
        particular resource.
        """
        if name in ['create', 'delete']:
            raise AttributeError
        else:
            return object.__getattribute__(self, name)
コード例 #10
0
class Resource(models.SurveyResource):
    cli_help = 'Manage job templates.'
    endpoint = '/job_templates/'

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    job_type = models.Field(
        required=False,
        display=False,
        type=click.Choice(['run', 'check', 'scan']),
    )
    inventory = models.Field(type=types.Related('inventory'), required=False)
    project = models.Field(type=types.Related('project'))
    playbook = models.Field()
    machine_credential = models.Field(
        'credential',
        display=False,
        required=False,
        type=types.Related('credential'),
    )
    cloud_credential = models.Field(type=types.Related('credential'),
                                    required=False,
                                    display=False)
    network_credential = models.Field(type=types.Related('credential'),
                                      required=False,
                                      display=False)
    forks = models.Field(type=int, required=False, display=False)
    limit = models.Field(required=False, display=False)
    verbosity = models.Field(
        display=False,
        type=types.MappedChoice([
            (0, 'default'),
            (1, 'verbose'),
            (2, 'more_verbose'),
            (3, 'debug'),
            (4, 'connection'),
            (5, 'winrm'),
        ]),
        required=False,
    )
    job_tags = models.Field(required=False, display=False)
    skip_tags = models.Field(required=False, display=False)
    extra_vars = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        multiple=True,
        help_text='Extra variables used by Ansible in YAML or key=value '
        'format. Use @ to get YAML from a file.')
    host_config_key = models.Field(
        required=False,
        display=False,
        help_text='Allow Provisioning Callbacks using this host config key')
    ask_variables_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for extra_vars on launch.')
    ask_limit_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for host limits on launch.')
    ask_tags_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for job tags on launch.')
    ask_skip_tags_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for tags to skip on launch.')
    ask_job_type_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for job type on launch.')
    ask_inventory_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for inventory on launch.')
    ask_credential_on_launch = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for machine credential on launch.')
    become_enabled = models.Field(type=bool, required=False, display=False)
    timeout = models.Field(type=int,
                           required=False,
                           display=False,
                           help_text='The timeout field (in seconds).')
    survey_enabled = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for job type on launch.')
    survey_spec = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        help_text='On write commands, perform extra POST to the '
        'survey_spec endpoint.')

    @resources.command
    def create(self,
               fail_on_found=False,
               force_on_exists=False,
               extra_vars=None,
               **kwargs):
        """Create a job template."""
        # Provide a default value for job_type, but only in creation of JT
        if not kwargs.get('job_type', False):
            kwargs['job_type'] = 'run'
        return super(Resource, self).create(fail_on_found=fail_on_found,
                                            force_on_exists=force_on_exists,
                                            **kwargs)

    @resources.command(use_fields_as_options=False)
    @click.option('--job-template', type=types.Related('job_template'))
    @click.option('--label', type=types.Related('label'))
    def associate_label(self, job_template, label):
        """Associate an label with this job template."""
        return self._assoc('labels', job_template, label)

    @resources.command(use_fields_as_options=False)
    @click.option('--job-template', type=types.Related('job_template'))
    @click.option('--label', type=types.Related('label'))
    def disassociate_label(self, job_template, label):
        """Disassociate an label from this job template."""
        return self._disassoc('labels', job_template, label)

    @resources.command(use_fields_as_options=False)
    @click.option('--job-template', type=types.Related('job_template'))
    @click.option('--notification-template',
                  type=types.Related('notification_template'))
    @click.option('--status',
                  type=click.Choice(['any', 'error', 'success']),
                  required=False,
                  default='any',
                  help='Specify job run status'
                  ' of job template to relate to.')
    def associate_notification_template(self, job_template,
                                        notification_template, status):
        """Associate a notification template from this job template."""
        return self._assoc('notification_templates_%s' % status, job_template,
                           notification_template)

    @resources.command(use_fields_as_options=False)
    @click.option('--job-template', type=types.Related('job_template'))
    @click.option('--notification-template',
                  type=types.Related('notification_template'))
    @click.option('--status',
                  type=click.Choice(['any', 'error', 'success']),
                  required=False,
                  default='any',
                  help='Specify job run status'
                  ' of job template to relate to.')
    def disassociate_notification_template(self, job_template,
                                           notification_template, status):
        """Disassociate a notification template from this job template."""
        return self._disassoc('notification_templates_%s' % status,
                              job_template, notification_template)
コード例 #11
0
ファイル: workflow.py プロジェクト: samdoran/tower-cli
class Resource(models.SurveyResource):
    cli_help = 'Manage workflow job templates.'
    endpoint = '/workflow_job_templates/'
    unified_job_type = '/workflow_jobs/'

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    extra_vars = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        multiple=True,
        help_text='Extra variables used by Ansible in YAML or key=value '
        'format. Use @ to get YAML from a file. Use the option '
        'multiple times to add multiple extra variables')
    organization = models.Field(type=types.Related('organization'),
                                required=False)
    survey_enabled = models.Field(
        type=bool,
        required=False,
        display=False,
        help_text='Prompt user for job type on launch.')
    survey_spec = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        help_text='On write commands, perform extra POST to the '
        'survey_spec endpoint.')

    @staticmethod
    def _workflow_node_structure(node_results):
        '''
        Takes the list results from the API in `node_results` and
        translates this data into a dictionary organized in a
        human-readable heirarchial structure
        '''
        # Build list address translation, and create backlink lists
        node_list_pos = {}
        for i, node_result in enumerate(node_results):
            for rel in ['success', 'failure', 'always']:
                node_result['{0}_backlinks'.format(rel)] = []
            node_list_pos[node_result['id']] = i

        # Populate backlink lists
        for node_result in node_results:
            for rel in ['success', 'failure', 'always']:
                for sub_node_id in node_result['{0}_nodes'.format(rel)]:
                    j = node_list_pos[sub_node_id]
                    node_results[j]['{0}_backlinks'.format(rel)].append(
                        node_result['id'])

        # Find the root nodes
        root_nodes = []
        for node_result in node_results:
            is_root = True
            for rel in ['success', 'failure', 'always']:
                if node_result['{0}_backlinks'.format(rel)] != []:
                    is_root = False
                    break
            if is_root:
                root_nodes.append(node_result['id'])

        # Create network dictionary recursively from root nodes
        def branch_schema(node_id):
            i = node_list_pos[node_id]
            node_dict = node_results[i]
            ret_dict = {}
            for fd in NODE_STANDARD_FIELDS:
                val = node_dict.get(fd, None)
                if val is not None:
                    if fd == 'unified_job_template':
                        job_type = node_dict['summary_fields'][
                            'unified_job_template']['unified_job_type']
                        ujt_key = JOB_TYPES[job_type]
                        ret_dict[ujt_key] = val
                    else:
                        ret_dict[fd] = val
                for rel in ['success', 'failure', 'always']:
                    sub_node_id_list = node_dict['{0}_nodes'.format(rel)]
                    if len(sub_node_id_list) == 0:
                        continue
                    relationship_name = '{0}_nodes'.format(rel)
                    ret_dict[relationship_name] = []
                    for sub_node_id in sub_node_id_list:
                        ret_dict[relationship_name].append(
                            branch_schema(sub_node_id))
            return ret_dict

        schema_dict = []
        for root_node_id in root_nodes:
            schema_dict.append(branch_schema(root_node_id))
        return schema_dict

    def _get_schema(self, wfjt_id):
        """
        Returns a dictionary that represents the node network of the
        workflow job template
        """
        node_res = get_resource('node')
        node_results = node_res.list(workflow_job_template=wfjt_id,
                                     all_pages=True)['results']
        return self._workflow_node_structure(node_results)

    @resources.command(use_fields_as_options=False)
    @click.argument('wfjt', type=types.Related('workflow'))
    @click.argument('node_network', type=types.Variables(), required=False)
    def schema(self, wfjt, node_network=None):
        """
        Convert YAML/JSON content into workflow node objects if
        node_network param is given.
        If not, print a YAML representation of the node network.
        """
        if node_network is None:
            if settings.format == 'human':
                settings.format = 'yaml'
            return self._get_schema(wfjt)

        node_res = get_resource('node')

        def create_node(node_branch, parent, relationship):
            # Create node with data specified by top-level keys
            create_data = {}
            FK_FIELDS = JOB_TYPES.values() + ['inventory', 'credential']
            for fd in NODE_STANDARD_FIELDS + JOB_TYPES.values():
                if fd in node_branch:
                    if (fd in FK_FIELDS
                            and not isinstance(node_branch[fd], int)):
                        # Node's template was given by name, do lookup
                        ujt_res = get_resource(fd)
                        ujt_data = ujt_res.get(name=node_branch[fd])
                        create_data[fd] = ujt_data['id']
                    else:
                        create_data[fd] = node_branch[fd]
            create_data['workflow_job_template'] = wfjt
            return node_res._get_or_create_child(parent, relationship,
                                                 **create_data)

        def get_adj_list(node_branch):
            ret = {}
            for fd in node_branch:
                for rel in ['success', 'failure', 'always']:
                    if fd.startswith(rel):
                        sub_branch_list = node_branch[fd]
                        if not isinstance(sub_branch_list, list):
                            raise BadRequest(
                                'Sublists in spec must be lists.'
                                'Encountered in {0} at {1}'.format(
                                    fd, sub_branch_list))
                        ret[rel] = sub_branch_list
                        break
            return ret

        def create_node_recursive(node_network):
            queue = deque()
            id_queue = deque()
            for base_node in node_network:
                queue.append(base_node)
                id_queue.append(create_node(base_node, None, None)['id'])
                while (len(queue) != 0):
                    to_expand = queue.popleft()
                    parent_id = id_queue.popleft()
                    adj_list = get_adj_list(to_expand)
                    for rel in adj_list:
                        for sub_node in adj_list[rel]:
                            id_queue.append(
                                create_node(sub_node, parent_id, rel)['id'])
                            queue.append(sub_node)
                            node_res._assoc(node_res._forward_rel_name(rel),
                                            parent_id, id_queue[-1])

        if hasattr(node_network, 'read'):
            node_network = node_network.read()
        node_network = string_to_dict(node_network,
                                      allow_kv=False,
                                      require_dict=False)

        create_node_recursive(node_network)

        if settings.format == 'human':
            settings.format = 'yaml'
        return self._get_schema(wfjt)
コード例 #12
0
ファイル: workflow_job.py プロジェクト: samdoran/tower-cli
class Resource(models.ExeResource):
    cli_help = 'Launch or monitor workflow jobs.'
    endpoint = '/workflow_jobs/'

    workflow_job_template = models.Field(key='-W',
                                         type=types.Related('workflow'),
                                         display=True)
    extra_vars = models.Field(type=types.Variables(),
                              required=False,
                              display=False,
                              multiple=True)
    created = models.Field(required=False, display=True)
    status = models.Field(required=False, display=True)

    def __getattribute__(self, attr):
        """Alias the stdout to `summary` specially for workflow"""
        if attr == 'summary':
            return object.__getattribute__(self, 'stdout')
        elif attr == 'stdout':
            raise AttributeError
        return super(Resource, self).__getattribute__(attr)

    def lookup_stdout(self,
                      pk=None,
                      start_line=None,
                      end_line=None,
                      full=True):
        """
        Internal method that lies to our `monitor` method by returning
        a scorecard for the workflow job where the standard out
        would have been expected.
        """
        uj_res = get_resource('unified_job')
        # Filters
        #  - limit search to jobs spawned as part of this workflow job
        #  - order in the order in which they should add to the list
        #  - only include final job states
        query_params = (('unified_job_node__workflow_job', pk),
                        ('order_by', 'finished'), ('status__in',
                                                   'successful,failed,error'))
        jobs_list = uj_res.list(all_pages=True, query=query_params)
        if jobs_list['count'] == 0:
            return ''

        return_content = uj_res.as_command()._format_human(jobs_list)
        lines = return_content.split('\n')
        if not full:
            lines = lines[:-1]

        N = len(lines)
        start_range = start_line
        if start_line is None:
            start_range = 0
        elif start_line > N:
            start_range = N

        end_range = end_line
        if end_line is None or end_line > N:
            end_range = N

        lines = lines[start_range:end_range]
        return_content = '\n'.join(lines)
        if len(lines) > 0:
            return_content += '\n'

        return return_content

    @resources.command
    def summary(self):
        """Placeholder to get swapped out for `stdout`."""
        pass

    @resources.command(use_fields_as_options=('workflow_job_template',
                                              'extra_vars'))
    @click.option('--monitor',
                  is_flag=True,
                  default=False,
                  help='If used, immediately calls monitor on the newly '
                  'launched workflow job rather than exiting.')
    @click.option('--wait',
                  is_flag=True,
                  default=False,
                  help='Wait until completion to exit, displaying '
                  'placeholder text while in progress.')
    @click.option('--timeout',
                  required=False,
                  type=int,
                  help='If provided with --monitor, this command (not the job)'
                  ' will time out after the given number of seconds. '
                  'Does nothing if --monitor is not sent.')
    def launch(self,
               workflow_job_template=None,
               monitor=False,
               wait=False,
               timeout=None,
               extra_vars=None,
               **kwargs):
        """Launch a new workflow job based on a workflow job template.

        Creates a new workflow job in Ansible Tower, starts it, and
        returns back an ID in order for its status to be monitored.
        """
        if len(extra_vars) > 0:
            kwargs['extra_vars'] = parser.process_extra_vars(extra_vars)

        debug.log('Launching the workflow job.', header='details')
        self._pop_none(kwargs)
        post_response = client.post(
            'workflow_job_templates/{}/launch/'.format(workflow_job_template),
            data=kwargs).json()

        workflow_job_id = post_response['id']
        post_response['changed'] = True

        if monitor:
            return self.monitor(workflow_job_id, timeout=timeout)
        elif wait:
            return self.wait(workflow_job_id, timeout=timeout)

        return post_response
コード例 #13
0
ファイル: group.py プロジェクト: samdoran/tower-cli
class Resource(models.Resource):
    cli_help = 'Manage groups belonging to an inventory.'
    endpoint = '/groups/'
    identity = ('inventory', 'name')

    name = models.Field(unique=True)
    description = models.Field(required=False, display=False)
    inventory = models.Field(type=types.Related('inventory'))
    variables = models.Field(
        type=types.Variables(),
        required=False,
        display=False,
        help_text='Group variables, use "@" to get from file.')

    def lookup_with_inventory(self, group, inventory=None):
        group_res = get_resource('group')
        if isinstance(group, int) or group.isdigit():
            return group_res.get(int(group))
        else:
            return group_res.get(name=group, inventory=inventory)

    def set_child_endpoint(self, parent, inventory=None):
        parent_data = self.lookup_with_inventory(parent, inventory)
        self.endpoint = '/groups/' + str(parent_data['id']) + '/children/'
        return parent_data

    # Basic options for the source
    @resources.command
    @click.option('--credential',
                  type=types.Related('credential'),
                  required=False,
                  help='The cloud credential to use.')
    @click.option('--source',
                  type=click.Choice(INVENTORY_SOURCES),
                  default='manual',
                  help='The source to use for this group.')
    @click.option('--source-regions', help='Regions for your cloud provider.')
    # Options may not be valid for certain types of cloud servers
    @click.option('--source-vars',
                  help='Override variables found on source '
                  'with variables defined in this field.')
    @click.option('--instance-filters',
                  help='A comma-separated list of '
                  'filter expressions for matching hosts to be imported to '
                  'Tower.')
    @click.option('--group-by',
                  help='Limit groups automatically created from'
                  ' inventory source.')
    @click.option('--source-script',
                  type=types.Related('inventory_script'),
                  help='Inventory script to be used when group type is '
                  '"custom".')
    @click.option('--overwrite',
                  type=bool,
                  help='Delete child groups and hosts not found in source.')
    @click.option('--overwrite-vars',
                  type=bool,
                  help='Override vars in child groups and hosts with those '
                  'from the external source.')
    @click.option('--update-on-launch',
                  type=bool,
                  help='Refresh inventory '
                  'data from its source each time a job is run.')
    @click.option('--parent', help='Parent group to nest this one inside of.')
    @click.option('--job-timeout',
                  type=int,
                  help='Timeout value (in seconds) '
                  'for underlying inventory source.')
    def create(self, fail_on_found=False, force_on_exists=False, **kwargs):
        """Create a group and, if necessary, modify the inventory source within
        the group.
        """
        group_fields = [f.name for f in self.fields]
        if kwargs.get('parent', None):
            parent_data = self.set_child_endpoint(parent=kwargs['parent'],
                                                  inventory=kwargs.get(
                                                      'inventory', None))
            kwargs['inventory'] = parent_data['inventory']
            group_fields.append('group')
        elif 'inventory' not in kwargs:
            raise exc.UsageError('To create a group, you must provide a '
                                 'parent inventory or parent group.')

        # Break out the options for the group vs its inventory_source
        is_kwargs = {}
        for field in kwargs.copy():
            if field not in group_fields:
                if field == 'job_timeout':
                    is_kwargs['timeout'] = kwargs.pop(field)
                else:
                    is_kwargs[field] = kwargs.pop(field)

        # Handle alias for "manual" source
        if is_kwargs.get('source', None) == 'manual':
            is_kwargs.pop('source')

        # First, create the group.
        answer = super(Resource, self).create(fail_on_found=fail_on_found,
                                              force_on_exists=force_on_exists,
                                              **kwargs)

        # If the group already exists and we aren't supposed to make changes,
        # then we're done.
        if not force_on_exists and not answer['changed']:
            return answer

        # Sanity check: A group was created, but do we need to do anything
        # with the inventory source at all? If no credential or source
        # was specified, then we'd just be updating the inventory source
        # with an effective no-op.
        if len(is_kwargs) == 0:
            return answer

        # Get the inventory source ID ("isid").
        # Inventory sources are not created directly; rather, one was created
        # automatically when the group was created.
        isid = self._get_inventory_source_id(answer)

        # We now have our inventory source ID; modify it according to the
        # provided parameters.
        isrc = get_resource('inventory_source')
        is_answer = isrc.write(pk=isid, force_on_exists=True, **is_kwargs)

        # If either the inventory_source or the group objects were modified
        # then refelect this in the output to avoid confusing the user.
        if is_answer['changed']:
            answer['changed'] = True
        return answer

    @resources.command
    @click.option('--credential',
                  type=types.Related('credential'),
                  required=False,
                  help='The cloud credential to use.')
    @click.option('--source',
                  type=click.Choice(INVENTORY_SOURCES),
                  help='The source to use for this group.')
    @click.option('--source-regions', help='Regions for your cloud provider.')
    # Options may not be valid for certain types of cloud servers
    @click.option('--source-vars',
                  help='Override variables found on source '
                  'with variables defined in this field.')
    @click.option('--instance-filters',
                  help='A comma-separated list of '
                  'filter expressions for matching hosts to be imported to '
                  'Tower.')
    @click.option('--group-by',
                  help='Limit groups automatically created from'
                  ' inventory source.')
    @click.option('--source-script',
                  type=types.Related('inventory_script'),
                  help='Inventory script to be used when group type is '
                  '"custom".')
    @click.option('--overwrite',
                  type=bool,
                  help='Delete child groups and hosts not found in source.')
    @click.option('--overwrite-vars',
                  type=bool,
                  help='Override vars in child groups and hosts with those '
                  'from the external source.')
    @click.option('--update-on-launch',
                  type=bool,
                  help='Refersh inventory '
                  'data from its source each time a job is run.')
    @click.option('--job-timeout',
                  type=int,
                  help='Timeout value (in seconds) '
                  'for underlying inventory source.')
    def modify(self, pk=None, create_on_missing=False, **kwargs):
        """Modify a group and, if necessary, the inventory source within
        the group.
        """
        # Break out the options for the group vs its inventory_source
        group_fields = [f.name for f in self.fields]
        is_kwargs = {}
        for field in kwargs.copy():
            if field not in group_fields:
                if field == 'job_timeout':
                    is_kwargs['timeout'] = kwargs.pop(field)
                else:
                    is_kwargs[field] = kwargs.pop(field)

        # Handle alias for "manual" source
        if is_kwargs.get('source', None) == 'manual':
            is_kwargs['source'] = ''

        # First, modify the group.
        answer = super(Resource,
                       self).modify(pk=pk,
                                    create_on_missing=create_on_missing,
                                    **kwargs)

        # If the group already exists and we aren't supposed to make changes,
        # then we're done.
        if len(is_kwargs) == 0:
            return answer

        # Get the inventory source ID ("isid").
        # Inventory sources are not created directly; rather, one was created
        # automatically when the group was created.
        isid = self._get_inventory_source_id(answer)

        # We now have our inventory source ID; modify it according to the
        # provided parameters.
        #
        # Note: Any fields that were part of the group modification need
        # to be expunged from kwargs before making this call.
        isrc = get_resource('inventory_source')
        is_answer = isrc.write(pk=isid, force_on_exists=True, **is_kwargs)

        # If either the inventory_source or the group objects were modified
        # then refelect this in the output to avoid confusing the user.
        if is_answer['changed']:
            answer['changed'] = True
        return answer

    @resources.command(ignore_defaults=True, no_args_is_help=False)
    @click.option('--root',
                  is_flag=True,
                  default=False,
                  help='Show only root groups (groups with no parent groups) '
                  'within the given inventory.')
    @click.option('--parent', help='Parent group to nest this one inside of.')
    def list(self, root=False, **kwargs):
        """Return a list of groups."""

        # Option to list children of a parent group
        if kwargs.get('parent', None):
            self.set_child_endpoint(parent=kwargs['parent'],
                                    inventory=kwargs.get('inventory', None))
            kwargs.pop('parent')

        # Sanity check: If we got `--root` and no inventory, that's an
        # error.
        if root and not kwargs.get('inventory', None):
            raise exc.UsageError('The --root option requires specifying an '
                                 'inventory also.')

        # If we are tasked with getting root groups, do that.
        if root:
            inventory_id = kwargs['inventory']
            r = client.get('/inventories/%d/root_groups/' % inventory_id)
            return r.json()

        # Return the superclass implementation.
        return super(Resource, self).list(**kwargs)

    @click.argument('group', required=False, type=types.Related('group'))
    @click.option('--monitor',
                  is_flag=True,
                  default=False,
                  help='If sent, immediately calls `monitor` on the newly '
                  'launched job rather than exiting with a success.')
    @click.option('--wait',
                  is_flag=True,
                  default=False,
                  help='Polls server for status, exists when finished.')
    @click.option('--timeout',
                  required=False,
                  type=int,
                  help='If provided with --monitor, this command (not the job)'
                  ' will time out after the given number of seconds. '
                  'Does nothing if --monitor is not sent.')
    @resources.command(no_args_is_help=True)
    def sync(self, group, monitor=False, wait=False, timeout=None, **kwargs):
        """Update the given group's inventory source."""

        isrc = get_resource('inventory_source')
        isid = self._get_inventory_source_id(group, kwargs)
        return isrc.update(isid,
                           monitor=monitor,
                           timeout=timeout,
                           wait=wait,
                           **kwargs)

    @resources.command
    @click.argument('group', required=False, type=types.Related('group'))
    @click.option('--start-line',
                  required=False,
                  type=int,
                  help='Line at which to start printing the standard out.')
    @click.option('--end-line',
                  required=False,
                  type=int,
                  help='Line at which to end printing the standard out.')
    def stdout(self, group, start_line=None, end_line=None, **kwargs):
        """Print the standard out of the last group update."""

        isrc = get_resource('inventory_source')
        isid = self._get_inventory_source_id(group, kwargs)
        return isrc.stdout(isid)

    @resources.command(use_fields_as_options=False)
    @click.option('--group', help='The group to move.')
    @click.option('--parent', help='Destination group to move into.')
    @click.option('--inventory', type=types.Related('inventory'))
    def associate(self, group, parent, **kwargs):
        """Associate this group with the specified group."""
        parent_id = self.lookup_with_inventory(parent,
                                               kwargs.get('inventory',
                                                          None))['id']
        group_id = self.lookup_with_inventory(group,
                                              kwargs.get('inventory',
                                                         None))['id']
        return self._assoc('children', parent_id, group_id)

    @resources.command(use_fields_as_options=False)
    @click.option('--group', help='The group to move.')
    @click.option('--parent', help='Destination group to move into.')
    @click.option('--inventory', type=types.Related('inventory'))
    def disassociate(self, group, parent, **kwargs):
        """Disassociate this group from the specified group."""
        parent_id = self.lookup_with_inventory(parent,
                                               kwargs.get('inventory',
                                                          None))['id']
        group_id = self.lookup_with_inventory(group,
                                              kwargs.get('inventory',
                                                         None))['id']
        return self._disassoc('children', parent_id, group_id)

    def _get_inventory_source_id(self, group, data=None):
        """Return the inventory source ID given a group dictionary returned
        from the Tower API. Alternatively, get it from a group's identity
        set in data.
        """
        if group is None:
            group = self.get(**data)
        # If we got a group ID rather than a group, get the group.
        elif isinstance(group, int):
            group = self.get(group)

        # Return the inventory source ID.
        return int(group['related']['inventory_source'].split('/')[-2])