class Resource(models.Resource): cli_help = 'Manage groups belonging to an inventory.' endpoint = '/groups/' name = models.Field(unique=True) description = models.Field(required=False, display=False) inventory = models.Field(type=types.Related('inventory')) variables = models.Field(type=types.File('r'), required=False, display=False) @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.') def list(self, root=False, **kwargs): """Return a list of groups.""" # 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)
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.File('r'), required=False, display=False) @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)
def test_convert_file_object(self): """Establish that if we receive a file-like object to the convert method, that it is passed through without action. """ sio = StringIO('The cat is trying to eat my goldfish crackers.') f = types.File('r') self.assertEqual(sio, f.convert(sio, 'myfile', None))
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.File('r'), required=False, display=False) @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): if group: kwargs['query'] = (kwargs.get('query', ()) + (('groups__in', group),)) return super(Resource, self).list(**kwargs)
def test_convert_expanduser(self): """Establish that if a filename is specified with a user home directory shortcut, that it is expanded appropriately. """ f = types.File('f') with mock.patch.object(click.File, 'convert') as convert: f.convert('~/my_file.txt', 'myfile', None) convert.assert_called_with(os.path.expanduser('~/my_file.txt'), 'myfile', None)
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.File('r'), required=False, display=False) @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.') def create(self, credential=None, source=None, **kwargs): """Create a group and, if necessary, modify the inventory source within the group. """ # First, create the group. answer = super(Resource, self).create(**kwargs) # If the group already exists and we aren't supposed to make changes, # then we're done. if not kwargs.pop('force_on_exists', False) 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 not credential and source in ('manual', None): 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') return isrc.modify(isid, credential=credential, source=source, force_on_exists=True, **kwargs) @click.option('--credential', type=types.Related('credential'), required=False) @click.option('--source', type=click.Choice(INVENTORY_SOURCES), default='manual', help='The source to use for this group.') def modify(self, pk=None, credential=None, source=None, **kwargs): """Modify a group and, if necessary, the inventory source within the group. """ # First, modify the group. answer = super(Resource, self).modify(pk=pk, **kwargs) # If the group already exists and we aren't supposed to make changes, # then we're done. if not kwargs.pop('force_on_exists', True) and not answer['changed']: 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') for field in self.fields: kwargs.pop(field.name, None) return isrc.modify(isid, credential=credential, source=source, force_on_exists=True, **kwargs) @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.') def list(self, root=False, **kwargs): """Return a list of groups.""" # 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', 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('--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(use_fields_as_options=False, no_args_is_help=True) def sync(self, group, monitor=False, timeout=None, **kwargs): """Update the given group's inventory source.""" isrc = get_resource('inventory_source') isid = self._get_inventory_source_id(group) return isrc.update(isid, monitor=monitor, timeout=timeout, **kwargs) def _get_inventory_source_id(self, group): """Return the inventory source ID given a group dictionary returned from the Tower API. """ # If we got a group ID rather than a group, get the group. if isinstance(group, int): group = self.get(group) # Return the inventory soruce ID. return int(group['related']['inventory_source'].split('/')[-2])
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.File('r'), required=False, display=False) # Basic options for the source @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('--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.') 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. """ # 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: 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 @click.option('--credential', type=types.Related('credential'), required=False) @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('--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.') 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: 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.') def list(self, root=False, **kwargs): """Return a list of groups.""" # 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', 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('--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(use_fields_as_options=False, no_args_is_help=True) def sync(self, group, monitor=False, timeout=None, **kwargs): """Update the given group's inventory source.""" isrc = get_resource('inventory_source') isid = self._get_inventory_source_id(group) return isrc.update(isid, monitor=monitor, timeout=timeout, **kwargs) def _get_inventory_source_id(self, group): """Return the inventory source ID given a group dictionary returned from the Tower API. """ # If we got a group ID rather than a group, get the group. if isinstance(group, int): group = self.get(group) # Return the inventory source ID. return int(group['related']['inventory_source'].split('/')[-2])
class Resource(models.MonitorableResource): """A resource for jobs. As a base resource, this resource does *not* have the normal create, list, etc. methods. """ cli_help = 'Launch or monitor jobs.' endpoint = '/jobs/' @resources.command @click.option('--job-template', type=types.Related('job_template')) @click.option('--monitor', is_flag=True, default=False, help='If sent, immediately calls `job monitor` on the newly ' 'launched job rather than exiting with a success.') @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.') @click.option('--no-input', is_flag=True, default=False, help='Suppress any requests for input.') @click.option('--extra-vars', type=types.File('r'), required=False) @click.option('--tags', required=False) def launch(self, job_template, tags=None, monitor=False, timeout=None, no_input=True, extra_vars=None): """Launch a new job based on a job template. Creates a new job in Ansible Tower, immediately stats it, and returns back an ID in order for its status to be monitored. """ # Get the job template from Ansible Tower. # This is used as the baseline for starting the job. jt_resource = get_resource('job_template') jt = jt_resource.get(job_template) # Update the job data by adding an automatically-generated job name, # and removing the ID. data = copy(jt) data['job_template'] = data.pop('id') data['name'] = '%s [invoked via. Tower CLI]' % data['name'] if tags: data['job_tags'] = tags # If the job template requires prompting for extra variables, # do so (unless --no-input is set). if extra_vars: if hasattr(extra_vars, 'read'): extra_vars = extra_vars.read() data['extra_vars'] = extra_vars elif data.pop('ask_variables_on_launch', False) and not no_input: initial = data['extra_vars'] initial = '\n'.join(( '# Specify extra variables (if any) here.', '# Lines beginning with "#" are ignored.', initial, )) extra_vars = click.edit(initial) or '' extra_vars = '\n'.join( [i for i in extra_vars.split('\n') if not i.startswith('#')]) data['extra_vars'] = extra_vars # In Tower 2.1 and later, we create the new job with # /job_templates/N/launch/; in Tower 2.0 and before, there is a two # step process of posting to /jobs/ and then /jobs/N/start/. supports_job_template_launch = False if 'launch' in jt['related']: supports_job_template_launch = True # Create the new job in Ansible Tower. start_data = {} if supports_job_template_launch: endpoint = '/job_templates/%d/launch/' % jt['id'] if 'extra_vars' in data: start_data['extra_vars'] = data['extra_vars'] if tags: start_data['job_tags'] = data['job_tags'] else: debug.log('Creating the job.', header='details') job = client.post('/jobs/', data=data).json() job_id = job['id'] endpoint = '/jobs/%d/start/' % job_id # There's a non-trivial chance that we are going to need some # additional information to start the job; in particular, many jobs # rely on passwords entered at run-time. # # If there are any such passwords on this job, ask for them now. debug.log('Asking for information necessary to start the job.', header='details') job_start_info = client.get(endpoint).json() for password in job_start_info.get('passwords_needed_to_start', []): start_data[password] = getpass('Password for %s: ' % password) # Actually start the job. debug.log('Launching the job.', header='details') result = client.post(endpoint, start_data) # If this used the /job_template/N/launch/ route, get the job # ID from the result. if supports_job_template_launch: job_id = result.json()['job'] # If we were told to monitor the job once it started, then call # monitor from here. if monitor: return self.monitor(job_id, timeout=timeout) # Return the job ID. return { 'changed': True, 'id': job_id, } @resources.command @click.option('--detail', is_flag=True, default=False, help='Print more detail.') def status(self, pk, detail=False): """Print the current job status.""" # Get the job from Ansible Tower. debug.log('Asking for job status.', header='details') job = client.get('/jobs/%d/' % pk).json() # In most cases, we probably only want to know the status of the job # and the amount of time elapsed. However, if we were asked for # verbose information, provide it. if detail: return job # Print just the information we need. return adict({ 'elapsed': job['elapsed'], 'failed': job['failed'], 'status': job['status'], }) @resources.command @click.option('--fail-if-not-running', is_flag=True, default=False, help='Fail loudly if the job is not currently running.') def cancel(self, pk, fail_if_not_running=False): """Cancel a currently running job. Fails with a non-zero exit status if the job cannot be canceled. """ # Attempt to cancel the job. try: client.post('/jobs/%d/cancel/' % pk) changed = True except exc.MethodNotAllowed: changed = False if fail_if_not_running: raise exc.TowerCLIError('Job not running.') # Return a success. return adict({'status': 'canceled', 'changed': changed})
class Resource(models.BaseResource): """A resource for jobs. As a base resource, this resource does *not* have the normal create, list, etc. methods. """ cli_help = 'Launch or monitor jobs.' endpoint = '/jobs/' @resources.command @click.option('--job-template', type=int) @click.option('--monitor', is_flag=True, default=False, help='If sent, immediately calls `job monitor` on the newly ' 'launched job rather than exiting with a success.') @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.') @click.option('--no-input', is_flag=True, default=False, help='Suppress any requests for input.') @click.option('--extra-vars', type=types.File('r'), required=False) def launch(self, job_template, monitor=False, timeout=None, no_input=True, extra_vars=None): """Launch a new job based on a job template. Creates a new job in Ansible Tower, immediately stats it, and returns back an ID in order for its status to be monitored. """ # Get the job template from Ansible Tower. # This is used as the baseline for starting the job. jt_resource = get_resource('job_template') jt = jt_resource.get(job_template) # Update the job data by adding an automatically-generated job name, # and removing the ID. data = copy(jt) data.pop('id') data['name'] = '%s [invoked via. Tower CLI]' % data['name'] # If the job template requires prompting for extra variables, # do so (unless --no-input is set). if extra_vars: data['extra_vars'] = extra_vars.read() elif data.pop('ask_variables_on_launch', False) and not no_input: initial = data['extra_vars'] initial = '\n'.join(( '# Specify extra variables (if any) here.', '# Lines beginning with "#" are ignored.', initial, )) extra_vars = click.edit(initial) or '' extra_vars = '\n'.join( [i for i in extra_vars.split('\n') if not i.startswith('#')]) data['extra_vars'] = extra_vars # Create the new job in Ansible Tower. debug.log('Creating the job.', header='details') job = client.post('/jobs/', data=data).json() # There's a non-trivial chance that we are going to need some # additional information to start the job; in particular, many jobs # rely on passwords entered at run-time. # # If there are any such passwords on this job, ask for them now. debug.log('Asking for information necessary to start the job.', header='details') job_start_info = client.get('/jobs/%d/start/' % job['id']).json() start_data = {} for password in job_start_info.get('passwords_needed_to_start', []): start_data[password] = getpass('Password for %s: ' % password) # Actually start the job. debug.log('Launching the job.', header='details') result = client.post('/jobs/%d/start/' % job['id'], start_data) # If we were told to monitor the job once it started, then call # monitor from here. if monitor: return self.monitor(job['id'], timeout=timeout) # Return the job ID. return { 'changed': True, 'id': job['id'], } @resources.command @click.option('--min-interval', default=1, help='The minimum interval to request an update ' 'from Tower.') @click.option('--max-interval', default=30, help='The maximum interval to request an update ' 'from Tower.') @click.option('--timeout', required=False, type=int, help='If provided, this command (not the job) will time out ' 'after the given number of seconds.') def monitor(self, pk, min_interval=1, max_interval=30, timeout=None, outfile=sys.stdout): """Monitor a running job. Blocks further input until the job completes (whether successfully or unsuccessfully) and a final status can be given. """ dots = itertools.cycle([0, 1, 2, 3]) longest_string = 0 interval = min_interval start = time.time() # Poll the Ansible Tower instance for status, and print the status # to the outfile (usually standard out). # # Note that this is one of the few places where we use `click.secho` # even though we're in a function that might theoretically be imported # and run in Python. This seems fine; outfile can be set to /dev/null # and very much the normal use for this method should be CLI # monitoring. job = self.status(pk) last_poll = time.time() timeout_check = 0 while job['status'] != 'successful': # If the job has failed, we want to raise an Exception for that # so we get a non-zero response. if job['failed']: if is_tty(outfile) and not settings.verbose: click.secho('\r' + ' ' * longest_string + '\n', file=outfile) raise exc.JobFailure('Job failed.') # Sanity check: Have we officially timed out? # The timeout check is incremented below, so this is checking # to see if we were timed out as of the previous iteration. # If we are timed out, abort. if timeout and timeout_check - start > timeout: raise exc.Timeout('Monitoring aborted due to timeout.') # If the outfile is a TTY, print the current status. output = '\rCurrent status: %s%s' % (job['status'], '.' * next(dots)) if longest_string > len(output): output += ' ' * (longest_string - len(output)) else: longest_string = len(output) if is_tty(outfile) and not settings.verbose: click.secho(output, nl=False, file=outfile) # Put the process to sleep briefly. time.sleep(0.2) # Sanity check: Have we reached our timeout? # If we're about to time out, then we need to ensure that we # do one last check. # # Note that the actual timeout will be performed at the start # of the **next** iteration, so there's a chance for the job's # completion to be noted first. timeout_check = time.time() if timeout and timeout_check - start > timeout: last_poll -= interval # If enough time has elapsed, ask the server for a new status. # # Note that this doesn't actually do a status check every single # time; we want the "spinner" to spin even if we're not actively # doing a check. # # So, what happens is that we are "counting down" (actually up) # to the next time that we intend to do a check, and once that # time hits, we do the status check as part of the normal cycle. if time.time() - last_poll > interval: job = self.status(pk) last_poll = time.time() interval = min(interval * 1.5, max_interval) # If the outfile is *not* a TTY, print a status update # when and only when we make an actual check to job status. if not is_tty(outfile) or settings.verbose: click.echo('Current status: %s' % job['status'], file=outfile) # Wipe out the previous output if is_tty(outfile) and not settings.verbose: click.secho('\r' + ' ' * longest_string, file=outfile, nl=False) click.secho('\r', file=outfile, nl=False) # Done; return the result return job @resources.command @click.option('--detail', is_flag=True, default=False, help='Print more detail.') def status(self, pk, detail=False): """Print the current job status.""" # Get the job from Ansible Tower. debug.log('Asking for job status.', header='details') job = client.get('/jobs/%d/' % pk).json() # In most cases, we probably only want to know the status of the job # and the amount of time elapsed. However, if we were asked for # verbose information, provide it. if detail: return job # Print just the information we need. return adict({ 'elapsed': job['elapsed'], 'failed': job['failed'], 'status': job['status'], }) @resources.command @click.option('--fail-if-not-running', is_flag=True, default=False, help='Fail loudly if the job is not currently running.') def cancel(self, pk, fail_if_not_running=False): """Cancel a currently running job. Fails with a non-zero exit status if the job cannot be canceled. """ # Attempt to cancel the job. try: client.post('/jobs/%d/cancel/' % pk) changed = True except exc.MethodNotAllowed: changed = False if fail_if_not_running: raise exc.TowerCLIError('Job not running.') # Return a success. return adict({'status': 'canceled', 'changed': changed})