def _get_auth_token(self): filename = os.path.expanduser('~/.tower_cli_token.json') token_json = None try: with open(filename) as f: token_json = json.load(f) if not isinstance(token_json, dict) or self.cli_client.get_prefix() not in token_json or \ 'token' not in token_json[self.cli_client.get_prefix()] or \ 'expires' not in token_json[self.cli_client.get_prefix()] or \ dt.utcnow() > dt.strptime(token_json[self.cli_client.get_prefix()]['expires'], TOWER_DATETIME_FMT): raise Exception("Current token expires.") return 'Token ' + token_json[self.cli_client.get_prefix()]['token'] except Exception as e: debug.log('Acquiring and caching auth token due to:\n%s' % str(e), fg='blue', bold=True) if not isinstance(token_json, dict): token_json = {} token_json[self.cli_client.get_prefix()] = self._acquire_token() if not isinstance(token_json[self.cli_client.get_prefix()], dict) or \ 'token' not in token_json[self.cli_client.get_prefix()] or \ 'expires' not in token_json[self.cli_client.get_prefix()]: raise exc.AuthError('Invalid Tower auth token format: %s' % json.dumps( token_json[self.cli_client.get_prefix()] )) with open(filename, 'w') as f: json.dump(token_json, f) try: os.chmod(filename, stat.S_IRUSR | stat.S_IWUSR) except Exception as e: warnings.warn( 'Unable to set permissions on {0} - {1} '.format(filename, e), UserWarning ) return 'Token ' + token_json[self.cli_client.get_prefix()]['token']
def list(self, all_pages=False, **kwargs): """Return a list of objects. If one or more filters are provided through keyword arguments, filter the results accordingly. If no filters are provided, return all results. """ # If the `all_pages` flag is set, then ignore any page that might # also be sent. if all_pages: kwargs.pop('page', None) # Get the response. debug.log('Getting records.', header='details') response = self.read(**kwargs) # Alter the "next" and "previous" to reflect simple integers, # rather than URLs, since this endpoint just takes integers. for key in ('next', 'previous'): if not response[key]: continue match = re.search(r'page=(?P<num>[\d]+)', response[key]) response[key] = int(match.groupdict()['num']) # If we were asked for all pages, keep retrieving pages until we # have them all. if all_pages and response['next']: cursor = copy(response) while cursor['next']: cursor = self.list(**dict(kwargs, page=cursor['next'])) response['results'] += cursor['results'] # Done; return the response return response
def status(self, pk=None, detail=False, **kwargs): """Print the current job status. This is used to check a running job. You can look up the job with the same parameters used for a get request.""" # Remove default values (anything where the value is None). self._pop_none(kwargs) # Search for the record if pk not given if not pk: job = self.get(include_debug_header=True, **kwargs) # Get the job from Ansible Tower if pk given else: debug.log('Asking for job status.', header='details') finished_endpoint = '%s%d/' % (self.endpoint, pk) job = client.get(finished_endpoint).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'], })
def delete(self, pk=None, fail_on_missing=False, **kwargs): """Remove the given object. If `fail_on_missing` is True, then the object's not being found is considered a failure; otherwise, a success with no change is reported. """ # If we weren't given a primary key, determine which record we're # deleting. if not pk: existing_data = self._lookup(fail_on_missing=fail_on_missing, **kwargs) if not existing_data: return {'changed': False} pk = existing_data['id'] # Attempt to delete the record. # If it turns out the record doesn't exist, handle the 404 # appropriately (this is an okay response if `fail_on_missing` is # False). url = '%s%d/' % (self.endpoint, pk) debug.log('DELETE %s' % url, fg='blue', bold=True) try: client.delete(url) return {'changed': True} except exc.NotFound: if fail_on_missing: raise return {'changed': False}
def create(self, organization=None, monitor=False, timeout=None, fail_on_found=False, force_on_exists=False, **kwargs): """Create a new item of resource, with or w/o org. This would be a shared class with user, but it needs the ability to monitor if the flag is set. """ # First, run the create method, ignoring the organization given answer = super(Resource, self).write( create_on_missing=True, fail_on_found=fail_on_found, force_on_exists=force_on_exists, **kwargs ) project_id = answer['id'] # If an organization is given, associate it here if organization: # Get the organization from Tower, will lookup name if needed org_resource = get_resource('organization') org_data = org_resource.get(organization) org_pk = org_data['id'] debug.log("associating the project with its organization", header='details', nl=1) org_resource._assoc('projects', org_pk, project_id) # if the monitor flag is set, wait for the SCM to update if monitor: return self.monitor(project_id, timeout=timeout) return answer
def update(self, pk=None, create_on_missing=False, monitor=False, timeout=None, name=None, organization=None): """Trigger a project update job within Ansible Tower. Only meaningful on non-manual projects. """ # First, get the appropriate project. # This should be uniquely identified at this point, and if not, then # we just want the error that `get` will throw to bubble up. project = self.get(pk, name=name, organization=organization) pk = project['id'] # Determine whether this project is able to be updated. debug.log('Asking whether the project can be updated.', header='details') result = client.get('/projects/%d/update/' % pk) if not result.json()['can_update']: raise exc.CannotStartJob('Cannot update project.') # Okay, this project can be updated, according to Tower. # Commence the update. debug.log('Updating the project.', header='details') result = client.post('/projects/%d/update/' % pk) # If we were told to monitor the project update's status, do so. if monitor: return self.monitor(pk, timeout=timeout) # Return the project update ID. return { 'changed': True, }
def update(self, inventory_source, monitor=False, timeout=None, **kwargs): """Update the given inventory source.""" # Establish that we are able to update this inventory source # at all. debug.log('Asking whether the inventory source can be updated.', header='details') r = client.get('%s%d/update/' % (self.endpoint, inventory_source)) if not r.json()['can_update']: raise exc.BadRequest('Tower says it cannot run an update against ' 'this inventory source.') # Run the update. debug.log('Updating the inventory source.', header='details') r = client.post('%s%d/update/' % (self.endpoint, inventory_source)) # If we were told to monitor the project update's status, do so. if monitor: result = self.monitor(inventory_source, timeout=timeout) inventory = client.get('/inventory_sources/%d/' % result['inventory_source'])\ .json()['inventory'] result['inventory'] = int(inventory) return result # Done. return {'status': 'ok'}
def create(self, organization=None, monitor=False, timeout=None, *args, **kwargs): """Create a new item of resource, with or w/o org. This would be a shared class with user, but it needs the ability to monitor if the flag is set. """ backup_endpoint = self.endpoint if organization: debug.log("using alternative endpoint specific to organization", header='details') # Get the organization from Tower, will lookup name if needed org_resource = get_resource('organization') org_data = org_resource.get(organization) org_pk = org_data['id'] self.endpoint = '/organizations/%s%s' % (org_pk, backup_endpoint) answer = super(Resource, self).create(*args, **kwargs) self.endpoint = backup_endpoint # if the monitor flag is set, wait for the SCM to update if monitor: project_id = answer['id'] return self.monitor(project_id, timeout=timeout) return answer
def convert(self, value, param, ctx): """Return the appropriate interger value. If a non-integer is provided, attempt a name-based lookup and return the primary key. """ resource = tower_cli.get_resource(self.resource_name) # Ensure that None is passed through without trying to # do anything. if value is None: return None # If we were already given an integer, do nothing. # This ensures that the convert method is idempotent. if isinstance(value, int): return value # Do we have a string that contains only digits? # If so, then convert it to an integer and return it. if re.match(r'^[\d]+$', value): return int(value) # Okay, we have a string. Try to do a name-based lookup on the # resource, and return back the ID that we get from that. # # This has the chance of erroring out, which is fine. try: debug.log('The %s field is given as a name; ' 'looking it up.' % param.name, header='details') rel = resource.get(**{resource.unique_criterion: value}) except exc.TowerCLIError as ex: raise exc.RelatedError('Could not get %s. %s' % (self.resource_name, str(ex))) # Done! Return the ID. return rel['id']
def test_not_verbose_mode(self): """Establish that this method does nothing if we are not in verbose mode. """ with settings.runtime_values(verbose=False): with mock.patch.object(click, 'secho') as secho: debug.log('foo bar baz') self.assertEqual(secho.call_count, 0)
def launch(self, monitor=False, wait=False, timeout=None, **kwargs): """Launch a new ad-hoc command. Runs a user-defined command from Ansible Tower, immediately starts it, and returns back an ID in order for its status to be monitored. =====API DOCS===== Launch a new ad-hoc command. :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched command rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the job, but do not print while job is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param `**kwargs`: Fields needed to create and launch an ad hoc command. :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; dictionary of "id" and "changed" if none of the two flags are on. :rtype: dict :raises tower_cli.exceptions.TowerCLIError: When ad hoc commands are not available in Tower backend. =====API DOCS===== """ # This feature only exists for versions 2.2 and up r = client.get('/') if 'ad_hoc_commands' not in r.json(): raise exc.TowerCLIError('Your host is running an outdated version' 'of Ansible Tower that can not run ' 'ad-hoc commands (2.2 or earlier)') # Pop the None arguments because we have no .write() method in # inheritance chain for this type of resource. This is needed self._pop_none(kwargs) # Actually start the command. debug.log('Launching the ad-hoc command.', header='details') result = client.post(self.endpoint, data=kwargs) command = result.json() command_id = command['id'] # If we were told to monitor the command once it started, then call # monitor from here. if monitor: return self.monitor(command_id, timeout=timeout) elif wait: return self.wait(command_id, timeout=timeout) # Return the command ID and other response data answer = OrderedDict(( ('changed', True), ('id', command_id), )) answer.update(result.json()) return answer
def get(self, pk=None, **kwargs): """Get information about a role.""" if kwargs.pop('include_debug_header', True): debug.log('Getting the role record.', header='details') data, self.endpoint = self.data_endpoint(kwargs) response = self.read(pk=pk, fail_on_no_results=True, fail_on_multiple_results=True, **data) item_dict = response['results'][0] self.configure_display(item_dict) return item_dict
def test_extra_newlines(self): """Establish that extra newlines are correctly applied if they are requested. """ s = 'All your base are belong to us.' with mock.patch.object(click, 'secho') as secho: with settings.runtime_values(verbose=True): debug.log(s, nl=3) self.assertEqual(secho.mock_calls[0][1][0], 'All your base are belong to us.\n\n')
def create(self, **kwargs): if (kwargs.get('user', False) or kwargs.get('team', False) or kwargs.get('organization', False)): debug.log('Checking Project API Details.', header='details') r = client.options('/credentials/') if 'organization' in r.json()['actions']['POST']: for i in range(len(self.fields)): if self.fields[i].name in ('user', 'team', 'credential'): self.fields[i].no_lookup = True return super(Resource, self).create(**kwargs)
def update(self, inventory_source, monitor=False, wait=False, timeout=None, **kwargs): """Update the given inventory source. =====API DOCS===== Update the given inventory source. :param inventory_source: Primary key or name of the inventory source to be updated. :type inventory_source: str :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched inventory update rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the inventory update, but do not print while it is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param `**kwargs`: Fields used to override underlyingl inventory source fields when creating and launching an inventory update. :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; dictionary of "status" if none of the two flags are on. :rtype: dict :raises tower_cli.exceptions.BadRequest: When the inventory source cannot be updated. =====API DOCS===== """ # Establish that we are able to update this inventory source # at all. debug.log('Asking whether the inventory source can be updated.', header='details') r = client.get('%s%d/update/' % (self.endpoint, inventory_source)) if not r.json()['can_update']: raise exc.BadRequest('Tower says it cannot run an update against this inventory source.') # Run the update. debug.log('Updating the inventory source.', header='details') r = client.post('%s%d/update/' % (self.endpoint, inventory_source), data={}) inventory_update_id = r.json()['inventory_update'] # If we were told to monitor the project update's status, do so. if monitor or wait: if monitor: result = self.monitor(inventory_update_id, parent_pk=inventory_source, timeout=timeout) elif wait: result = self.wait(inventory_update_id, parent_pk=inventory_source, timeout=timeout) inventory = client.get('/inventory_sources/%d/' % result['inventory_source']).json()['inventory'] result['inventory'] = int(inventory) return result # Done. return { 'id': inventory_update_id, 'status': 'ok' }
def test_header(self): """Establish that a header echoes the expected string, of correct length. """ s = 'Decided all the things.' with mock.patch.object(click, 'secho') as secho: with settings.runtime_values(verbose=True): debug.log(s, header='decision', fg='blue') self.assertEqual(secho.mock_calls[0][1][0], '*** DECISION: Decided all the things. ' '*****************************************')
def get(self, pk=None, **kwargs): """Return one and exactly one object. Lookups may be through a primary key, specified as a positional argument, and/or through filters specified through keyword arguments. If the number of results does not equal one, raise an exception. """ if kwargs.pop("include_debug_header", True): debug.log("Getting the record.", header="details") response = self.read(pk=pk, fail_on_no_results=True, fail_on_multiple_results=True, **kwargs) return response["results"][0]
def get_permission_pk(self, pk, user, team, **kwargs): """Return the pk with a search method specific to permissions.""" if not pk: self.set_base_url(user, team) debug.log('Checking for existing permission.', header='details') existing_data = self._lookup( fail_on_found=False, fail_on_missing=True, include_debug_header=False, **kwargs) return existing_data['id'] else: self.no_lookup_flag = True return pk
def create(self, *args, **kwargs): """Create a project, with or w/o org. Fix for issue #52, second method, replacing the /projects/ endpoint temporarily if the project has an organization specified """ if "organization" in kwargs: debug.log("using alternative endpoint for new project", header='details') org_pk = kwargs['organization'] self.endpoint = '/organizations/%s/projects/' % org_pk to_return = super(Resource, self).create(*args, **kwargs) self.endpoint = '/projects/' return to_return
def test_extra_long_words(self): """Ensure we treat words longer than 79 characters properly and do not trigger any issue. """ s = ' '.join(['short_word', 'short_word', 'l' + 'o' * 68 + 'ng_word']) expected = '\n'.join([ '*** DETAILS: short_word short_word ********************************************', '*** loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong_word ', ]) with mock.patch.object(click, 'secho') as secho: with settings.runtime_values(verbose=True): debug.log(s, header='details') self.assertEqual(secho.mock_calls[0][1][0], expected)
def create(self, fail_on_found=False, force_on_exists=False, **kwargs): """Create a new label. There are two types of label creation: isolatedly creating a new label and creating a new label under a job template. Here the two types are discriminated by whether to provide --job-template option. Fields in the resource's `identity` tuple are used for a lookup; if a match is found, then no-op (unless `force_on_exists` is set) but do not fail (unless `fail_on_found` is set). =====API DOCS===== Create a label. :param job_template: Primary key or name of the job template for the created label to associate to. :type job_template: str :param fail_on_found: Flag that if set, the operation fails if an object matching the unique criteria already exists. :type fail_on_found: bool :param force_on_exists: Flag that if set, then if a match is found on unique fields, other fields will be updated to the provided values.; If unset, a match causes the request to be a no-op. :type force_on_exists: bool :param `**kwargs`: Keyword arguments which, all together, will be used as POST body to create the resource object. :returns: A dictionary combining the JSON output of the created resource, as well as two extra fields: "changed", a flag indicating if the resource is created successfully; "id", an integer which is the primary key of the created object. :rtype: dict :raises tower_cli.exceptions.TowerCLIError: When the label already exists and ``fail_on_found`` flag is on. =====API DOCS===== """ jt_id = kwargs.pop('job_template', None) old_endpoint = self.endpoint if jt_id is not None: jt = get_resource('job_template') jt.get(pk=jt_id) try: label_id = self.get(name=kwargs.get('name', None), organization=kwargs.get('organization', None))['id'] except exc.NotFound: pass else: if fail_on_found: raise exc.TowerCLIError('Label already exists and fail-on-found is switched on. Please use' ' "associate_label" method of job_template instead.') else: debug.log('Label already exists, associating with job template.', header='details') return jt.associate_label(job_template=jt_id, label=label_id) self.endpoint = '/job_templates/%d/labels/' % jt_id result = super(Resource, self).create(fail_on_found=fail_on_found, force_on_exists=force_on_exists, **kwargs) self.endpoint = old_endpoint return result
def modify(self, pk=None, create_on_missing=False, **kwargs): """Modify an existing notification template. Not all required configuration-related fields (required according to notification_type) should be provided. Fields in the resource's `identity` tuple can be used in lieu of a primary key for a lookup; in such a case, only other fields are written. To modify unique fields, you must use the primary key for the lookup. """ # Create the resource if needed. if pk is None and create_on_missing: try: self.get(**copy.deepcopy(kwargs)) except exc.NotFound: return self.create(**kwargs) # Modify everything except notification type and configuration config_item = self._separate(kwargs) notification_type = kwargs.pop('notification_type', None) debug.log('Modify everything except notification type and' ' configuration', header='details') part_result = super(Resource, self).\ modify(pk=pk, create_on_missing=create_on_missing, **kwargs) # Modify notification type and configuration if notification_type is None or \ notification_type == part_result['notification_type']: for item in part_result['notification_configuration']: if item not in config_item or not config_item[item]: to_add = part_result['notification_configuration'][item] if not (to_add == '$encrypted$' and item in Resource.encrypted_fields): config_item[item] = to_add if notification_type is None: kwargs['notification_type'] = part_result['notification_type'] else: kwargs['notification_type'] = notification_type self._configuration(kwargs, config_item) debug.log('Modify notification type and configuration', header='details') result = super(Resource, self).\ modify(pk=pk, create_on_missing=create_on_missing, **kwargs) # Update 'changed' field to give general changed info if 'changed' in result and 'changed' in part_result: result['changed'] = result['changed'] or part_result['changed'] return result
def convert(self, value, param, ctx): """Return the appropriate integer value. If a non-integer is provided, attempt a name-based lookup and return the primary key. """ resource = tower_cli.get_resource(self.resource_name) # Ensure that None is passed through without trying to # do anything. if value is None: return None # If we were already given an integer, do nothing. # This ensures that the convert method is idempotent. if isinstance(value, int): return value # Do we have a string that contains only digits? # If so, then convert it to an integer and return it. if re.match(r'^[\d]+$', value): return int(value) # Special case to allow disassociations if value == 'null': return value # Okay, we have a string. Try to do a name-based lookup on the # resource, and return back the ID that we get from that. # # This has the chance of erroring out, which is fine. try: debug.log('The %s field is given as a name; ' 'looking it up.' % param.name, header='details') lookup_data = {resource.identity[-1]: value} rel = resource.get(**lookup_data) except exc.MultipleResults: raise exc.MultipleRelatedError( 'Cannot look up {0} exclusively by name, because multiple {0} ' 'objects exist with that name.\n' 'Please send an ID. You can get the ID for the {0} you want ' 'with:\n' ' tower-cli {0} list --name "{1}"'.format(self.resource_name, value), ) except exc.TowerCLIError as ex: raise exc.RelatedError('Could not get %s. %s' % (self.resource_name, str(ex))) # Done! Return the ID. return rel['id']
def get_api_options(asset_type): if asset_type not in API_POST_OPTIONS: endpoint = tower_cli.get_resource(asset_type).endpoint response = client.options(endpoint) return_json = response.json() if "actions" not in return_json or "POST" not in return_json["actions"]: # Maybe we want to do a debug.log here debug.log("WARNING: Asset type {} has no API POST options no pre-checks can be performed".format( asset_type )) API_POST_OPTIONS[asset_type] = None else: API_POST_OPTIONS[asset_type] = return_json["actions"]["POST"] return API_POST_OPTIONS[asset_type]
def _get_or_create_child(self, parent, relationship, **kwargs): ujt_pk = kwargs.get('unified_job_template', None) if ujt_pk is None: raise exceptions.BadRequest( 'A child node must be specified by one of the options ' 'unified-job-template, job-template, project, or ' 'inventory-source') kwargs.update(self._parent_filter(parent, relationship, **kwargs)) response = self.read( fail_on_no_results=False, fail_on_multiple_results=False, **kwargs) if len(response['results']) == 0: debug.log('Creating new workflow node.', header='details') return client.post(self.endpoint, data=kwargs).json() else: return response['results'][0]
def test_multi_lines(self): """Establish that overly long debug messages will be displayed in multiple lines. """ s = ' '.join(['multi-line'] * 30) expected = '\n'.join([ '*** DETAILS: multi-line multi-line multi-line multi-line multi-line multi-line ', '*** multi-line multi-line multi-line multi-line multi-line multi-line *********', '*** multi-line multi-line multi-line multi-line multi-line multi-line *********', '*** multi-line multi-line multi-line multi-line multi-line multi-line *********', '*** multi-line multi-line multi-line multi-line multi-line multi-line *********', ]) with mock.patch.object(click, 'secho') as secho: with settings.runtime_values(verbose=True): debug.log(s, header='details') self.assertEqual(secho.mock_calls[0][1][0], expected)
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. =====API DOCS===== Launch a new workflow job based on a workflow job template. :param workflow_job_template: Primary key or name of the workflow job template to launch new job. :type workflow_job_template: str :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched workflow job rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the workflow job, but do not print while job is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param extra_vars: yaml formatted texts that contains extra variables to pass on. :type extra_vars: array of strings :param `**kwargs`: Fields needed to create and launch a workflow job. :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; loaded JSON output of the job launch if none of the two flags are on. :rtype: dict =====API DOCS===== """ if extra_vars is not None and 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/{0}/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
def create(self, fail_on_found=False, force_on_exists=False, **kwargs): """Create a notification template. All required configuration-related fields (required according to notification_type) must be provided. There are two types of notification template creation: isolatedly creating a new notification template and creating a new notification template under a job template. Here the two types are discriminated by whether to provide --job-template option. --status option controls more specific, job-run-status-related association. Fields in the resource's `identity` tuple are used for a lookup; if a match is found, then no-op (unless `force_on_exists` is set) but do not fail (unless `fail_on_found` is set). """ config_item = self._separate(kwargs) jt_id = kwargs.pop('job_template', None) status = kwargs.pop('status', 'any') old_endpoint = self.endpoint if jt_id is not None: jt = get_resource('job_template') jt.get(pk=jt_id) try: nt_id = self.get(**copy.deepcopy(kwargs))['id'] except exc.NotFound: pass else: if fail_on_found: raise exc.TowerCLIError('Notification template already ' 'exists and fail-on-found is ' 'switched on. Please use' ' "associate_notification" method' ' of job_template instead.') else: debug.log('Notification template already exists, ' 'associating with job template.', header='details') return jt.associate_notification(jt_id, nt_id, status=status) self.endpoint = '/job_templates/%d/notification_templates_%s/' %\ (jt_id, status) self._configuration(kwargs, config_item) result = super(Resource, self).create(**kwargs) self.endpoint = old_endpoint return result
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'], })
def process_extra_vars(extra_vars_list, force_json=True): """Returns a string that is valid JSON or YAML and contains all the variables in every extra_vars_opt inside of extra_vars_list. Args: parse_kv (bool): whether to allow key=value syntax. force_json (bool): if True, always output json. """ # Read from all the different sources and put into dictionary extra_vars = {} extra_vars_yaml = "" for extra_vars_opt in extra_vars_list: # Load file content if necessary if extra_vars_opt.startswith("@"): with open(extra_vars_opt[1:], 'r') as f: extra_vars_opt = f.read() # Convert text markup to a dictionary conservatively opt_dict = string_to_dict(extra_vars_opt, allow_kv=False) else: # Convert text markup to a dictionary liberally opt_dict = string_to_dict(extra_vars_opt, allow_kv=True) # Rolling YAML-based string combination if any(line.startswith("#") for line in extra_vars_opt.split('\n')): extra_vars_yaml += extra_vars_opt + "\n" elif extra_vars_opt != "": extra_vars_yaml += yaml.dump( opt_dict, default_flow_style=False) + "\n" # Combine dictionary with cumulative dictionary extra_vars.update(opt_dict) # Return contents in form of a string if not force_json: try: # Conditions to verify it is safe to return rolling YAML string try_dict = yaml.load(extra_vars_yaml) assert type(try_dict) is dict debug.log('Using unprocessed YAML', header='decision', nl=2) return extra_vars_yaml.rstrip() except: debug.log('Failed YAML parsing, defaulting to JSON', header='decison', nl=2) if extra_vars == {}: return "" return json.dumps(extra_vars)
def _make_request(self, method, url, args, kwargs): # Decide whether to require SSL verification verify_ssl = True if (settings.verify_ssl is False) or hasattr(settings, 'insecure'): verify_ssl = False elif settings.certificate is not None: verify_ssl = settings.certificate # Call the superclass method. try: with warnings.catch_warnings(): warnings.simplefilter( "ignore", urllib3.exceptions.InsecureRequestWarning) return super(Client, self).request(method, url, *args, verify=verify_ssl, **kwargs) except SSLError as ex: # Throw error if verify_ssl not set to false and server # is not using verified certificate. if settings.verbose: debug.log('SSL connection failed:', fg='yellow', bold=True) debug.log(str(ex), fg='yellow', bold=True, nl=2) if not settings.host.startswith('http'): secho( 'Suggestion: add the correct http:// or ' 'https:// prefix to the host configuration.', fg='blue', bold=True) raise exc.ConnectionError( 'Could not establish a secure connection. ' 'Please add the server to your certificate ' 'authority.\nYou can run this command without verifying SSL ' 'with the --insecure flag, or permanently disable ' 'verification by the config setting:\n\n ' 'tower-cli config verify_ssl false') except ConnectionError as ex: # Throw error if server can not be reached. if settings.verbose: debug.log('Cannot connect to Tower:', fg='yellow', bold=True) debug.log(str(ex), fg='yellow', bold=True, nl=2) raise exc.ConnectionError( 'There was a network error of some kind trying to connect ' 'to Tower.\n\nThe most common reason for this is a settings ' 'issue; is your "host" value in `tower-cli config` correct?\n' 'Right now it is: "%s".' % settings.host)
def update(self, inventory_source, monitor=False, wait=False, timeout=None, **kwargs): """Update the given inventory source.""" # Establish that we are able to update this inventory source # at all. debug.log('Asking whether the inventory source can be updated.', header='details') r = client.get('%s%d/update/' % (self.endpoint, inventory_source)) if not r.json()['can_update']: raise exc.BadRequest('Tower says it cannot run an update against ' 'this inventory source.') # Run the update. debug.log('Updating the inventory source.', header='details') r = client.post('%s%d/update/' % (self.endpoint, inventory_source)) # If we were told to monitor the project update's status, do so. if monitor or wait: inventory_update_id = r.json()['inventory_update'] if monitor: result = self.monitor( inventory_update_id, parent_pk=inventory_source, timeout=timeout) elif wait: result = self.wait( inventory_update_id, parent_pk=inventory_source, timeout=timeout) inventory = client.get('/inventory_sources/%d/' % result['inventory_source'])\ .json()['inventory'] result['inventory'] = int(inventory) return result # Done. return {'status': 'ok'}
def update(self, pk=None, create_on_missing=False, monitor=False, wait=False, timeout=None, name=None, organization=None): """Trigger a project update job within Ansible Tower. Only meaningful on non-manual projects. """ # First, get the appropriate project. # This should be uniquely identified at this point, and if not, then # we just want the error that `get` will throw to bubble up. project = self.get(pk, name=name, organization=organization) pk = project['id'] # Determine whether this project is able to be updated. debug.log('Asking whether the project can be updated.', header='details') result = client.get('/projects/%d/update/' % pk) if not result.json()['can_update']: raise exc.CannotStartJob('Cannot update project.') # Okay, this project can be updated, according to Tower. # Commence the update. debug.log('Updating the project.', header='details') result = client.post('/projects/%d/update/' % pk) # If we were told to monitor the project update's status, do so. if monitor: project_update_id = result.json()['project_update'] return self.monitor(project_update_id, parent_pk=pk, timeout=timeout) elif wait: project_update_id = result.json()['project_update'] return self.wait(project_update_id, parent_pk=pk, timeout=timeout) # Return the project update ID. return { 'changed': True, }
def create(self, **kwargs): """Create a credential. Fields in the resource's `identity` tuple are used for a lookup; if a match is found, then no-op (unless `force_on_exists` is set) but do not fail (unless `fail_on_found` is set). =====API DOCS===== Create a credential. :param fail_on_found: Flag that if set, the operation fails if an object matching the unique criteria already exists. :type fail_on_found: bool :param force_on_exists: Flag that if set, then if a match is found on unique fields, other fields will be updated to the provided values.; If unset, a match causes the request to be a no-op. :type force_on_exists: bool :param `**kwargs`: Keyword arguements which, all together, will be used as POST body to create the resource object. :returns: A dictionary combining the JSON output of the created resource, as well as two extra fields: "changed", a flag indicating if the resource is created successfully; "id", an integer which is the primary key of the created object. :rtype: dict =====API DOCS===== """ if (kwargs.get('user', False) or kwargs.get('team', False) or kwargs.get('organization', False)): debug.log('Checking Project API Details.', header='details') r = client.options('/credentials/') if 'organization' in r.json()['actions']['POST']: for i in range(len(self.fields)): if self.fields[i].name in ('user', 'team'): self.fields[i].no_lookup = True return super(Resource, self).create(**kwargs)
def _get_auth_token(self): filename = os.path.expanduser('~/.tower_cli_token.json') try: with open(filename) as f: token_json = json.load(f) if not isinstance(token_json, dict) or 'token' not in token_json or 'expires' not in token_json or \ dt.utcnow() > dt.strptime(token_json['expires'], TOWER_DATETIME_FMT): raise Exception("Current token expires.") return 'Token ' + token_json['token'] except Exception as e: debug.log('Acquiring and caching auth token due to:\n%s' % str(e), fg='blue', bold=True) token_json = self._acquire_token() if not isinstance(token_json, dict) or 'token' not in token_json or 'expires' not in token_json: raise exc.AuthError('Invalid Tower auth token format: %s' % json.dumps(token_json)) with open(filename, 'w') as f: json.dump(token_json, f) try: os.chmod(filename, stat.S_IRUSR | stat.S_IWUSR) except Exception as e: warnings.warn( 'Unable to set permissions on {0} - {1} '.format(filename, e), UserWarning ) return 'Token ' + token_json['token']
def disassociate_credential(self, job_template, credential): """Disassociate a credential with this job template. =====API DOCS===== Disassociate a credential from this job template. :param job_template: The job template to disassociate fom. :type job_template: str :param credential: The credential to be disassociated. :type credential: str :returns: Dictionary of only one key "changed", which indicates whether the disassociation succeeded. :rtype: dict =====API DOCS===== """ try: return self._disassoc('credentials', job_template, credential) except NotFound: debug.log( 'Attempting to use extra_credential as fallback in ' 'case server is older version.', header='details') return self._disassoc('extra_credentials', job_template, credential)
def process_inventory_groups(group_json): group_post_options = get_api_options('group') group_to_return = {} map_node_to_post_options(group_post_options, group_json, group_to_return) name_to_id_map = {} group_to_return['name'] = group_json['name'] # Now we need to get the children for the group (which should all be groups) if 'related' in group_json and 'children' in group_json['related']: group_to_return['sub_groups'] = [] children = load_all_assets(group_json['related']['children']) for child in children['results']: if 'type' not in child: debug.log( "Found a child without a type in group {} : {}".format( group_json['name'], child)) continue if child['type'] == 'group': process_inv_data = process_inventory_groups(child) group_to_return['sub_groups'].append(process_inv_data['items']) name_to_id_map[child['name']] = { 'id': child['id'], 'sub_groups': process_inv_data['name_to_id_map'] } else: debug.log( "Found unexpected child type of {} when processing group {}" .format(child['type'], group_json['name'])) # And also get the hosts in this group if 'related' in group_json and 'hosts' in group_json['related']: group_to_return['hosts'] = [] hosts = load_all_assets(group_json['related']['hosts']) for host in hosts['results']: if 'name' not in host: debug.log( "Found a host without a name in group {} : {}".format( group_json['name'], host)) continue group_to_return['hosts'].append(host['name']) # we can remove the inventory option because we are appending this group directory to an inventory object if 'inventory' in group_to_return: del group_to_return['inventory'] return {'items': group_to_return, 'name_to_id_map': name_to_id_map}
def write(self, pk=None, **kwargs): survey_input = kwargs.pop('survey_spec', None) if kwargs.get('extra_vars', None): kwargs['extra_vars'] = parser.process_extra_vars( kwargs['extra_vars']) ret = super(SurveyResource, self).write(pk=pk, **kwargs) if survey_input is not None and ret.get('id', None): if not isinstance(survey_input, dict): survey_input = json.loads(survey_input.strip(' ')) if survey_input == {}: debug.log('Deleting the survey_spec.', header='details') r = client.delete(self._survey_endpoint(ret['id'])) else: debug.log('Saving the survey_spec.', header='details') r = client.post(self._survey_endpoint(ret['id']), data=survey_input) if r.status_code == 200: ret['changed'] = True if survey_input and not ret['survey_enabled']: debug.log('For survey to take effect, set survey_enabled' ' field to True.', header='warning') return ret
def write(self, pk=None, create_on_missing=False, fail_on_found=False, force_on_exists=True, **kwargs): """Modify the given object using the Ansible Tower API. Return the object and a boolean value informing us whether or not the record was changed. If `create_on_missing` is True, then an object matching the appropriate unique criteria is not found, then a new object is created. If there are no unique criteria on the model (other than the primary key), then this will always constitute a creation (even if a match exists) unless the primary key is sent. If `fail_on_found` is True, then if an object matching the unique criteria already exists, the operation fails. If `force_on_exists` is True, then if an object is modified based on matching via. unique fields (as opposed to the primary key), other fields are updated based on data sent. If `force_on_exists` is set to False, then the non-unique values are only written in a creation case. """ existing_data = {} # Remove default values (anything where the value is None). # click is unfortunately bad at the way it sends through unspecified # defaults. for key, value in copy(kwargs).items(): if value is None: kwargs.pop(key) if hasattr(value, 'read'): kwargs[key] = value.read() # Determine which record we are writing, if we weren't given a # primary key. if not pk: debug.log('Checking for an existing record.', header='details') existing_data = self._lookup(fail_on_found=fail_on_found, fail_on_missing=not create_on_missing, include_debug_header=False, **kwargs) if existing_data: pk = existing_data['id'] else: # We already know the primary key, but get the existing data. # This allows us to know whether the write made any changes. debug.log('Getting existing record.', header='details') existing_data = self.get(pk) # Sanity check: Are we missing required values? # If we don't have a primary key, then all required values must be # set, and if they're not, it's an error. required_fields = [i.key or i.name for i in self.fields if i.required] missing_fields = [i for i in required_fields if i not in kwargs] if missing_fields and not pk: raise exc.BadRequest('Missing required fields: %s' % ', '.join(missing_fields)) # Sanity check: Do we need to do a write at all? # If `force_on_exists` is False and the record was, in fact, found, # then no action is required. if pk and not force_on_exists: debug.log( 'Record already exists, and --force-on-exists is off; ' 'do nothing.', header='decision', nl=2) answer = OrderedDict(( ('changed', False), ('id', pk), )) answer.update(existing_data) return answer # 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 # Get the URL and method to use for the write. url = self.endpoint method = 'POST' if pk: url += '%d/' % pk method = 'PATCH' # If debugging is on, print the URL and data being sent. debug.log('Writing the record.', header='details') # Actually perform the write. r = getattr(client, method.lower())(url, data=kwargs) # At this point, we know the write succeeded, and we know that data # was changed in the process. answer = OrderedDict(( ('changed', True), ('id', r.json()['id']), )) answer.update(r.json()) return answer
def modify(self, pk=None, create_on_missing=False, **kwargs): """Modify an existing notification template. Not all required configuration-related fields (required according to notification_type) should be provided. Fields in the resource's `identity` tuple can be used in lieu of a primary key for a lookup; in such a case, only other fields are written. To modify unique fields, you must use the primary key for the lookup. =====API DOCS===== Modify an already existing object. :param pk: Primary key of the resource to be modified. :type pk: int :param create_on_missing: Flag that if set, a new object is created if ``pk`` is not set and objects matching the appropriate unique criteria is not found. :type create_on_missing: bool :param `**kwargs`: Keyword arguments which, all together, will be used as PATCH body to modify the resource object. if ``pk`` is not set, key-value pairs of ``**kwargs`` which are also in resource's identity will be used to lookup existing reosource. :returns: A dictionary combining the JSON output of the modified resource, as well as two extra fields: "changed", a flag indicating if the resource is successfully updated; "id", an integer which is the primary key of the updated object. :rtype: dict =====API DOCS===== """ # Create the resource if needed. if pk is None and create_on_missing: try: self.get(**copy.deepcopy(kwargs)) except exc.NotFound: return self.create(**kwargs) # Modify everything except notification type and configuration config_item = self._separate(kwargs) notification_type = kwargs.pop('notification_type', None) debug.log( 'Modify everything except notification type and' ' configuration', header='details') part_result = super(Resource, self).\ modify(pk=pk, create_on_missing=create_on_missing, **kwargs) # Modify notification type and configuration if notification_type is None or \ notification_type == part_result['notification_type']: for item in part_result['notification_configuration']: if item not in config_item or not config_item[item]: to_add = part_result['notification_configuration'][item] if not (to_add == '$encrypted$' and item in Resource.encrypted_fields): config_item[item] = to_add if notification_type is None: kwargs['notification_type'] = part_result['notification_type'] else: kwargs['notification_type'] = notification_type self._configuration(kwargs, config_item) debug.log('Modify notification type and configuration', header='details') result = super(Resource, self).\ modify(pk=pk, create_on_missing=create_on_missing, **kwargs) # Update 'changed' field to give general changed info if 'changed' in result and 'changed' in part_result: result['changed'] = result['changed'] or part_result['changed'] return result
def create(self, fail_on_found=False, force_on_exists=False, **kwargs): """Create a notification template. All required configuration-related fields (required according to notification_type) must be provided. There are two types of notification template creation: isolatedly creating a new notification template and creating a new notification template under a job template. Here the two types are discriminated by whether to provide --job-template option. --status option controls more specific, job-run-status-related association. Fields in the resource's `identity` tuple are used for a lookup; if a match is found, then no-op (unless `force_on_exists` is set) but do not fail (unless `fail_on_found` is set). =====API DOCS===== Create an object. :param fail_on_found: Flag that if set, the operation fails if an object matching the unique criteria already exists. :type fail_on_found: bool :param force_on_exists: Flag that if set, then if a match is found on unique fields, other fields will be updated to the provided values.; If unset, a match causes the request to be a no-op. :type force_on_exists: bool :param `**kwargs`: Keyword arguments which, all together, will be used as POST body to create the resource object. :returns: A dictionary combining the JSON output of the created resource, as well as two extra fields: "changed", a flag indicating if the resource is created successfully; "id", an integer which is the primary key of the created object. :rtype: dict =====API DOCS===== """ config_item = self._separate(kwargs) jt_id = kwargs.pop('job_template', None) status = kwargs.pop('status', 'any') old_endpoint = self.endpoint if jt_id is not None: jt = get_resource('job_template') jt.get(pk=jt_id) try: nt_id = self.get(**copy.deepcopy(kwargs))['id'] except exc.NotFound: pass else: if fail_on_found: raise exc.TowerCLIError('Notification template already ' 'exists and fail-on-found is ' 'switched on. Please use' ' "associate_notification" method' ' of job_template instead.') else: debug.log( 'Notification template already exists, ' 'associating with job template.', header='details') return jt.associate_notification_template(jt_id, nt_id, status=status) self.endpoint = '/job_templates/%d/notification_templates_%s/' %\ (jt_id, status) self._configuration(kwargs, config_item) result = super(Resource, self).create(**kwargs) self.endpoint = old_endpoint return result
def create(self, organization=None, monitor=False, wait=False, timeout=None, fail_on_found=False, force_on_exists=False, **kwargs): """Create a new item of resource, with or w/o org. This would be a shared class with user, but it needs the ability to monitor if the flag is set. =====API DOCS===== Create a project and, if related flags are set, monitor or wait the triggered initial project update. :param monitor: Flag that if set, immediately calls ``monitor`` on the newly triggered project update rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the triggered project update, but do not print while it is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: bool :param fail_on_found: Flag that if set, the operation fails if an object matching the unique criteria already exists. :type fail_on_found: bool :param force_on_exists: Flag that if set, then if a match is found on unique fields, other fields will be updated to the provided values.; If unset, a match causes the request to be a no-op. :type force_on_exists: bool :param `**kwargs`: Keyword arguments which, all together, will be used as POST body to create the resource object. :returns: A dictionary combining the JSON output of the created resource, as well as two extra fields: "changed", a flag indicating if the resource is created successfully; "id", an integer which is the primary key of the created object. :rtype: dict =====API DOCS===== """ if 'job_timeout' in kwargs and 'timeout' not in kwargs: kwargs['timeout'] = kwargs.pop('job_timeout') post_associate = False if organization: # Processing the organization flag depends on version debug.log('Checking Organization Relationship.', header='details') r = client.options('/projects/') if 'organization' in r.json().get('actions', {}).get('POST', {}): kwargs['organization'] = organization else: post_associate = True # First, run the create method, ignoring the organization given answer = super(Resource, self).write(create_on_missing=True, fail_on_found=fail_on_found, force_on_exists=force_on_exists, **kwargs) project_id = answer['id'] # If an organization is given, associate it here if post_associate: # Get the organization from Tower, will lookup name if needed org_resource = get_resource('organization') org_data = org_resource.get(organization) org_pk = org_data['id'] debug.log("associating the project with its organization", header='details', nl=1) org_resource._assoc('projects', org_pk, project_id) # if the monitor flag is set, wait for the SCM to update if monitor and answer.get('changed', False): return self.monitor(pk=None, parent_pk=project_id, timeout=timeout) elif wait and answer.get('changed', False): return self.wait(pk=None, parent_pk=project_id, timeout=timeout) return answer
def update(self, pk=None, create_on_missing=False, monitor=False, wait=False, timeout=None, name=None, organization=None): """Trigger a project update job within Ansible Tower. Only meaningful on non-manual projects. =====API DOCS===== Update the given project. :param pk: Primary key of the project to be updated. :type pk: int :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched project update rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the project update, but do not print while it is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param name: Name of the project to be updated if ``pk`` is not set. :type name: str :param organization: Primary key or name of the organization the project to be updated belonging to if ``pk`` is not set. :type organization: str :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; dictionary of "status" if none of the two flags are on. :rtype: dict :raises tower_cli.exceptions.CannotStartJob: When the project cannot be updated. =====API DOCS===== """ # First, get the appropriate project. # This should be uniquely identified at this point, and if not, then # we just want the error that `get` will throw to bubble up. project = self.get(pk, name=name, organization=organization) pk = project['id'] # Determine whether this project is able to be updated. debug.log('Asking whether the project can be updated.', header='details') result = client.get('/projects/%d/update/' % pk) if not result.json()['can_update']: raise exc.CannotStartJob('Cannot update project.') # Okay, this project can be updated, according to Tower. # Commence the update. debug.log('Updating the project.', header='details') result = client.post('/projects/%d/update/' % pk) project_update_id = result.json()['project_update'] # If we were told to monitor the project update's status, do so. if monitor: return self.monitor(project_update_id, parent_pk=pk, timeout=timeout) elif wait: return self.wait(project_update_id, parent_pk=pk, timeout=timeout) # Return the project update ID. return { 'id': project_update_id, 'changed': True, }
def request(self, method, url, *args, **kwargs): """Make a request to the Ansible Tower API, and return the response. """ # If the URL has the api/vX at the front strip it off # This is common to have if you are extracting a URL from an existing object. # For example, any of the 'related' fields of an object will have this import re url = re.sub("^/?api/v[0-9]+/", "", url) # Piece together the full URL. use_version = not url.startswith('/o/') url = '%s%s' % (self.get_prefix(use_version), url.lstrip('/')) # Ansible Tower expects authenticated requests; add the authentication # from settings if it's provided. kwargs.setdefault( 'auth', BasicTowerAuth(settings.username, settings.password, self)) # POST and PUT requests will send JSON by default; make this # the content_type by default. This makes it such that we don't have # to constantly write that in our code, which gets repetitive. headers = kwargs.get('headers', {}) if method.upper() in ('PATCH', 'POST', 'PUT'): headers.setdefault('Content-Type', 'application/json') kwargs['headers'] = headers # If debugging is on, print the URL and data being sent. debug.log('%s %s' % (method, url), fg='blue', bold=True) if method in ('POST', 'PUT', 'PATCH'): debug.log('Data: %s' % kwargs.get('data', {}), fg='blue', bold=True) if method == 'GET' or kwargs.get('params', None): debug.log('Params: %s' % kwargs.get('params', {}), fg='blue', bold=True) debug.log('') # If this is a JSON request, encode the data value. if headers.get('Content-Type', '') == 'application/json': kwargs['data'] = json.dumps(kwargs.get('data', {})) r = self._make_request(method, url, args, kwargs) # Sanity check: Did the server send back some kind of internal error? # If so, bubble this up. if r.status_code >= 500: raise exc.ServerError('The Tower server sent back a server error. ' 'Please try again later.') # Sanity check: Did we fail to authenticate properly? # If so, fail out now; this is always a failure. if r.status_code == 401: raise exc.AuthError( 'Invalid Tower authentication credentials (HTTP 401).') # Sanity check: Did we get a forbidden response, which means that # the user isn't allowed to do this? Report that. if r.status_code == 403: raise exc.Forbidden( "You don't have permission to do that (HTTP 403).") # Sanity check: Did we get a 404 response? # Requests with primary keys will return a 404 if there is no response, # and we want to consistently trap these. if r.status_code == 404: raise exc.NotFound('The requested object could not be found.') # Sanity check: Did we get a 405 response? # A 405 means we used a method that isn't allowed. Usually this # is a bad request, but it requires special treatment because the # API sends it as a logic error in a few situations (e.g. trying to # cancel a job that isn't running). if r.status_code == 405: raise exc.MethodNotAllowed( "The Tower server says you can't make a request with the " "%s method to that URL (%s)." % (method, url), ) # Sanity check: Did we get some other kind of error? # If so, write an appropriate error message. if r.status_code >= 400: raise exc.BadRequest( 'The Tower server claims it was sent a bad request.\n\n' '%s %s\nParams: %s\nData: %s\n\nResponse: %s' % (method, url, kwargs.get('params', None), kwargs.get('data', None), r.content.decode('utf8'))) # Django REST Framework intelligently prints API keys in the # order that they are defined in the models and serializer. # # We want to preserve this behavior when it is possible to do so # with minimal effort, because while the order has no explicit meaning, # we make some effort to order keys in a convenient manner. # # To this end, make this response into an APIResponse subclass # (defined below), which has a `json` method that doesn't lose key # order. r.__class__ = APIResponse # Return the response object. return r
def update(self, inventory_source, monitor=False, wait=False, timeout=None, **kwargs): """Update the given inventory source. =====API DOCS===== Update the given inventory source. :param inventory_source: Primary key or name of the inventory source to be updated. :type inventory_source: str :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched inventory update rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the inventory update, but do not print while it is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param `**kwargs`: Fields used to override underlyingl inventory source fields when creating and launching an inventory update. :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; dictionary of "status" if none of the two flags are on. :rtype: dict :raises tower_cli.exceptions.BadRequest: When the inventory source cannot be updated. =====API DOCS===== """ # Establish that we are able to update this inventory source # at all. debug.log('Asking whether the inventory source can be updated.', header='details') r = client.get('%s%d/update/' % (self.endpoint, inventory_source)) if not r.json()['can_update']: raise exc.BadRequest( 'Tower says it cannot run an update against this inventory source.' ) # Run the update. debug.log('Updating the inventory source.', header='details') r = client.post('%s%d/update/' % (self.endpoint, inventory_source), data={}) # If we were told to monitor the project update's status, do so. if monitor or wait: inventory_update_id = r.json()['inventory_update'] if monitor: result = self.monitor(inventory_update_id, parent_pk=inventory_source, timeout=timeout) elif wait: result = self.wait(inventory_update_id, parent_pk=inventory_source, timeout=timeout) inventory = client.get( '/inventory_sources/%d/' % result['inventory_source']).json()['inventory'] result['inventory'] = int(inventory) return result # Done. return {'status': 'ok'}
def launch(self, job_template=None, monitor=False, timeout=None, no_input=True, extra_vars=None, **kwargs): """Launch a new job based on a job template. Creates a new job in Ansible Tower, immediately starts 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. tags = kwargs.get('tags', None) use_job_endpoint = kwargs.pop('use_job_endpoint', False) 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 # Initialize an extra_vars list that starts with the job template # preferences first, if they exist extra_vars_list = [] if 'extra_vars' in data and len(data['extra_vars']) > 0: # But only do this for versions before 2.3 debug.log('Getting version of Tower.', header='details') r = client.get('/config/') if LooseVersion(r.json()['version']) < LooseVersion('2.4'): extra_vars_list = [data['extra_vars']] # Add the runtime extra_vars to this list if extra_vars: extra_vars_list += list(extra_vars) # accept tuples # If the job template requires prompting for extra variables, # do so (unless --no-input is set). if data.pop('ask_variables_on_launch', False) and not no_input \ and not extra_vars: # If JT extra_vars are JSON, echo them to user as YAML initial = parser.process_extra_vars([data['extra_vars']], force_json=False) initial = '\n'.join(( '# Specify extra variables (if any) here as YAML.', '# Lines beginning with "#" denote comments.', initial, )) extra_vars = click.edit(initial) or '' if extra_vars != initial: extra_vars_list = [extra_vars] # Data is starting out with JT variables, and we only want to # include extra_vars that come from the algorithm here. data.pop('extra_vars', None) # Replace/populate data fields if prompted. modified = set() for resource in PROMPT_LIST: if data.pop('ask_' + resource + '_on_launch', False) \ and not no_input or use_job_endpoint: resource_object = kwargs.get(resource, None) if type(resource_object) == types.Related: resource_class = get_resource(resource) resource_object = resource_class.get(resource).\ pop('id', None) if resource_object is None: if not use_job_endpoint: debug.log( '{0} is asked at launch but not provided'.format( resource), header='warning') elif resource != 'tags': data[resource] = resource_object modified.add(resource) # Dump extra_vars into JSON string for launching job if len(extra_vars_list) > 0: data['extra_vars'] = parser.process_extra_vars(extra_vars_list, force_json=True) # 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 and not use_job_endpoint: endpoint = '/job_templates/%d/launch/' % jt['id'] if 'extra_vars' in data and len(data['extra_vars']) > 0: start_data['extra_vars'] = data['extra_vars'] if tags: start_data['job_tags'] = data['job_tags'] for resource in PROMPT_LIST: if resource in modified: start_data[resource] = data[resource] 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') self._pop_none(kwargs) kwargs.update(start_data) job_started = client.post(endpoint, data=kwargs) # If this used the /job_template/N/launch/ route, get the job # ID from the result. if supports_job_template_launch and not use_job_endpoint: job_id = job_started.json()['job'] # Get some information about the running job to print result = self.status(pk=job_id, detail=True) result['changed'] = True # 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 result
def launch(self, job_template=None, monitor=False, wait=False, timeout=None, no_input=True, extra_vars=None, **kwargs): """Launch a new job based on a job template. Creates a new job in Ansible Tower, immediately starts it, and returns back an ID in order for its status to be monitored. =====API DOCS===== Launch a new job based on a job template. :param job_template: Primary key or name of the job template to launch new job. :type job_template: str :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched job rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the job, but do not print while job is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param no_input: Flag that if set, suppress any requests for input. :type no_input: bool :param extra_vars: yaml formatted texts that contains extra variables to pass on. :type extra_vars: array of strings :param diff_mode: Specify diff mode for job template to run. :type diff_mode: bool :param limit: Specify host limit for job template to run. :type limit: str :param tags: Specify tagged actions in the playbook to run. :type tags: str :param skip_tags: Specify tagged actions in the playbook to omit. :type skip_tags: str :param job_type: Specify job type for job template to run. :type job_type: str :param verbosity: Specify verbosity of the playbook run. :type verbosity: int :param inventory: Specify machine credential for job template to run. :type inventory: str :param credential: Specify machine credential for job template to run. :type credential: str :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; Result of subsequent ``status`` call if none of the two flags are on. :rtype: dict =====API DOCS===== """ # Get the job template from Ansible Tower. # This is used as the baseline for starting the job. tags = kwargs.get('tags', None) 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 = {} if tags: data['job_tags'] = tags # Initialize an extra_vars list that starts with the job template # preferences first, if they exist extra_vars_list = [] if 'extra_vars' in data and len(data['extra_vars']) > 0: # But only do this for versions before 2.3 debug.log('Getting version of Tower.', header='details') r = client.get('/config/') if LooseVersion(r.json()['version']) < LooseVersion('2.4'): extra_vars_list = [data['extra_vars']] # Add the runtime extra_vars to this list if extra_vars: extra_vars_list += list(extra_vars) # accept tuples # If the job template requires prompting for extra variables, # do so (unless --no-input is set). if jt.get('ask_variables_on_launch', False) and not no_input \ and not extra_vars: # If JT extra_vars are JSON, echo them to user as YAML initial = parser.process_extra_vars([jt['extra_vars']], force_json=False) initial = '\n'.join(( '# Specify extra variables (if any) here as YAML.', '# Lines beginning with "#" denote comments.', initial, )) extra_vars = click.edit(initial) or '' if extra_vars != initial: extra_vars_list = [extra_vars] # Data is starting out with JT variables, and we only want to # include extra_vars that come from the algorithm here. data.pop('extra_vars', None) # Replace/populate data fields if prompted. modified = set() for resource in PROMPT_LIST: if jt.pop('ask_' + resource + '_on_launch', False) and not no_input: resource_object = kwargs.get(resource, None) if type(resource_object) == types.Related: resource_class = get_resource(resource) resource_object = resource_class.get(resource).pop( 'id', None) if resource_object is None: debug.log('{0} is asked at launch but not provided'.format( resource), header='warning') elif resource != 'tags': data[resource] = resource_object modified.add(resource) # Dump extra_vars into JSON string for launching job if len(extra_vars_list) > 0: data['extra_vars'] = parser.process_extra_vars(extra_vars_list, force_json=True) # Create the new job in Ansible Tower. start_data = {} endpoint = '/job_templates/%d/launch/' % jt['id'] if 'extra_vars' in data and len(data['extra_vars']) > 0: start_data['extra_vars'] = data['extra_vars'] if tags: start_data['job_tags'] = data['job_tags'] for resource in PROMPT_LIST: if resource in modified: start_data[resource] = data[resource] # 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') self._pop_none(kwargs) kwargs.update(start_data) job_started = client.post(endpoint, data=kwargs) # Get the job ID from the result. job_id = job_started.json()['id'] # If returning json indicates any ignored fields, display it in # verbose mode. if job_started.text == '': ignored_fields = {} else: ignored_fields = job_started.json().get('ignored_fields', {}) has_ignored_fields = False for key, value in ignored_fields.items(): if value and value != '{}': if not has_ignored_fields: debug.log('List of ignored fields on the server side:', header='detail') has_ignored_fields = True debug.log('{0}: {1}'.format(key, value)) # Get some information about the running job to print result = self.status(pk=job_id, detail=True) result['changed'] = True # 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) elif wait: return self.wait(job_id, timeout=timeout) return result
def launch(self, monitor=False, wait=False, timeout=None, become=False, **kwargs): """Launch a new ad-hoc command. Runs a user-defined command from Ansible Tower, immediately starts it, and returns back an ID in order for its status to be monitored. =====API DOCS===== Launch a new ad-hoc command. :param monitor: Flag that if set, immediately calls ``monitor`` on the newly launched command rather than exiting with a success. :type monitor: bool :param wait: Flag that if set, monitor the status of the job, but do not print while job is in progress. :type wait: bool :param timeout: If provided with ``monitor`` flag set, this attempt will time out after the given number of seconds. :type timeout: int :param become: Flag that if set, privilege escalation will be enabled for this command. :type become: bool :param `**kwargs`: Fields needed to create and launch an ad hoc command. :returns: Result of subsequent ``monitor`` call if ``monitor`` flag is on; Result of subsequent ``wait`` call if ``wait`` flag is on; dictionary of "id" and "changed" if none of the two flags are on. :rtype: dict :raises tower_cli.exceptions.TowerCLIError: When ad hoc commands are not available in Tower backend. =====API DOCS===== """ # This feature only exists for versions 2.2 and up r = client.get('/') if 'ad_hoc_commands' not in r.json(): raise exc.TowerCLIError('Your host is running an outdated version' 'of Ansible Tower that can not run ' 'ad-hoc commands (2.2 or earlier)') # Pop the None arguments because we have no .write() method in # inheritance chain for this type of resource. This is needed self._pop_none(kwargs) # Change the flag to the dictionary format if become: kwargs['become_enabled'] = True # Actually start the command. debug.log('Launching the ad-hoc command.', header='details') result = client.post(self.endpoint, data=kwargs) command = result.json() command_id = command['id'] # If we were told to monitor the command once it started, then call # monitor from here. if monitor: return self.monitor(command_id, timeout=timeout) elif wait: return self.wait(command_id, timeout=timeout) # Return the command ID and other response data answer = OrderedDict(( ('changed', True), ('id', command_id), )) answer.update(result.json()) return answer
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, }
def request(self, method, url, *args, **kwargs): """Make a request to the Ansible Tower API, and return the response. """ # Piece together the full URL. url = '%s%s' % (self.prefix, url.lstrip('/')) # Ansible Tower expects authenticated requests; add the authentication # from settings if it's provided. kwargs.setdefault('auth', (settings.username, settings.password)) # POST and PUT requests will send JSON by default; make this # the content_type by default. This makes it such that we don't have # to constantly write that in our code, which gets repetitive. headers = kwargs.get('headers', {}) if method.upper() in ('PATCH', 'POST', 'PUT'): headers.setdefault('Content-Type', 'application/json') kwargs['headers'] = headers # If debugging is on, print the URL and data being sent. debug.log('%s %s' % (method, url), fg='blue', bold=True) if method in ('POST', 'PUT', 'PATCH'): debug.log('Data: %s' % kwargs.get('data', {}), fg='blue', bold=True) if method == 'GET' or kwargs.get('params', None): debug.log('Params: %s' % kwargs.get('params', {}), fg='blue', bold=True) debug.log('') # If this is a JSON request, encode the data value. if headers.get('Content-Type', '') == 'application/json': kwargs['data'] = json.dumps(kwargs.get('data', {})) # Call the superclass method. try: r = super(Client, self).request(method, url, *args, verify=False, **kwargs) except ConnectionError as ex: if settings.verbose: debug.log('Cannot connect to Tower:', fg='yellow', bold=True) debug.log(str(ex), fg='yellow', bold=True, nl=2) raise exc.ConnectionError( 'There was a network error of some kind trying to connect ' 'to Tower.\n\nThe most common reason for this is a settings ' 'issue; is your "host" value in `tower-cli config` correct?\n' 'Right now it is: "%s".' % settings.host ) # Sanity check: Did the server send back some kind of internal error? # If so, bubble this up. if r.status_code >= 500: raise exc.ServerError('The Tower server sent back a server error. ' 'Please try again later.') # Sanity check: Did we fail to authenticate properly? # If so, fail out now; this is always a failure. if r.status_code == 401: raise exc.AuthError('Invalid Tower authentication credentials.') # Sanity check: Did we get a forbidden response, which means that # the user isn't allowed to do this? Report that. if r.status_code == 403: raise exc.Forbidden("You don't have permission to do that.") # Sanity check: Did we get a 404 response? # Requests with primary keys will return a 404 if there is no response, # and we want to consistently trap these. if r.status_code == 404: raise exc.NotFound('The requested object could not be found.') # Sanity check: Did we get a 405 response? # A 405 means we used a method that isn't allowed. Usually this # is a bad request, but it requires special treatment because the # API sends it as a logic error in a few situations (e.g. trying to # cancel a job that isn't running). if r.status_code == 405: raise exc.MethodNotAllowed( "The Tower server says you can't make a request with the " "%s method to that URL (%s)." % (method, url), ) # Sanity check: Did we get some other kind of error? # If so, write an appropriate error message. if r.status_code >= 400: raise exc.BadRequest( 'The Tower server claims it was sent a bad request.\n\n' '%s %s\nParams: %s\nData: %s\n\nResponse: %s' % (method, url, kwargs.get('params', None), kwargs.get('data', None), r.content.decode('utf8')) ) # Django REST Framework intelligently prints API keys in the # order that they are defined in the models and serializer. # # We want to preserve this behavior when it is possible to do so # with minimal effort, because while the order has no explicit meaning, # we make some effort to order keys in a convenient manner. # # To this end, make this response into an APIResponse subclass # (defined below), which has a `json` method that doesn't lose key # order. r.__class__ = APIResponse # Return the response object. return r