import awxkit

Co-authored-by: Christopher Wang <cwang@ansible.com>
Co-authored-by: Jake McDermott <jmcdermott@ansible.com>
Co-authored-by: Jim Ladd <jladd@redhat.com>
Co-authored-by: Elijah DeLee <kdelee@redhat.com>
Co-authored-by: Alan Rominger <arominge@redhat.com>
Co-authored-by: Yanis Guenane <yanis@guenane.org>
This commit is contained in:
Ryan Petrello
2019-08-08 22:12:31 -04:00
parent 9b836abf1f
commit 9616cc6f78
101 changed files with 10479 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
# Order matters
from .page import * # NOQA
from .base import * # NOQA
from .access_list import * # NOQA
from .api import * # NOQA
from .authtoken import * # NOQA
from .roles import * # NOQA
from .organizations import * # NOQA
from .notifications import * # NOQA
from .notification_templates import * # NOQA
from .users import * # NOQA
from .applications import * # NOQA
from .teams import * # NOQA
from .credentials import * # NOQA
from .unified_jobs import * # NOQA
from .unified_job_templates import * # NOQA
from .projects import * # NOQA
from .inventory import * # NOQA
from .system_job_templates import * # NOQA
from .job_templates import * # NOQA
from .jobs import * # NOQA
from .survey_spec import * # NOQA
from .system_jobs import * # NOQA
from .config import * # NOQA
from .ping import * # NOQA
from .dashboard import * # NOQA
from .activity_stream import * # NOQA
from .schedules import * # NOQA
from .ad_hoc_commands import * # NOQA
from .labels import * # NOQA
from .workflow_job_templates import * # NOQA
from .workflow_job_template_nodes import * # NOQA
from .workflow_jobs import * # NOQA
from .workflow_job_nodes import * # NOQA
from .settings import * # NOQA
from .instances import * # NOQA
from .instance_groups import * # NOQA
from .credential_input_sources import * # NOQA
from .metrics import * # NOQA

View File

@@ -0,0 +1,18 @@
from awxkit.api.resources import resources
from . import users
from . import page
class AccessList(page.PageList, users.User):
pass
page.register_page([resources.organization_access_list,
resources.user_access_list,
resources.inventory_access_list,
resources.group_access_list,
resources.credential_access_list,
resources.project_access_list,
resources.job_template_access_list,
resources.team_access_list], AccessList)

View File

@@ -0,0 +1,20 @@
from awxkit.api.resources import resources
from . import base
from . import page
class ActivityStream(base.Base):
pass
page.register_page(resources.activity, ActivityStream)
class ActivityStreams(page.PageList, ActivityStream):
pass
page.register_page([resources.activity_stream,
resources.object_activity_stream], ActivityStreams)

View File

@@ -0,0 +1,66 @@
from awxkit.utils import update_payload, PseudoNamespace
from awxkit.api.pages import Inventory, Credential
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.utils import not_provided as np
from awxkit.api.resources import resources
from .jobs import UnifiedJob
from . import page
class AdHocCommand(HasCreate, UnifiedJob):
dependencies = [Inventory, Credential]
def relaunch(self, payload={}):
"""Relaunch the command using the related->relaunch endpoint"""
# navigate to relaunch_pg
relaunch_pg = self.get_related('relaunch')
# relaunch the command
result = relaunch_pg.post(payload)
# return the corresponding command_pg
return self.walk(result.url)
def payload(self, inventory, credential, module_name='ping', **kwargs):
payload = PseudoNamespace(inventory=inventory.id,
credential=credential.id,
module_name=module_name)
optional_fields = ('diff_mode', 'extra_vars', 'module_args', 'job_type', 'limit', 'forks',
'verbosity')
return update_payload(payload, optional_fields, kwargs)
def create_payload(self, module_name='ping', module_args=np, job_type=np, limit=np, verbosity=np,
inventory=Inventory, credential=Credential, **kwargs):
self.create_and_update_dependencies(inventory, credential)
payload = self.payload(module_name=module_name, module_args=module_args, job_type=job_type, limit=limit,
verbosity=verbosity, inventory=self.ds.inventory, credential=self.ds.credential,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, module_name='ping', module_args=np, job_type=np, limit=np, verbosity=np,
inventory=Inventory, credential=Credential, **kwargs):
payload = self.create_payload(module_name=module_name, module_args=module_args,
job_type=job_type, limit=limit, verbosity=verbosity,
inventory=inventory, credential=credential, **kwargs)
return self.update_identity(AdHocCommands(self.connection).post(payload))
page.register_page([resources.ad_hoc_command], AdHocCommand)
class AdHocCommands(page.PageList, AdHocCommand):
pass
page.register_page([resources.ad_hoc_commands,
resources.inventory_related_ad_hoc_commands,
resources.group_related_ad_hoc_commands,
resources.host_related_ad_hoc_commands], AdHocCommands)

View File

@@ -0,0 +1,19 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Api(base.Base):
pass
page.register_page(resources.api, Api)
class ApiV2(base.Base):
pass
page.register_page(resources.v2, ApiV2)

View File

@@ -0,0 +1,84 @@
from awxkit.utils import random_title, update_payload, filter_by_class, PseudoNamespace
from awxkit.api.resources import resources
from awxkit.api.pages import Organization
from awxkit.api.mixins import HasCreate, DSAdapter
from . import page
from . import base
class OAuth2Application(HasCreate, base.Base):
dependencies = [Organization]
def payload(self, **kwargs):
payload = PseudoNamespace(name=kwargs.get('name') or 'OAuth2Application - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10),
client_type=kwargs.get('client_type', 'public'),
authorization_grant_type=kwargs.get('authorization_grant_type', 'password'))
if kwargs.get('organization'):
payload.organization = kwargs['organization'].id
optional_fields = ('redirect_uris', 'skip_authorization')
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(self, organization=Organization, **kwargs):
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
organization = self.ds.organization if organization else None
payload = self.payload(organization=organization, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, organization=Organization, **kwargs):
payload = self.create_payload(organization=organization, **kwargs)
return self.update_identity(OAuth2Applications(self.connection).post(payload))
page.register_page((resources.application,
(resources.applications, 'post')), OAuth2Application)
class OAuth2Applications(page.PageList, OAuth2Application):
pass
page.register_page(resources.applications, OAuth2Applications)
class OAuth2AccessToken(HasCreate, base.Base):
optional_dependencies = [OAuth2Application]
def payload(self, **kwargs):
payload = PseudoNamespace(description=kwargs.get('description') or random_title(10),
scope=kwargs.get('scope', 'write'))
if kwargs.get('oauth_2_application'):
payload.application = kwargs['oauth_2_application'].id
optional_fields = ('expires',)
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(self, oauth_2_application=None, **kwargs):
self.create_and_update_dependencies(*filter_by_class((oauth_2_application, OAuth2Application)))
oauth_2_application = self.ds.oauth_2_application if oauth_2_application else None
payload = self.payload(oauth_2_application=oauth_2_application, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, oauth_2_application=None, **kwargs):
payload = self.create_payload(oauth_2_application=oauth_2_application, **kwargs)
return self.update_identity(OAuth2AccessTokens(self.connection).post(payload))
page.register_page((resources.token,
(resources.tokens, 'post')), OAuth2AccessToken)
class OAuth2AccessTokens(page.PageList, OAuth2AccessToken):
pass
page.register_page(resources.tokens, OAuth2AccessTokens)

View File

@@ -0,0 +1,11 @@
from awxkit.api.resources import resources
from . import base
from . import page
class AuthToken(base.Base):
pass
page.register_page(resources.authtoken, AuthToken)

View File

@@ -0,0 +1,234 @@
import collections
import logging
from requests.auth import HTTPBasicAuth
from awxkit.api.pages import (
Page,
get_registered_page,
exception_from_status_code
)
from awxkit.config import config
from awxkit.api.resources import resources
import awxkit.exceptions as exc
log = logging.getLogger(__name__)
class Base(Page):
def silent_delete(self):
"""Delete the object. If it's already deleted, ignore the error"""
try:
if not config.prevent_teardown:
return self.delete()
except (exc.NoContent, exc.NotFound, exc.Forbidden):
pass
def get_object_role(self, role, by_name=False):
"""Lookup and return a related object role by its role field or name.
Args:
----
role (str): The role's `role_field` or name
by_name (bool): Whether to retrieve the role by its name field (default: False)
Examples:
--------
>>> # get the description of the Use role for an inventory
>>> inventory = v2.inventory.create()
>>> use_role_1 = inventory.get_object_role('use_role')
>>> use_role_2 = inventory.get_object_role('use', True)
>>> use_role_1.description
u'Can use the inventory in a job template'
>>> use_role_1.json == use_role_2.json
True
"""
if by_name:
for obj_role in self.related.object_roles.get().results:
if obj_role.name.lower() == role.lower():
return obj_role
raise Exception("Role '{0}' not found for {1.endpoint}".format(role, self))
object_roles = self.get_related('object_roles', role_field=role)
if not object_roles.count == 1:
raise Exception("No role with role_field '{0}' found.".format(role))
return object_roles.results[0]
def set_object_roles(self, agent, *role_names, **kw):
"""Associate related object roles to a User or Team by role names
Args:
----
agent (User or Team): The agent the role is to be (dis)associated with.
*role_names (str): an arbitrary number of role names ('Admin', 'Execute', 'Read', etc.)
**kw:
endpoint (str): The endpoint to use when making the object role association
- 'related_users': use the related users endpoint of the role (default)
- 'related_roles': use the related roles endpoint of the user
disassociate (bool): Indicates whether to disassociate the role with the user (default: False)
Examples:
--------
# create a user that is an organization admin with use and
# update roles on an inventory
>>> organization = v2.organization.create()
>>> inventory = v2.inventory.create()
>>> user = v2.user.create()
>>> organization.set_object_roles(user, 'admin')
>>> inventory.set_object_roles(user, 'use', 'update')
"""
from awxkit.api.pages import User, Team
endpoint = kw.get('endpoint', 'related_users')
disassociate = kw.get('disassociate', False)
if not any([isinstance(agent, agent_type) for agent_type in (User, Team)]):
raise ValueError('Invalid agent type {0.__class__.__name__}'.format(agent))
if endpoint not in ('related_users', 'related_roles'):
raise ValueError('Invalid role association endpoint: {0}'.format(endpoint))
object_roles = [self.get_object_role(name, by_name=True) for name in role_names]
payload = {}
for role in object_roles:
if endpoint == 'related_users':
payload['id'] = agent.id
if isinstance(agent, User):
endpoint_model = role.related.users
elif isinstance(agent, Team):
endpoint_model = role.related.teams
else:
raise RuntimeError("Unhandled type for agent: {0.__class__.__name__}.".format(agent))
elif endpoint == 'related_roles':
payload['id'] = role.id
endpoint_model = agent.related.roles
else:
raise RuntimeError('Invalid role association endpoint')
if disassociate:
payload['disassociate'] = True
try:
endpoint_model.post(payload)
except exc.NoContent: # desired exception on successful (dis)association
pass
return True
@property
def object_roles(self):
from awxkit.api.pages import Roles, Role
url = self.get().json.related.object_roles
for obj_role in Roles(self.connection, endpoint=url).get().json.results:
yield Role(self.connection, endpoint=obj_role.url).get()
def get_authtoken(self, username='', password=''):
default_cred = config.credentials.default
payload = dict(username=username or default_cred.username,
password=password or default_cred.password)
auth_url = resources.authtoken
return get_registered_page(auth_url)(self.connection, endpoint=auth_url).post(payload).token
def load_authtoken(self, username='', password=''):
self.connection.login(token=self.get_authtoken(username, password))
return self
load_default_authtoken = load_authtoken
def get_oauth2_token(self, username='', password='', client_id=None,
client_secret=None, scope='write'):
default_cred = config.credentials.default
username = username or default_cred.username
password = password or default_cred.password
req = collections.namedtuple('req', 'headers')({})
if client_id and client_secret:
HTTPBasicAuth(client_id, client_secret)(req)
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
resp = self.connection.post(
'/api/o/token/',
data={
"grant_type": "password",
"username": username,
"password": password,
"scope": scope
},
headers=req.headers
)
elif client_id:
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
resp = self.connection.post(
'/api/o/token/',
data={
"grant_type": "password",
"username": username,
"password": password,
"client_id": client_id,
"scope": scope
},
headers=req.headers
)
else:
HTTPBasicAuth(username, password)(req)
resp = self.connection.post(
'/api/v2/users/{}/personal_tokens/'.format(username),
json={
"description": "Tower CLI",
"application": None,
"scope": scope
},
headers=req.headers
)
if resp.ok:
result = resp.json()
if client_id:
return result.pop('access_token', None)
else:
return result.pop('token', None)
else:
raise exception_from_status_code(resp.status_code)
def load_session(self, username='', password=''):
default_cred = config.credentials.default
self.connection.login(username=username or default_cred.username,
password=password or default_cred.password,
**self.connection.get_session_requirements())
return self
def cleanup(self):
log.debug('{0.endpoint} cleaning up.'.format(self))
return self._cleanup(self.delete)
def silent_cleanup(self):
log.debug('{0.endpoint} silently cleaning up.'.format(self))
return self._cleanup(self.silent_delete)
def _cleanup(self, delete_method):
try:
delete_method()
except exc.Forbidden as e:
if e.msg == {'detail': 'Cannot delete running job resource.'}:
self.cancel()
self.wait_until_completed(interval=1, timeout=30, since_job_created=False)
delete_method()
else:
raise
except exc.Conflict as e:
conflict = e.msg.get('conflict', e.msg.get('error', ''))
if "running jobs" in conflict:
active_jobs = e.msg.get('active_jobs', []) # [{type: id},], not page containing
jobs = []
for active_job in active_jobs:
job_type = active_job['type']
endpoint = '/api/v2/{}s/{}/'.format(job_type, active_job['id'])
job = self.walk(endpoint)
jobs.append(job)
job.cancel()
for job in jobs:
job.wait_until_completed(interval=1, timeout=30, since_job_created=False)
delete_method()
else:
raise

View File

@@ -0,0 +1,55 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Config(base.Base):
@property
def is_aws_license(self):
return self.license_info.get('is_aws', False) or \
'ami-id' in self.license_info or \
'instance-id' in self.license_info
@property
def is_demo_license(self):
return self.license_info.get('demo', False) or \
self.license_info.get('key_present', False)
@property
def is_valid_license(self):
return self.license_info.get('valid_key', False) and \
'license_key' in self.license_info and \
'instance_count' in self.license_info
@property
def is_trial_license(self):
return self.is_valid_license and \
self.license_info.get('trial', False)
@property
def is_awx_license(self):
return self.license_info.get('license_type', None) == 'open'
@property
def is_legacy_license(self):
return self.is_valid_license and \
self.license_info.get('license_type', None) == 'legacy'
@property
def is_basic_license(self):
return self.is_valid_license and \
self.license_info.get('license_type', None) == 'basic'
@property
def is_enterprise_license(self):
return self.is_valid_license and \
self.license_info.get('license_type', None) == 'enterprise'
@property
def features(self):
"""returns a list of enabled license features"""
return [k for k, v in self.license_info.get('features', {}).items() if v]
page.register_page(resources.config, Config)

View File

@@ -0,0 +1,20 @@
from awxkit.api.resources import resources
from . import base
from . import page
class CredentialInputSource(base.Base):
pass
page.register_page(resources.credential_input_source, CredentialInputSource)
class CredentialInputSources(page.PageList, CredentialInputSource):
pass
page.register_page([resources.credential_input_sources,
resources.related_input_sources], CredentialInputSources)

View File

@@ -0,0 +1,336 @@
import logging
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from awxkit.utils import (
cloud_types,
filter_by_class,
not_provided,
random_title,
update_payload,
PseudoNamespace)
from awxkit.api.pages import Organization, User, Team
from awxkit.api.mixins import HasCreate, HasCopy, DSAdapter
from awxkit.api.resources import resources
from awxkit.config import config
from . import base
from . import page
log = logging.getLogger(__name__)
credential_input_fields = (
'authorize_password',
'become_method',
'become_password',
'become_username',
'client',
'cloud_environment',
'domain',
'host',
'password',
'project_id',
'project_name',
'secret',
'ssh_key_data',
'ssh_key_unlock',
'subscription',
'tenant',
'username',
'vault_password',
'vault_id')
def generate_private_key():
key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
def config_cred_from_kind(kind):
try:
if kind == 'net':
config_cred = config.credentials.network
elif kind in cloud_types:
if kind == 'azure_rm':
config_cred = config.credentials.cloud.azure
else:
config_cred = config.credentials.cloud[kind]
else:
config_cred = config.credentials[kind]
return config_cred
except (KeyError, AttributeError):
return PseudoNamespace()
credential_type_name_to_config_kind_map = {
'amazon web services': 'aws',
'ansible tower': 'tower',
'google compute engine': 'gce',
'insights': 'insights',
'microsoft azure classic (deprecated)': 'azure_classic',
'microsoft azure resource manager': 'azure_rm',
'network': 'net',
'openstack': 'OpenStack',
'red hat virtualization': 'rhv',
'red hat cloudforms': 'cloudforms',
'red hat satellite 6': 'satellite6',
'source control': 'scm',
'machine': 'ssh',
'vault': 'vault',
'vmware vcenter': 'vmware'}
config_kind_to_credential_type_name_map = {
kind: name
for name, kind in credential_type_name_to_config_kind_map.items()}
def kind_and_config_cred_from_credential_type(credential_type):
kind = ''
if not credential_type.managed_by_tower:
return kind, PseudoNamespace()
try:
if credential_type.kind == 'net':
config_cred = config.credentials.network
kind = 'net'
elif credential_type.kind == 'cloud':
kind = credential_type_name_to_config_kind_map[credential_type.name.lower(
)]
config_kind = kind if kind != 'azure_rm' else 'azure'
config_cred = config.credentials.cloud[config_kind]
else:
kind = credential_type.kind.lower()
config_cred = config.credentials[kind]
return kind, config_cred
except (KeyError, AttributeError):
return kind, PseudoNamespace()
def get_payload_field_and_value_from_kwargs_or_config_cred(
field, kind, kwargs, config_cred):
if field in (
'project_id',
'project_name'): # Needed to prevent Project kwarg collision
config_field = 'project'
elif field == 'subscription' and 'azure' in kind:
config_field = 'subscription_id'
elif field == 'username' and kind == 'azure_ad':
config_field = 'ad_user'
elif field == 'client':
config_field = 'client_id'
elif field == 'authorize_password':
config_field = 'authorize'
else:
config_field = field
value = kwargs.get(field, config_cred.get(config_field, not_provided))
if field in ('project_id', 'project_name'):
field = 'project'
return field, value
class CredentialType(HasCreate, base.Base):
def silent_delete(self):
if not self.managed_by_tower:
return super(CredentialType, self).silent_delete()
def payload(self, kind='cloud', **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'CredentialType - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
kind=kind)
fields = ('inputs', 'injectors')
update_payload(payload, fields, kwargs)
return payload
def create_payload(self, kind='cloud', **kwargs):
payload = self.payload(kind=kind, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, kind='cloud', **kwargs):
payload = self.create_payload(kind=kind, **kwargs)
return self.update_identity(
CredentialTypes(
self.connection).post(payload))
page.register_page([resources.credential_type,
(resources.credential_types, 'post')], CredentialType)
class CredentialTypes(page.PageList, CredentialType):
pass
page.register_page(resources.credential_types, CredentialTypes)
class Credential(HasCopy, HasCreate, base.Base):
dependencies = [CredentialType]
optional_dependencies = [Organization, User, Team]
def payload(
self,
credential_type,
user=None,
team=None,
organization=None,
inputs=None,
**kwargs):
if not any((user, team, organization)):
raise TypeError(
'{0.__class__.__name__} requires user, team, and/or organization instances.'.format(self))
if inputs is None:
inputs = {}
payload = PseudoNamespace(
name=kwargs.get('name') or 'Credential - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
credential_type=credential_type.id,
inputs=inputs)
if user:
payload.user = user.id
if team:
payload.team = team.id
if organization:
payload.organization = organization.id
kind, config_cred = kind_and_config_cred_from_credential_type(
credential_type)
for field in credential_input_fields:
field, value = get_payload_field_and_value_from_kwargs_or_config_cred(
field, kind, inputs or kwargs, config_cred)
if value != not_provided:
payload.inputs[field] = value
if kind == 'net':
payload.inputs.authorize = inputs.get(
'authorize', bool(inputs.get('authorize_password')))
if kind in ('ssh', 'net') and 'ssh_key_data' not in payload.inputs:
payload.inputs.ssh_key_data = inputs.get(
'ssh_key_data', generate_private_key())
return payload
def create_payload(
self,
credential_type=CredentialType,
user=None,
team=None,
organization=Organization,
inputs=None,
**kwargs):
if isinstance(credential_type, int):
# if an int was passed, it is assumed to be the pk id of a
# credential type
credential_type = CredentialTypes(
self.connection).get(id=credential_type).results.pop()
if credential_type == CredentialType:
kind = kwargs.pop('kind', 'ssh')
if kind in ('openstack', 'openstack_v3'):
credential_type_name = 'OpenStack'
if inputs is None:
if kind == 'openstack_v3':
inputs = config.credentials.cloud['openstack_v3']
else:
inputs = config.credentials.cloud['openstack']
else:
credential_type_name = config_kind_to_credential_type_name_map[kind]
credential_type = CredentialTypes(
self.connection).get(
managed_by_tower=True,
name__icontains=credential_type_name).results.pop()
credential_type, organization, user, team = filter_by_class(
(credential_type, CredentialType), (organization, Organization), (user, User), (team, Team))
if not any((user, team, organization)):
organization = Organization
self.create_and_update_dependencies(
credential_type, organization, user, team)
user = self.ds.user if user else None
team = self.ds.team if team else None
organization = self.ds.organization if organization else None
payload = self.payload(
self.ds.credential_type,
user=user,
team=team,
organization=organization,
inputs=inputs,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
credential_type=CredentialType,
user=None,
team=None,
organization=Organization,
inputs=None,
**kwargs):
payload = self.create_payload(
credential_type=credential_type,
user=user,
team=team,
organization=organization,
inputs=inputs,
**kwargs)
return self.update_identity(
Credentials(
self.connection)).post(payload)
@property
def expected_passwords_needed_to_start(self):
"""Return a list of expected passwords needed to start a job using this credential."""
passwords = []
for field in (
'password',
'become_password',
'ssh_key_unlock',
'vault_password'):
if getattr(self.inputs, field, None) == 'ASK':
if field == 'password':
passwords.append('ssh_password')
else:
passwords.append(field)
return passwords
page.register_page([resources.credential,
(resources.credentials, 'post'),
(resources.credential_copy, 'post')], Credential)
class Credentials(page.PageList, Credential):
pass
page.register_page([resources.credentials,
resources.related_credentials,
resources.job_extra_credentials,
resources.job_template_extra_credentials],
Credentials)

View File

@@ -0,0 +1,11 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Dashboard(base.Base):
pass
page.register_page(resources.dashboard, Dashboard)

View File

@@ -0,0 +1,45 @@
from awxkit.utils import PseudoNamespace, random_title, suppress, update_payload
from awxkit.api.resources import resources
from awxkit.api.mixins import HasCreate
import awxkit.exceptions as exc
from . import base
from . import page
class InstanceGroup(HasCreate, base.Base):
def add_instance(self, instance):
with suppress(exc.NoContent):
self.related.instances.post(dict(id=instance.id))
def remove_instance(self, instance):
with suppress(exc.NoContent):
self.related.instances.post(dict(id=instance.id, disassociate=True))
def payload(self, **kwargs):
payload = PseudoNamespace(name=kwargs.get('name') or
'Instance Group - {}'.format(random_title()))
fields = ('policy_instance_percentage', 'policy_instance_minimum', 'policy_instance_list')
update_payload(payload, fields, kwargs)
return payload
def create_payload(self, name='', **kwargs):
payload = self.payload(name=name, **kwargs)
return payload
def create(self, name='', **kwargs):
payload = self.create_payload(name=name, **kwargs)
return self.update_identity(InstanceGroups(self.connection).post(payload))
page.register_page([resources.instance_group,
(resources.instance_groups, 'post')], InstanceGroup)
class InstanceGroups(page.PageList, InstanceGroup):
pass
page.register_page([resources.instance_groups,
resources.related_instance_groups], InstanceGroups)

View File

@@ -0,0 +1,20 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Instance(base.Base):
pass
page.register_page(resources.instance, Instance)
class Instances(page.PageList, Instance):
pass
page.register_page([resources.instances,
resources.related_instances], Instances)

View File

@@ -0,0 +1,684 @@
import logging
import json
import re
from awxkit.api.pages import (
Credential,
Organization,
Project,
UnifiedJob,
UnifiedJobTemplate
)
from awxkit.utils import (
filter_by_class,
random_title,
update_payload,
suppress,
not_provided,
PseudoNamespace,
poll_until,
random_utf8
)
from awxkit.api.mixins import DSAdapter, HasCreate, HasInstanceGroups, HasNotifications, HasVariables, HasCopy
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from . import base
from . import page
log = logging.getLogger(__name__)
class Inventory(HasCopy, HasCreate, HasInstanceGroups, HasVariables, base.Base):
dependencies = [Organization]
def print_ini(self):
"""Print an ini version of the inventory"""
output = list()
inv_dict = self.related.script.get(hostvars=1).json
for group in inv_dict.keys():
if group == '_meta':
continue
# output host groups
output.append('[%s]' % group)
for host in inv_dict[group].get('hosts', []):
# FIXME ... include hostvars
output.append(host)
output.append('') # newline
# output child groups
if inv_dict[group].get('children', []):
output.append('[%s:children]' % group)
for child in inv_dict[group].get('children', []):
output.append(child)
output.append('') # newline
# output group vars
if inv_dict[group].get('vars', {}).items():
output.append('[%s:vars]' % group)
for k, v in inv_dict[group].get('vars', {}).items():
output.append('%s=%s' % (k, v))
output.append('') # newline
print('\n'.join(output))
def payload(self, organization, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Inventory - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
organization=organization.id)
optional_fields = (
'host_filter',
'insights_credential',
'kind',
'variables')
update_payload(payload, optional_fields, kwargs)
if 'variables' in payload and isinstance(payload.variables, dict):
payload.variables = json.dumps(payload.variables)
if 'insights_credential' in payload and isinstance(
payload.insights_credential, Credential):
payload.insights_credential = payload.insights_credential.id
return payload
def create_payload(
self,
name='',
description='',
organization=Organization,
**kwargs):
self.create_and_update_dependencies(organization)
payload = self.payload(
name=name,
description=description,
organization=self.ds.organization,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
organization=Organization,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
organization=organization,
**kwargs)
return self.update_identity(
Inventories(
self.connection).post(payload))
def add_host(self, host=None):
if host is None:
return self.related.hosts.create(inventory=self)
if isinstance(host, base.Base):
host = host.json
with suppress(exc.NoContent):
self.related.hosts.post(host)
return host
def wait_until_deleted(self):
def _wait():
try:
self.get()
except exc.NotFound:
return True
poll_until(_wait, interval=1, timeout=60)
def update_inventory_sources(self, wait=False):
response = self.related.update_inventory_sources.post()
source_ids = [entry['inventory_source']
for entry in response if entry['status'] == 'started']
inv_updates = []
for source_id in source_ids:
inv_source = self.related.inventory_sources.get(
id=source_id).results.pop()
inv_updates.append(inv_source.related.current_job.get())
if wait:
for update in inv_updates:
update.wait_until_completed()
return inv_updates
page.register_page([resources.inventory,
(resources.inventories, 'post'),
(resources.inventory_copy, 'post')], Inventory)
class Inventories(page.PageList, Inventory):
pass
page.register_page([resources.inventories,
resources.related_inventories], Inventories)
class InventoryScript(HasCopy, HasCreate, base.Base):
dependencies = [Organization]
def payload(self, organization, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Inventory Script - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
organization=organization.id,
script=kwargs.get('script') or self._generate_script())
return payload
def create_payload(
self,
name='',
description='',
organization=Organization,
script='',
**kwargs):
self.create_and_update_dependencies(organization)
payload = self.payload(
name=name,
description=description,
organization=self.ds.organization,
script=script,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
organization=Organization,
script='',
**kwargs):
payload = self.create_payload(
name=name,
description=description,
organization=organization,
script=script,
**kwargs)
return self.update_identity(
InventoryScripts(
self.connection).post(payload))
def _generate_script(self):
script = '\n'.join([
'#!/usr/bin/env python',
'# -*- coding: utf-8 -*-',
'import json',
'inventory = dict()',
'inventory["{0}"] = dict()',
'inventory["{0}"]["hosts"] = list()',
'inventory["{0}"]["hosts"].append("{1}")',
'inventory["{0}"]["hosts"].append("{2}")',
'inventory["{0}"]["hosts"].append("{3}")',
'inventory["{0}"]["hosts"].append("{4}")',
'inventory["{0}"]["hosts"].append("{5}")',
'inventory["{0}"]["vars"] = dict(ansible_host="127.0.0.1", ansible_connection="local")',
'print(json.dumps(inventory))'
])
group_name = re.sub(r"[\']", "", "group-{}".format(random_utf8()))
host_names = [
re.sub(
r"[\':]",
"",
"host_{}".format(
random_utf8())) for _ in range(5)]
return script.format(group_name, *host_names)
page.register_page([resources.inventory_script,
(resources.inventory_scripts, 'post'),
(resources.inventory_script_copy, 'post')], InventoryScript)
class InventoryScripts(page.PageList, InventoryScript):
pass
page.register_page([resources.inventory_scripts], InventoryScripts)
class Group(HasCreate, HasVariables, base.Base):
dependencies = [Inventory]
optional_dependencies = [Credential, InventoryScript]
@property
def is_root_group(self):
"""Returns whether the current group is a top-level root group in the inventory"""
return self.related.inventory.get().related.root_groups.get(id=self.id).count == 1
def get_parents(self):
"""Inspects the API and returns all groups that include the current group as a child."""
return Groups(self.connection).get(children=self.id).results
def payload(self, inventory, credential=None, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Group{}'.format(
random_title(
non_ascii=False)),
description=kwargs.get('description') or random_title(10),
inventory=inventory.id)
if credential:
payload.credential = credential.id
update_payload(payload, ('variables',), kwargs)
if 'variables' in payload and isinstance(payload.variables, dict):
payload.variables = json.dumps(payload.variables)
return payload
def create_payload(
self,
name='',
description='',
inventory=Inventory,
credential=None,
source_script=None,
**kwargs):
credential, source_script = filter_by_class(
(credential, Credential), (source_script, InventoryScript))
self.create_and_update_dependencies(
inventory, credential, source_script)
credential = self.ds.credential if credential else None
payload = self.payload(
inventory=self.ds.inventory,
credential=credential,
name=name,
description=description,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', inventory=Inventory, **kwargs):
payload = self.create_payload(
name=name,
description=description,
inventory=inventory,
**kwargs)
parent = kwargs.get('parent', None) # parent must be a Group instance
resource = parent.related.children if parent else Groups(
self.connection)
return self.update_identity(resource.post(payload))
def add_host(self, host=None):
if host is None:
host = self.related.hosts.create(inventory=self.ds.inventory)
with suppress(exc.NoContent):
host.related.groups.post(dict(id=self.id))
return host
if isinstance(host, base.Base):
host = host.json
with suppress(exc.NoContent):
self.related.hosts.post(host)
return host
def add_group(self, group):
if isinstance(group, page.Page):
group = group.json
with suppress(exc.NoContent):
self.related.children.post(group)
def remove_group(self, group):
if isinstance(group, page.Page):
group = group.json
with suppress(exc.NoContent):
self.related.children.post(dict(id=group.id, disassociate=True))
page.register_page([resources.group,
(resources.groups, 'post')], Group)
class Groups(page.PageList, Group):
pass
page.register_page([resources.groups,
resources.host_groups,
resources.inventory_related_groups,
resources.inventory_related_root_groups,
resources.group_children,
resources.group_potential_children], Groups)
class Host(HasCreate, HasVariables, base.Base):
dependencies = [Inventory]
def payload(self, inventory, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Host{}'.format(
random_title(
non_ascii=False)),
description=kwargs.get('description') or random_title(10),
inventory=inventory.id)
optional_fields = ('enabled', 'instance_id')
update_payload(payload, optional_fields, kwargs)
variables = kwargs.get('variables', not_provided)
if variables is None:
variables = dict(
ansible_host='127.0.0.1',
ansible_connection='local')
if variables != not_provided:
if isinstance(variables, dict):
variables = json.dumps(variables)
payload.variables = variables
return payload
def create_payload(
self,
name='',
description='',
variables=None,
inventory=Inventory,
**kwargs):
self.create_and_update_dependencies(
*filter_by_class((inventory, Inventory)))
payload = self.payload(
inventory=self.ds.inventory,
name=name,
description=description,
variables=variables,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
variables=None,
inventory=Inventory,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
variables=variables,
inventory=inventory,
**kwargs)
return self.update_identity(Hosts(self.connection).post(payload))
page.register_page([resources.host,
(resources.hosts, 'post')], Host)
class Hosts(page.PageList, Host):
pass
page.register_page([resources.hosts,
resources.group_related_hosts,
resources.inventory_related_hosts,
resources.inventory_sources_related_hosts], Hosts)
class FactVersion(base.Base):
pass
page.register_page(resources.host_related_fact_version, FactVersion)
class FactVersions(page.PageList, FactVersion):
@property
def count(self):
return len(self.results)
page.register_page(resources.host_related_fact_versions, FactVersions)
class FactView(base.Base):
pass
page.register_page(resources.fact_view, FactView)
class InventorySource(HasCreate, HasNotifications, UnifiedJobTemplate):
optional_schedule_fields = tuple()
dependencies = [Inventory]
optional_dependencies = [Credential, InventoryScript, Project]
def payload(
self,
inventory,
source='custom',
credential=None,
source_script=None,
project=None,
**kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'InventorySource - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
inventory=inventory.id,
source=source)
if credential:
payload.credential = credential.id
if source_script:
payload.source_script = source_script.id
if project:
payload.source_project = project.id
optional_fields = (
'group_by',
'instance_filters',
'source_path',
'source_regions',
'source_vars',
'timeout',
'overwrite',
'overwrite_vars',
'update_cache_timeout',
'update_on_launch',
'update_on_project_update',
'verbosity')
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(
self,
name='',
description='',
source='custom',
inventory=Inventory,
credential=None,
source_script=InventoryScript,
project=None,
**kwargs):
if source != 'custom' and source_script == InventoryScript:
source_script = None
if source == 'scm':
kwargs.setdefault('overwrite_vars', True)
if project is None:
project = Project
inventory, credential, source_script, project = filter_by_class((inventory, Inventory),
(credential, Credential),
(source_script, InventoryScript),
(project, Project))
self.create_and_update_dependencies(
inventory, credential, source_script, project)
if credential:
credential = self.ds.credential
if source_script:
source_script = self.ds.inventory_script
if project:
project = self.ds.project
payload = self.payload(
inventory=self.ds.inventory,
source=source,
credential=credential,
source_script=source_script,
project=project,
name=name,
description=description,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
source='custom',
inventory=Inventory,
credential=None,
source_script=InventoryScript,
project=None,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
source=source,
inventory=inventory,
credential=credential,
source_script=source_script,
project=project,
**kwargs)
return self.update_identity(
InventorySources(
self.connection).post(payload))
def update(self):
"""Update the inventory_source using related->update endpoint"""
# get related->launch
update_pg = self.get_related('update')
# assert can_update == True
assert update_pg.can_update, \
"The specified inventory_source (id:%s) is not able to update (can_update:%s)" % \
(self.id, update_pg.can_update)
# start the inventory_update
result = update_pg.post()
# assert JSON response
assert 'inventory_update' in result.json, \
"Unexpected JSON response when starting an inventory_update.\n%s" % \
json.dumps(result.json, indent=2)
# locate and return the inventory_update
jobs_pg = self.related.inventory_updates.get(
id=result.json['inventory_update'])
assert jobs_pg.count == 1, \
"An inventory_update started (id:%s) but job not found in response at %s/inventory_updates/" % \
(result.json['inventory_update'], self.url)
return jobs_pg.results[0]
@property
def is_successful(self):
"""An inventory_source is considered successful when source != "" and super().is_successful ."""
return self.source != "" and super(
InventorySource, self).is_successful
def add_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, associate=True))
def remove_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, disassociate=True))
page.register_page([resources.inventory_source,
(resources.inventory_sources, 'post')], InventorySource)
class InventorySources(page.PageList, InventorySource):
pass
page.register_page([resources.inventory_sources,
resources.related_inventory_sources],
InventorySources)
class InventorySourceGroups(page.PageList, Group):
pass
page.register_page(
resources.inventory_sources_related_groups,
InventorySourceGroups)
class InventorySourceUpdate(base.Base):
pass
page.register_page([resources.inventory_sources_related_update,
resources.inventory_related_update_inventory_sources],
InventorySourceUpdate)
class InventoryUpdate(UnifiedJob):
pass
page.register_page(resources.inventory_update, InventoryUpdate)
class InventoryUpdates(page.PageList, InventoryUpdate):
pass
page.register_page([resources.inventory_updates,
resources.inventory_source_updates,
resources.project_update_scm_inventory_updates],
InventoryUpdates)
class InventoryUpdateCancel(base.Base):
pass
page.register_page(resources.inventory_update_cancel, InventoryUpdateCancel)

View File

@@ -0,0 +1,235 @@
import json
from awxkit.utils import (
filter_by_class,
not_provided,
random_title,
suppress,
update_payload,
PseudoNamespace)
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from . import base
from . import page
class JobTemplate(
HasCopy,
HasCreate,
HasInstanceGroups,
HasNotifications,
HasSurvey,
UnifiedJobTemplate):
optional_dependencies = [Inventory, Credential, Project]
def launch(self, payload={}):
"""Launch the job_template using related->launch endpoint."""
# get related->launch
launch_pg = self.get_related('launch')
# launch the job_template
result = launch_pg.post(payload)
# return job
if result.json['type'] == 'job':
jobs_pg = self.get_related('jobs', id=result.json['job'])
assert jobs_pg.count == 1, \
"job_template launched (id:%s) but job not found in response at %s/jobs/" % \
(result.json['job'], self.url)
return jobs_pg.results[0]
elif result.json['type'] == 'workflow_job':
slice_workflow_jobs = self.get_related(
'slice_workflow_jobs', id=result.json['id'])
assert slice_workflow_jobs.count == 1, (
"job_template launched sliced job (id:%s) but not found in related %s/slice_workflow_jobs/" %
(result.json['id'], self.url)
)
return slice_workflow_jobs.results[0]
else:
raise RuntimeError('Unexpected type of job template spawned job.')
def payload(self, job_type='run', playbook='ping.yml', **kwargs):
name = kwargs.get('name') or 'JobTemplate - {}'.format(random_title())
description = kwargs.get('description') or random_title(10)
payload = PseudoNamespace(
name=name,
description=description,
job_type=job_type)
optional_fields = (
'ask_scm_branch_on_launch',
'ask_credential_on_launch',
'ask_diff_mode_on_launch',
'ask_inventory_on_launch',
'ask_job_type_on_launch',
'ask_limit_on_launch',
'ask_skip_tags_on_launch',
'ask_tags_on_launch',
'ask_variables_on_launch',
'ask_verbosity_on_launch',
'allow_simultaneous',
'become_enabled',
'diff_mode',
'force_handlers',
'forks',
'host_config_key',
'job_tags',
'limit',
'skip_tags',
'start_at_task',
'survey_enabled',
'timeout',
'use_fact_cache',
'vault_credential',
'verbosity',
'job_slice_count',
'scm_branch')
update_payload(payload, optional_fields, kwargs)
extra_vars = kwargs.get('extra_vars', not_provided)
if extra_vars != not_provided:
if isinstance(extra_vars, dict):
extra_vars = json.dumps(extra_vars)
payload.update(extra_vars=extra_vars)
if kwargs.get('project'):
payload.update(project=kwargs.get('project').id, playbook=playbook)
if kwargs.get('inventory'):
payload.update(inventory=kwargs.get('inventory').id)
if kwargs.get('credential'):
payload.update(credential=kwargs.get('credential').id)
return payload
def add_label(self, label):
if isinstance(label, page.Page):
label = label.json
with suppress(exc.NoContent):
self.related.labels.post(label)
def create_payload(
self,
name='',
description='',
job_type='run',
playbook='ping.yml',
credential=Credential,
inventory=Inventory,
project=None,
**kwargs):
if not project and job_type != 'scan':
project = Project
if not inventory and not kwargs.get('ask_inventory_on_launch', False):
inventory = Inventory
self.create_and_update_dependencies(
*
filter_by_class(
(credential,
Credential),
(inventory,
Inventory),
(project,
Project)))
project = self.ds.project if project else None
inventory = self.ds.inventory if inventory else None
credential = self.ds.credential if credential else None
payload = self.payload(
name=name,
description=description,
job_type=job_type,
playbook=playbook,
credential=credential,
inventory=inventory,
project=project,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload, credential
def create(
self,
name='',
description='',
job_type='run',
playbook='ping.yml',
credential=Credential,
inventory=Inventory,
project=None,
**kwargs):
payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
playbook=playbook, credential=credential, inventory=inventory,
project=project, **kwargs)
ret = self.update_identity(
JobTemplates(
self.connection).post(payload))
if credential:
with suppress(exc.NoContent):
self.related.credentials.post(dict(id=credential.id))
if 'vault_credential' in kwargs:
with suppress(exc.NoContent):
if not isinstance(kwargs['vault_credential'], int):
raise ValueError(
"Expected 'vault_credential' value to be an integer, the id of the desired vault credential")
self.related.credentials.post(
dict(id=kwargs['vault_credential']))
return ret
def add_extra_credential(self, credential):
with suppress(exc.NoContent):
self.related.extra_credentials.post(
dict(id=credential.id, associate=True))
def remove_extra_credential(self, credential):
with suppress(exc.NoContent):
self.related.extra_credentials.post(
dict(id=credential.id, disassociate=True))
def add_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, associate=True))
def remove_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, disassociate=True))
def remove_all_credentials(self):
for cred in self.related.credentials.get().results:
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=cred.id, disassociate=True))
page.register_page([resources.job_template,
(resources.job_templates, 'post'),
(resources.job_template_copy, 'post')], JobTemplate)
class JobTemplates(page.PageList, JobTemplate):
pass
page.register_page([resources.job_templates,
resources.related_job_templates], JobTemplates)
class JobTemplateCallback(base.Base):
pass
page.register_page(resources.job_template_callback, JobTemplateCallback)
class JobTemplateLaunch(base.Base):
pass
page.register_page(resources.job_template_launch, JobTemplateLaunch)

View File

@@ -0,0 +1,117 @@
from awxkit.api.pages import UnifiedJob
from awxkit.api.resources import resources
from . import base
from . import page
class Job(UnifiedJob):
def relaunch(self, payload={}):
result = self.related.relaunch.post(payload)
return self.walk(result.endpoint)
page.register_page(resources.job, Job)
class Jobs(page.PageList, Job):
pass
page.register_page([resources.jobs,
resources.job_template_jobs,
resources.system_job_template_jobs], Jobs)
class JobCancel(UnifiedJob):
pass
page.register_page(resources.job_cancel, JobCancel)
class JobEvent(base.Base):
pass
page.register_page([resources.job_event,
resources.job_job_event], JobEvent)
class JobEvents(page.PageList, JobEvent):
pass
page.register_page([resources.job_events,
resources.job_job_events,
resources.job_event_children,
resources.group_related_job_events], JobEvents)
class JobPlay(base.Base):
pass
page.register_page(resources.job_play, JobPlay)
class JobPlays(page.PageList, JobPlay):
pass
page.register_page(resources.job_plays, JobPlays)
class JobTask(base.Base):
pass
page.register_page(resources.job_task, JobTask)
class JobTasks(page.PageList, JobTask):
pass
page.register_page(resources.job_tasks, JobTasks)
class JobHostSummary(base.Base):
pass
page.register_page(resources.job_host_summary, JobHostSummary)
class JobHostSummaries(page.PageList, JobHostSummary):
pass
page.register_page([resources.job_host_summaries,
resources.group_related_job_host_summaries], JobHostSummaries)
class JobRelaunch(base.Base):
pass
page.register_page(resources.job_relaunch, JobRelaunch)
class JobStdout(base.Base):
pass
page.register_page(resources.related_stdout, JobStdout)

View File

@@ -0,0 +1,67 @@
from awxkit.utils import random_title, PseudoNamespace
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.api.resources import resources
from awxkit.api.pages import Organization
from . import base
from . import page
class Label(HasCreate, base.Base):
dependencies = [Organization]
def silent_delete(self):
"""Label pages do not support DELETE requests. Here, we override the base page object
silent_delete method to account for this.
"""
pass
def payload(self, organization, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Label - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
organization=organization.id)
return payload
def create_payload(
self,
name='',
description='',
organization=Organization,
**kwargs):
self.create_and_update_dependencies(organization)
payload = self.payload(
organization=self.ds.organization,
name=name,
description=description,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
organization=Organization,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
organization=organization,
**kwargs)
return self.update_identity(Labels(self.connection).post(payload))
page.register_page([resources.label,
(resources.labels, 'post')], Label)
class Labels(page.PageList, Label):
pass
page.register_page([resources.labels,
resources.job_labels,
resources.job_template_labels], Labels)

View File

@@ -0,0 +1,23 @@
from prometheus_client.parser import text_string_to_metric_families
from awxkit.api.resources import resources
from . import base
from . import page
class Metrics(base.Base):
def get(self, **query_parameters):
request = self.connection.get(self.endpoint, query_parameters)
self.page_identity(request, ignore_json_errors=True)
parsed_metrics = text_string_to_metric_families(request.text)
data = {}
for family in parsed_metrics:
for sample in family.samples:
data[sample[0]] = {"labels": sample[1], "value": sample[2]}
request.json = lambda: data
return self.page_identity(request)
page.register_page([resources.metrics,
(resources.metrics, 'get')], Metrics)

View File

@@ -0,0 +1,219 @@
from awxkit.api.mixins import HasCreate, HasCopy, DSAdapter
from awxkit.api.pages import Organization
from awxkit.api.resources import resources
from awxkit.config import config
import awxkit.exceptions as exc
from awxkit.utils import not_provided, random_title, suppress, PseudoNamespace
from . import base
from . import page
job_results = ('any', 'error', 'success')
notification_types = (
'email',
'irc',
'pagerduty',
'slack',
'twilio',
'webhook',
'mattermost')
class NotificationTemplate(HasCopy, HasCreate, base.Base):
dependencies = [Organization]
def test(self):
"""Create test notification"""
assert 'test' in self.related, \
"No such related attribute 'test'"
# trigger test notification
notification_id = self.related.test.post().notification
# return notification page
notifications_pg = self.get_related(
'notifications', id=notification_id).wait_until_count(1)
assert notifications_pg.count == 1, \
"test notification triggered (id:%s) but notification not found in response at %s/notifications/" % \
(notification_id, self.url)
return notifications_pg.results[0]
def silent_delete(self):
"""Delete the Notification Template, ignoring the exception that is raised
if there are notifications pending.
"""
try:
super(NotificationTemplate, self).silent_delete()
except (exc.MethodNotAllowed):
pass
def payload(self, organization, notification_type='slack', **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'NotificationTemplate ({0}) - {1}' .format(
notification_type,
random_title()),
description=kwargs.get('description') or random_title(10),
organization=organization.id,
notification_type=notification_type)
notification_configuration = kwargs.get(
'notification_configuration', {})
payload.notification_configuration = notification_configuration
if payload.notification_configuration == {}:
services = config.credentials.notification_services
if notification_type == 'email':
fields = (
'host',
'username',
'password',
'port',
'use_ssl',
'use_tls',
'sender',
'recipients')
cred = services.email
elif notification_type == 'irc':
fields = (
'server',
'port',
'use_ssl',
'password',
'nickname',
'targets')
cred = services.irc
elif notification_type == 'pagerduty':
fields = ('client_name', 'service_key', 'subdomain', 'token')
cred = services.pagerduty
elif notification_type == 'slack':
fields = ('channels', 'token')
cred = services.slack
elif notification_type == 'twilio':
fields = (
'account_sid',
'account_token',
'from_number',
'to_numbers')
cred = services.twilio
elif notification_type == 'webhook':
fields = ('url', 'headers')
cred = services.webhook
elif notification_type == 'mattermost':
fields = (
'mattermost_url',
'mattermost_username',
'mattermost_channel',
'mattermost_icon_url',
'mattermost_no_verify_ssl')
cred = services.mattermost
else:
raise ValueError(
'Unknown notification_type {0}'.format(notification_type))
for field in fields:
if field == 'bot_token':
payload_field = 'token'
else:
payload_field = field
value = kwargs.get(field, cred.get(field, not_provided))
if value != not_provided:
payload.notification_configuration[payload_field] = value
return payload
def create_payload(
self,
name='',
description='',
notification_type='slack',
organization=Organization,
**kwargs):
if notification_type not in notification_types:
raise ValueError(
'Unsupported notification type "{0}". Please use one of {1}.' .format(
notification_type, notification_types))
self.create_and_update_dependencies(organization)
payload = self.payload(
organization=self.ds.organization,
notification_type=notification_type,
name=name,
description=description,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
notification_type='slack',
organization=Organization,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
notification_type=notification_type,
organization=organization,
**kwargs)
return self.update_identity(
NotificationTemplates(
self.connection).post(payload))
def associate(self, resource, job_result='any'):
"""Associates a NotificationTemplate with the provided resource"""
return self._associate(resource, job_result)
def disassociate(self, resource, job_result='any'):
"""Disassociates a NotificationTemplate with the provided resource"""
return self._associate(resource, job_result, disassociate=True)
def _associate(self, resource, job_result='any', disassociate=False):
if job_result not in job_results:
raise ValueError(
'Unsupported job_result type "{0}". Please use one of {1}.' .format(
job_result, job_results))
result_attr = 'notification_templates_{0}'.format(job_result)
if result_attr not in resource.related:
raise ValueError(
'Unsupported resource "{0}". Does not have a related {1} field.' .format(
resource, result_attr))
payload = dict(id=self.id)
if disassociate:
payload['disassociate'] = True
with suppress(exc.NoContent):
getattr(resource.related, result_attr).post(payload)
page.register_page([resources.notification_template,
(resources.notification_templates, 'post'),
(resources.notification_template_copy, 'post'),
resources.notification_template_any,
resources.notification_template_error,
resources.notification_template_success], NotificationTemplate)
class NotificationTemplates(page.PageList, NotificationTemplate):
pass
page.register_page([resources.notification_templates,
resources.notification_templates_any,
resources.notification_templates_error,
resources.notification_templates_success],
NotificationTemplates)
class NotificationTemplateTest(base.Base):
pass
page.register_page(
resources.notification_template_test,
NotificationTemplateTest)

View File

@@ -0,0 +1,52 @@
from awxkit.api.mixins import HasStatus
from awxkit.api.resources import resources
from awxkit.utils import poll_until, seconds_since_date_string
from . import base
from . import page
class Notification(HasStatus, base.Base):
def __str__(self):
items = ['id', 'notification_type', 'status', 'error', 'notifications_sent',
'subject', 'recipients']
info = []
for item in [x for x in items if hasattr(self, x)]:
info.append('{0}:{1}'.format(item, getattr(self, item)))
output = '<{0.__class__.__name__} {1}>'.format(self, ', '.join(info))
return output.replace('%', '%%')
@property
def is_successful(self):
"""Return whether the notification was created successfully. This means that:
* self.status == 'successful'
* self.error == False
"""
return super(Notification, self).is_successful and not self.error
def wait_until_status(self, status, interval=5, timeout=30, **kwargs):
adjusted_timeout = timeout - seconds_since_date_string(self.created)
return super(Notification, self).wait_until_status(status, interval, adjusted_timeout, **kwargs)
def wait_until_completed(self, interval=5, timeout=240):
"""Notifications need a longer timeout, since the backend often has
to wait for the request (sending the notification) to timeout itself
"""
adjusted_timeout = timeout - seconds_since_date_string(self.created)
return super(Notification, self).wait_until_completed(interval, adjusted_timeout)
page.register_page(resources.notification, Notification)
class Notifications(page.PageList, Notification):
def wait_until_count(self, count, interval=10, timeout=60, **kw):
"""Poll notifications page until it is populated with `count` number of notifications."""
poll_until(lambda: getattr(self.get(), 'count') == count,
interval=interval, timeout=timeout, **kw)
return self
page.register_page([resources.notifications,
resources.related_notifications], Notifications)

View File

@@ -0,0 +1,49 @@
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, DSAdapter
from awxkit.utils import random_title, suppress, PseudoNamespace
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from . import base
from . import page
class Organization(HasCreate, HasInstanceGroups, HasNotifications, base.Base):
def add_admin(self, user):
if isinstance(user, page.Page):
user = user.json
with suppress(exc.NoContent):
self.related.admins.post(user)
def add_user(self, user):
if isinstance(user, page.Page):
user = user.json
with suppress(exc.NoContent):
self.related.users.post(user)
def payload(self, **kwargs):
payload = PseudoNamespace(name=kwargs.get('name') or 'Organization - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10))
return payload
def create_payload(self, name='', description='', **kwargs):
payload = self.payload(name=name, description=description, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', **kwargs):
payload = self.create_payload(name=name, description=description, **kwargs)
return self.update_identity(Organizations(self.connection).post(payload))
page.register_page([resources.organization,
(resources.organizations, 'post')], Organization)
class Organizations(page.PageList, Organization):
pass
page.register_page([resources.organizations,
resources.user_organizations,
resources.project_organizations], Organizations)

View File

@@ -0,0 +1,500 @@
import http.client
import inspect
import logging
import json
import re
from requests import Response
from awxkit.utils import (
PseudoNamespace,
is_relative_endpoint,
are_same_endpoint,
super_dir_set,
suppress,
is_list_or_tuple
)
from awxkit.api.client import Connection
from awxkit.api.registry import URLRegistry
from awxkit.config import config
import awxkit.exceptions as exc
log = logging.getLogger(__name__)
_page_registry = URLRegistry()
get_registered_page = _page_registry.get
def is_license_invalid(response):
if re.match(r".*Invalid license.*", response.text):
return True
if re.match(r".*Missing 'eula_accepted' property.*", response.text):
return True
if re.match(r".*'eula_accepted' must be True.*", response.text):
return True
if re.match(r".*Invalid license data.*", response.text):
return True
def is_license_exceeded(response):
if re.match(
r".*license range of.*instances has been exceeded.*",
response.text):
return True
if re.match(
r".*License count of.*instances has been reached.*",
response.text):
return True
if re.match(
r".*License count of.*instances has been exceeded.*",
response.text):
return True
if re.match(r".*License has expired.*", response.text):
return True
if re.match(r".*License is missing.*", response.text):
return True
def is_duplicate_error(response):
if re.match(r".*already exists.*", response.text):
return True
def register_page(urls, page_cls):
if not _page_registry.default:
from awxkit.api.pages import Base
_page_registry.setdefault(Base)
if not is_list_or_tuple(urls):
urls = [urls]
# Register every methodless page with wildcard method
# until more granular page objects exist (options, head, etc.)
updated_urls = []
for url_method_pair in urls:
if isinstance(url_method_pair, str):
url = url_method_pair
method = '.*'
else:
url, method = url_method_pair
updated_urls.append((url, method))
page_cls.endpoint = updated_urls[0][0]
return _page_registry.register(updated_urls, page_cls)
def objectify_response_json(response):
"""return a PseudoNamespace() from requests.Response.json()."""
try:
json = response.json()
except ValueError:
json = dict()
# PseudoNamespace arg must be a dict, and json can be an array.
# TODO: Assess if list elements should be PseudoNamespace
if isinstance(json, dict):
return PseudoNamespace(json)
return json
class Page(object):
endpoint = ''
def __init__(self, connection=None, *a, **kw):
if 'endpoint' in kw:
self.endpoint = kw['endpoint']
self.connection = connection or Connection(
config.base_url, kw.get(
'verify', not config.assume_untrusted))
self.r = kw.get('r', None)
self.json = kw.get(
'json', objectify_response_json(
self.r) if self.r else {})
self.last_elapsed = kw.get('last_elapsed', None)
def __getattr__(self, name):
if 'json' in self.__dict__ and name in self.json:
value = self.json[name]
if not isinstance(
value,
TentativePage) and is_relative_endpoint(value):
value = TentativePage(value, self.connection)
elif isinstance(value, dict):
for key, item in value.items():
if not isinstance(
item, TentativePage) and is_relative_endpoint(item):
value[key] = TentativePage(item, self.connection)
return value
raise AttributeError(
"{!r} object has no attribute {!r}".format(
self.__class__.__name__, name))
def __setattr__(self, name, value):
if 'json' in self.__dict__ and name in self.json:
# Update field only. For new field use explicit patch
self.patch(**{name: value})
else:
self.__dict__[name] = value
def __str__(self):
if hasattr(self, 'json'):
return json.dumps(self.json, indent=4)
return str(super(Page, self).__repr__())
__repr__ = __str__
def __dir__(self):
attrs = super_dir_set(self.__class__)
if 'json' in self.__dict__ and hasattr(self.json, 'keys'):
attrs.update(self.json.keys())
return sorted(attrs)
def __getitem__(self, key):
return getattr(self, key)
def __iter__(self):
return iter(self.json)
@property
def __item_class__(self):
"""Returns the class representing a single 'Page' item"""
return self.__class__
@classmethod
def from_json(cls, raw):
resp = Response()
resp._content = bytes(json.dumps(raw), 'utf-8')
resp.encoding = 'utf-8'
resp.status_code = 200
return cls(r=resp)
def page_identity(self, response, request_json=None, ignore_json_errors=False):
"""Takes a `requests.Response` and
returns a new __item_class__ instance if the request method is not a get, or returns
a __class__ instance if the request path is different than the caller's `endpoint`.
"""
request_path = response.request.path_url
request_method = response.request.method.lower()
self.last_elapsed = response.elapsed
if isinstance(request_json, dict) and 'ds' in request_json:
ds = request_json.ds
else:
ds = None
try:
data = response.json()
except ValueError as e: # If there was no json to parse
data = dict()
if (response.text and not ignore_json_errors) or response.status_code not in (200, 202, 204):
text = response.text
if len(text) > 1024:
text = text[:1024] + '... <<< Truncated >>> ...'
log.warning(
"Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text))
exc_str = "%s (%s) received" % (
http.client.responses[response.status_code], response.status_code)
exception = exception_from_status_code(response.status_code)
if exception:
raise exception(exc_str, data)
if response.status_code in (
http.client.OK,
http.client.CREATED,
http.client.ACCEPTED):
# Not all JSON responses include a URL. Grab it from the request
# object, if needed.
if 'url' in data:
endpoint = data['url']
else:
endpoint = request_path
data = objectify_response_json(response)
if request_method in ('get', 'patch', 'put'):
# Update existing resource and return it
if are_same_endpoint(self.endpoint, request_path):
self.json = data
self.r = response
return self
registered_type = get_registered_page(request_path, request_method)
return registered_type(
self.connection,
endpoint=endpoint,
json=data,
last_elapsed=response.elapsed,
r=response,
ds=ds)
elif response.status_code == http.client.FORBIDDEN:
if is_license_invalid(response):
raise exc.LicenseInvalid(exc_str, data)
elif is_license_exceeded(response):
raise exc.LicenseExceeded(exc_str, data)
else:
raise exc.Forbidden(exc_str, data)
elif response.status_code == http.client.BAD_REQUEST:
if is_license_invalid(response):
raise exc.LicenseInvalid(exc_str, data)
if is_duplicate_error(response):
raise exc.Duplicate(exc_str, data)
else:
raise exc.BadRequest(exc_str, data)
else:
raise exc.Unknown(exc_str, data)
def update_identity(self, obj):
"""Takes a `Page` and updates attributes to reflect its content"""
self.endpoint = obj.endpoint
self.json = obj.json
self.last_elapsed = obj.last_elapsed
self.r = obj.r
return self
def delete(self):
r = self.connection.delete(self.endpoint)
with suppress(exc.NoContent):
return self.page_identity(r)
def get(self, all_pages=False, **query_parameters):
r = self.connection.get(self.endpoint, query_parameters)
page = self.page_identity(r)
if all_pages and page.next:
paged_results = [r.json()['results']]
while page.next:
r = self.connection.get(self.next, query_parameters)
page = self.page_identity(r)
paged_results.append(r.json()['results'])
json = r.json()
json['results'] = []
for page in paged_results:
json['results'].extend(page)
page = self.__class__.from_json(json)
return page
def head(self):
r = self.connection.head(self.endpoint)
return self.page_identity(r)
def options(self):
r = self.connection.options(self.endpoint)
return self.page_identity(r)
def patch(self, **json):
r = self.connection.patch(self.endpoint, json)
return self.page_identity(r, request_json=json)
def post(self, json={}):
r = self.connection.post(self.endpoint, json)
return self.page_identity(r, request_json=json)
def put(self, json=None):
"""If a payload is supplied, PUT the payload. If not, submit our existing page JSON as our payload."""
json = self.json if json is None else json
r = self.connection.put(self.endpoint, json=json)
return self.page_identity(r, request_json=json)
def get_related(self, related_name, **kwargs):
assert related_name in self.json.get('related', [])
endpoint = self.json['related'][related_name]
return self.walk(endpoint, **kwargs)
def walk(self, endpoint, **kw):
page_cls = get_registered_page(endpoint)
return page_cls(self.connection, endpoint=endpoint).get(**kw)
_exception_map = {http.client.NO_CONTENT: exc.NoContent,
http.client.NOT_FOUND: exc.NotFound,
http.client.INTERNAL_SERVER_ERROR: exc.InternalServerError,
http.client.BAD_GATEWAY: exc.BadGateway,
http.client.METHOD_NOT_ALLOWED: exc.MethodNotAllowed,
http.client.UNAUTHORIZED: exc.Unauthorized,
http.client.PAYMENT_REQUIRED: exc.PaymentRequired,
http.client.CONFLICT: exc.Conflict}
def exception_from_status_code(status_code):
return _exception_map.get(status_code, None)
class PageList(object):
@property
def __item_class__(self):
"""Returns the class representing a single 'Page' item
With an inheritence of OrgListSubClass -> OrgList -> PageList -> Org -> Base -> Page, the following
will return the parent class of the current object (e.g. 'Org').
Obtaining a page type by registered endpoint is highly recommended over using this method.
"""
mro = inspect.getmro(self.__class__)
bl_index = mro.index(PageList)
return mro[bl_index + 1]
@property
def results(self):
items = []
for item in self.json['results']:
endpoint = item.get('url')
if endpoint is None:
registered_type = self.__item_class__
else:
registered_type = get_registered_page(endpoint)
items.append(
registered_type(
self.connection,
endpoint=endpoint,
json=item,
r=self.r))
return items
def go_to_next(self):
if self.next:
next_page = self.__class__(self.connection, endpoint=self.next)
return next_page.get()
def go_to_previous(self):
if self.previous:
prev_page = self.__class__(self.connection, endpoint=self.previous)
return prev_page.get()
def create(self, *a, **kw):
return self.__item_class__(self.connection).create(*a, **kw)
class TentativePage(str):
def __new__(cls, endpoint, connection):
return super(TentativePage, cls).__new__(cls, endpoint)
def __init__(self, endpoint, connection):
self.endpoint = endpoint
self.connection = connection
def _create(self):
return get_registered_page(
self.endpoint)(
self.connection,
endpoint=self.endpoint)
def get(self, **params):
return self._create().get(**params)
def create_or_replace(self, **query_parameters):
"""Create an object, and if any other item shares the name, delete that one first.
Generally, requires 'name' of object.
Exceptions:
- Users are looked up by username
- Teams need to be looked up by name + organization
"""
page = None
# look up users by username not name
if 'users' in self:
assert query_parameters.get(
'username'), 'For this resource, you must call this method with a "username" to look up the object by'
page = self.get(username=query_parameters['username'])
else:
assert query_parameters.get(
'name'), 'For this resource, you must call this method with a "name" to look up the object by'
if query_parameters.get('organization'):
if isinstance(query_parameters.get('organization'), int):
page = self.get(
name=query_parameters['name'],
organization=query_parameters.get('organization'))
else:
page = self.get(
name=query_parameters['name'],
organization=query_parameters.get('organization').id)
else:
page = self.get(name=query_parameters['name'])
if page and page.results:
for item in page.results:
# We found a duplicate item, we will delete it
# Some things, like inventory scripts, allow multiple scripts
# by same name as long as they have different organization
item.delete()
# Now that we know that there is no duplicate, we create a new object
return self.create(**query_parameters)
def get_or_create(self, **query_parameters):
"""Get an object by this name or id if it exists, otherwise create it.
Exceptions:
- Users are looked up by username
- Teams need to be looked up by name + organization
"""
page = None
# look up users by username not name
if query_parameters.get('username') and 'users' in self:
page = self.get(username=query_parameters['username'])
if query_parameters.get('name'):
if query_parameters.get('organization'):
if isinstance(query_parameters.get('organization'), int):
page = self.get(
name=query_parameters['name'],
organization=query_parameters.get('organization'))
else:
page = self.get(
name=query_parameters['name'],
organization=query_parameters.get('organization').id)
else:
page = self.get(name=query_parameters['name'])
elif query_parameters.get('id'):
page = self.get(id=query_parameters['id'])
if page and page.results:
item = page.results.pop()
return item.url.get()
else:
# We did not find it given these params, we will create it instead
return self.create(**query_parameters)
def post(self, payload={}):
return self._create().post(payload)
def put(self):
return self._create().put()
def patch(self, **payload):
return self._create().patch(**payload)
def delete(self):
return self._create().delete()
def options(self):
return self._create().options()
def create(self, *a, **kw):
return self._create().create(*a, **kw)
def payload(self, *a, **kw):
return self._create().payload(*a, **kw)
def create_payload(self, *a, **kw):
return self._create().create_payload(*a, **kw)
def __str__(self):
if hasattr(self, 'endpoint'):
return self.endpoint
return super(TentativePage, self).__str__()
__repr__ = __str__
def __eq__(self, other):
return self.endpoint == other
def __ne__(self, other):
return self.endpoint != other

View File

@@ -0,0 +1,11 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Ping(base.Base):
pass
page.register_page(resources.ping, Ping)

View File

@@ -0,0 +1,209 @@
import json
from awxkit.api.pages import Credential, Organization, UnifiedJob, UnifiedJobTemplate
from awxkit.utils import filter_by_class, random_title, update_payload, PseudoNamespace
from awxkit.api.mixins import HasCreate, HasNotifications, HasCopy, DSAdapter
from awxkit.api.resources import resources
from awxkit.config import config
from . import base
from . import page
class Project(HasCopy, HasCreate, HasNotifications, UnifiedJobTemplate):
optional_dependencies = [Credential, Organization]
optional_schedule_fields = tuple()
def payload(self, organization, scm_type='git', **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Project - {}'.format(
random_title()),
description=kwargs.get('description') or random_title(10),
scm_type=scm_type,
scm_url=kwargs.get('scm_url') or config.project_urls.get(
scm_type,
''))
if organization is not None:
payload.organization = organization.id
if kwargs.get('credential'):
payload.credential = kwargs.get('credential').id
fields = (
'scm_branch',
'local_path',
'scm_clean',
'scm_delete_on_update',
'scm_update_cache_timeout',
'scm_update_on_launch',
'scm_refspec',
'allow_override')
update_payload(payload, fields, kwargs)
return payload
def create_payload(
self,
name='',
description='',
scm_type='git',
scm_url='',
scm_branch='',
organization=Organization,
credential=None,
**kwargs):
if credential:
if isinstance(credential, Credential):
if credential.ds.credential_type.namespace not in (
'scm', 'insights'):
credential = None # ignore incompatible credential from HasCreate dependency injection
elif credential in (Credential,):
credential = (
Credential, dict(
credential_type=(
True, dict(
kind='scm'))))
elif credential is True:
credential = (
Credential, dict(
credential_type=(
True, dict(
kind='scm'))))
self.create_and_update_dependencies(
*filter_by_class((credential, Credential), (organization, Organization)))
credential = self.ds.credential if credential else None
organization = self.ds.organization if organization else None
payload = self.payload(
organization=organization,
scm_type=scm_type,
name=name,
description=description,
scm_url=scm_url,
scm_branch=scm_branch,
credential=credential,
**kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(
self,
name='',
description='',
scm_type='git',
scm_url='',
scm_branch='',
organization=Organization,
credential=None,
**kwargs):
payload = self.create_payload(
name=name,
description=description,
scm_type=scm_type,
scm_url=scm_url,
scm_branch=scm_branch,
organization=organization,
credential=credential,
**kwargs)
self.update_identity(Projects(self.connection).post(payload))
if kwargs.get('wait', True):
self.related.current_update.get().wait_until_completed()
return self.get()
return self
def update(self):
"""Update the project using related->update endpoint."""
# get related->launch
update_pg = self.get_related('update')
# assert can_update == True
assert update_pg.can_update, \
"The specified project (id:%s) is not able to update (can_update:%s)" % \
(self.id, update_pg.can_update)
# start the update
result = update_pg.post()
# assert JSON response
assert 'project_update' in result.json, \
"Unexpected JSON response when starting an project_update.\n%s" % \
json.dumps(result.json, indent=2)
# locate and return the specific update
jobs_pg = self.get_related(
'project_updates',
id=result.json['project_update'])
assert jobs_pg.count == 1, \
"An project_update started (id:%s) but job not found in response at %s/inventory_updates/" % \
(result.json['project_update'], self.url)
return jobs_pg.results[0]
@property
def is_successful(self):
"""An project is considered successful when:
0) scm_type != ""
1) unified_job_template.is_successful
"""
return self.scm_type != "" and \
super(Project, self).is_successful
page.register_page([resources.project,
(resources.projects, 'post'),
(resources.project_copy, 'post')], Project)
class Projects(page.PageList, Project):
pass
page.register_page([resources.projects,
resources.related_projects], Projects)
class ProjectUpdate(UnifiedJob):
pass
page.register_page(resources.project_update, ProjectUpdate)
class ProjectUpdates(page.PageList, ProjectUpdate):
pass
page.register_page([resources.project_updates,
resources.project_project_updates], ProjectUpdates)
class ProjectUpdateLaunch(base.Base):
pass
page.register_page(resources.project_related_update, ProjectUpdateLaunch)
class ProjectUpdateCancel(base.Base):
pass
page.register_page(resources.project_update_cancel, ProjectUpdateCancel)
class Playbooks(base.Base):
pass
page.register_page(resources.project_playbooks, Playbooks)

View File

@@ -0,0 +1,21 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Role(base.Base):
pass
page.register_page(resources.role, Role)
class Roles(page.PageList, Role):
pass
page.register_page([resources.roles,
resources.related_roles,
resources.related_object_roles], Roles)

View File

@@ -0,0 +1,54 @@
from awxkit.api.pages import UnifiedJob
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from awxkit.utils import suppress
from . import page
from . import base
class Schedule(UnifiedJob):
pass
page.register_page([resources.schedule,
resources.related_schedule], Schedule)
class Schedules(page.PageList, Schedule):
def get_zoneinfo(self):
return SchedulesZoneInfo(self.connection).get()
def preview(self, rrule=''):
payload = dict(rrule=rrule)
return SchedulesPreview(self.connection).post(payload)
def add_credential(self, cred):
with suppress(exc.NoContent):
self.related.credentials.post(dict(id=cred.id))
def remove_credential(self, cred):
with suppress(exc.NoContent):
self.related.credentials.post(dict(id=cred.id, disassociate=True))
page.register_page([resources.schedules,
resources.related_schedules], Schedules)
class SchedulesPreview(base.Base):
pass
page.register_page(((resources.schedules_preview, 'post'),), SchedulesPreview)
class SchedulesZoneInfo(base.Base):
def __getitem__(self, idx):
return self.json[idx]
page.register_page(((resources.schedules_zoneinfo, 'get'),), SchedulesZoneInfo)

View File

@@ -0,0 +1,42 @@
from awxkit.api.resources import resources
from . import base
from . import page
class Setting(base.Base):
pass
page.register_page([resources.setting,
resources.settings_all,
resources.settings_authentication,
resources.settings_changed,
resources.settings_github,
resources.settings_github_org,
resources.settings_github_team,
resources.settings_google_oauth2,
resources.settings_jobs,
resources.settings_ldap,
resources.settings_radius,
resources.settings_saml,
resources.settings_system,
resources.settings_tacacsplus,
resources.settings_ui,
resources.settings_user,
resources.settings_user_defaults], Setting)
class Settings(page.PageList, Setting):
def get_endpoint(self, endpoint):
"""Helper method used to navigate to a specific settings endpoint.
(Pdb) settings_pg.get_endpoint('all')
"""
base_url = '{0}{1}/'.format(self.endpoint, endpoint)
return self.walk(base_url)
get_setting = get_endpoint
page.register_page(resources.settings, Settings)

View File

@@ -0,0 +1,30 @@
from . import base
from . import page
from awxkit.api.resources import resources
class SurveySpec(base.Base):
def get_variable_default(self, var):
for item in self.spec:
if item.get('variable') == var:
return item.get('default')
def get_default_vars(self):
default_vars = dict()
for item in self.spec:
if item.get("default", None):
default_vars[item.variable] = item.default
return default_vars
def get_required_vars(self):
required_vars = []
for item in self.spec:
if item.get("required", None):
required_vars.append(item.variable)
return required_vars
page.register_page([resources.job_template_survey_spec,
resources.workflow_job_template_survey_spec], SurveySpec)

View File

@@ -0,0 +1,29 @@
from awxkit.api.mixins import HasNotifications
from awxkit.api.pages import UnifiedJobTemplate
from awxkit.api.resources import resources
from . import page
class SystemJobTemplate(UnifiedJobTemplate, HasNotifications):
def launch(self, payload={}):
"""Launch the system_job_template using related->launch endpoint."""
result = self.related.launch.post(payload)
# return job
jobs_pg = self.get_related('jobs', id=result.json['system_job'])
assert jobs_pg.count == 1, \
"system_job_template launched (id:%s) but unable to find matching " \
"job at %s/jobs/" % (result.json['job'], self.url)
return jobs_pg.results[0]
page.register_page(resources.system_job_template, SystemJobTemplate)
class SystemJobTemplates(page.PageList, SystemJobTemplate):
pass
page.register_page(resources.system_job_templates, SystemJobTemplates)

View File

@@ -0,0 +1,27 @@
from awxkit.api.pages import UnifiedJob
from awxkit.api.resources import resources
from . import page
class SystemJob(UnifiedJob):
pass
page.register_page(resources.system_job, SystemJob)
class SystemJobs(page.PageList, SystemJob):
pass
page.register_page(resources.system_jobs, SystemJobs)
class SystemJobCancel(UnifiedJob):
pass
page.register_page(resources.system_job_cancel, SystemJobCancel)

View File

@@ -0,0 +1,48 @@
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.utils import suppress, random_title, PseudoNamespace
from awxkit.api.resources import resources
from awxkit.api.pages import Organization
from awxkit.exceptions import NoContent
from . import base
from . import page
class Team(HasCreate, base.Base):
dependencies = [Organization]
def add_user(self, user):
if isinstance(user, page.Page):
user = user.json
with suppress(NoContent):
self.related.users.post(user)
def payload(self, organization, **kwargs):
payload = PseudoNamespace(name=kwargs.get('name') or 'Team - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10),
organization=organization.id)
return payload
def create_payload(self, name='', description='', organization=Organization, **kwargs):
self.create_and_update_dependencies(organization)
payload = self.payload(organization=self.ds.organization, name=name, description=description, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', organization=Organization, **kwargs):
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
return self.update_identity(Teams(self.connection).post(payload))
page.register_page([resources.team,
(resources.teams, 'post')], Team)
class Teams(page.PageList, Team):
pass
page.register_page([resources.teams,
resources.related_teams], Teams)

View File

@@ -0,0 +1,86 @@
from awxkit.api.resources import resources
from awxkit.utils import random_title, update_payload
from awxkit.api.mixins import HasStatus
from . import base
from . import page
class UnifiedJobTemplate(HasStatus, base.Base):
"""Base class for unified job template pages (e.g. project, inventory_source,
and job_template).
"""
optional_schedule_fields = (
'extra_data',
'diff_mode',
'limit',
'job_tags',
'skip_tags',
'job_type',
'verbosity',
'inventory',
)
def __str__(self):
# NOTE: I use .replace('%', '%%') to workaround an odd string
# formatting issue where result_stdout contained '%s'. This later caused
# a python traceback when attempting to display output from this
# method.
items = [
'id',
'name',
'status',
'source',
'last_update_failed',
'last_updated',
'result_traceback',
'job_explanation',
'job_args']
info = []
for item in [x for x in items if hasattr(self, x)]:
info.append('{0}:{1}'.format(item, getattr(self, item)))
output = '<{0.__class__.__name__} {1}>'.format(self, ', '.join(info))
return output.replace('%', '%%')
def add_schedule(
self,
name='',
description='',
enabled=True,
rrule=None,
**kwargs):
if rrule is None:
rrule = "DTSTART:30180101T000000Z RRULE:FREQ=YEARLY;INTERVAL=1"
payload = dict(
name=name or "{0} Schedule {1}".format(
self.name,
random_title()),
description=description or random_title(10),
enabled=enabled,
rrule=str(rrule))
update_payload(payload, self.optional_schedule_fields, kwargs)
return self.related.schedules.post(payload)
@property
def is_successful(self):
"""An unified_job_template is considered successful when:
1) status == 'successful'
2) not last_update_failed
3) last_updated
"""
return super(
UnifiedJobTemplate,
self).is_successful and not self.last_update_failed and self.last_updated is not None
page.register_page(resources.unified_job_template, UnifiedJobTemplate)
class UnifiedJobTemplates(page.PageList, UnifiedJobTemplate):
pass
page.register_page(resources.unified_job_templates, UnifiedJobTemplates)

View File

@@ -0,0 +1,150 @@
from pprint import pformat
import yaml.parser
import yaml.scanner
import yaml
from awxkit.utils import args_string_to_list, seconds_since_date_string
from awxkit.api.resources import resources
from awxkit.api.mixins import HasStatus
import awxkit.exceptions as exc
from . import base
from . import page
class UnifiedJob(HasStatus, base.Base):
"""Base class for unified job pages (e.g. project_updates, inventory_updates
and jobs).
"""
def __str__(self):
# NOTE: I use .replace('%', '%%') to workaround an odd string
# formatting issue where result_stdout contained '%s'. This later caused
# a python traceback when attempting to display output from this method.
items = ['id', 'name', 'status', 'failed', 'result_stdout', 'result_traceback',
'job_explanation', 'job_args']
info = []
for item in [x for x in items if hasattr(self, x)]:
info.append('{0}:{1}'.format(item, getattr(self, item)))
output = '<{0.__class__.__name__} {1}>'.format(self, ', '.join(info))
return output.replace('%', '%%')
@property
def result_stdout(self):
if 'result_stdout' not in self.json and 'stdout' in self.related:
return self.connection.get(
self.related.stdout, query_parameters=dict(format='txt_download')
).content.decode()
if str(self.json.get('result_stdout')) == 'stdout capture is missing' and 'stdout' in self.related:
ping = self.walk(resources.ping)
if self.execution_node != ping.active_node:
self.connection.get(self.related.stdout, query_parameters=dict(format='txt_download'))
self.get()
return self.json.result_stdout.decode()
def assert_text_in_stdout(self, expected_text, replace_spaces=None, replace_newlines=' '):
"""Assert text is found in stdout, and if not raise exception with entire stdout.
Default behavior is to replace newline characters with a space, but this can be modified, including replacement
with ''. Pass replace_newlines=None to disable.
Additionally, you may replace any ' ' with another character (including ''). This is applied after the newline
replacement. Default behavior is to not replace spaces.
"""
stdout = self.result_stdout
if replace_newlines is not None:
stdout = stdout.replace('\n', replace_newlines)
if replace_spaces is not None:
stdout = stdout.replace(' ', replace_spaces)
if expected_text not in stdout:
pretty_stdout = pformat(stdout)
raise AssertionError(
'Expected "{}", but it was not found in stdout. Full stdout:\n {}'.format(expected_text, pretty_stdout)
)
@property
def is_successful(self):
"""Return whether the current has completed successfully.
This means that:
* self.status == 'successful'
* self.has_traceback == False
* self.failed == False
"""
return super(UnifiedJob, self).is_successful and not (self.has_traceback or self.failed)
def wait_until_status(self, status, interval=1, timeout=60, since_job_created=True, **kwargs):
if since_job_created:
timeout = timeout - seconds_since_date_string(self.created)
return super(UnifiedJob, self).wait_until_status(status, interval, timeout, **kwargs)
def wait_until_completed(self, interval=5, timeout=60 * 8, since_job_created=True, **kwargs):
if since_job_created:
timeout = timeout - seconds_since_date_string(self.created)
return super(UnifiedJob, self).wait_until_completed(interval, timeout, **kwargs)
@property
def has_traceback(self):
"""Return whether a traceback has been detected in result_traceback"""
try:
tb = str(self.result_traceback)
except AttributeError:
# If record obtained from list view, then traceback isn't given
# and result_stdout is only given for some types
# we must suppress AttributeError or else it will be mis-interpreted
# by __getattr__
tb = ''
return 'Traceback' in tb
def cancel(self):
cancel = self.get_related('cancel')
if not cancel.can_cancel:
return
try:
cancel.post()
except exc.MethodNotAllowed as e:
# Race condition where job finishes between can_cancel
# check and post.
if not any("not allowed" in field for field in e.msg.values()):
raise(e)
return self.get()
@property
def job_args(self):
"""Helper property to return flattened cmdline arg tokens in a list.
Flattens arg strings for rough inclusion checks:
```assert "thing" in unified_job.job_args```
```assert dict(extra_var=extra_var_val) in unified_job.job_args```
If you need to ensure the job_args are of awx-provided format use raw unified_job.json.job_args.
"""
def attempt_yaml_load(arg):
try:
return yaml.load(arg, Loader=yaml.FullLoader)
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
return str(arg)
args = []
if not self.json.job_args:
return ""
for arg in yaml.load(self.json.job_args, Loader=yaml.FullLoader):
try:
args.append(yaml.load(arg, Loader=yaml.FullLoader))
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
if arg[0] == '@': # extra var file reference
args.append(attempt_yaml_load(arg))
elif args[-1] == '-c': # this arg is likely sh arg string
args.extend([attempt_yaml_load(item) for item in args_string_to_list(arg)])
else:
raise
return args
class UnifiedJobs(page.PageList, UnifiedJob):
pass
page.register_page([resources.unified_jobs,
resources.instance_related_jobs,
resources.instance_group_related_jobs,
resources.schedules_jobs], UnifiedJobs)

View File

@@ -0,0 +1,74 @@
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.utils import random_title, PseudoNamespace
from awxkit.api.resources import resources
from awxkit.config import config
from . import base
from . import page
class User(HasCreate, base.Base):
def payload(self, **kwargs):
payload = PseudoNamespace(
username=kwargs.get('username') or 'User-{}'.format(
random_title(
non_ascii=False)),
password=kwargs.get('password') or config.credentials.default.password,
is_superuser=kwargs.get(
'is_superuser',
False),
is_system_auditor=kwargs.get(
'is_system_auditor',
False),
first_name=kwargs.get(
'first_name',
random_title()),
last_name=kwargs.get(
'last_name',
random_title()),
email=kwargs.get(
'email',
'{}@example.com'.format(random_title(5, non_ascii=False)))
)
return payload
def create_payload(self, username='', password='', **kwargs):
payload = self.payload(username=username, password=password, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, username='', password='', organization=None, **kwargs):
payload = self.create_payload(
username=username, password=password, **kwargs)
self.password = payload.password
self.update_identity(Users(self.connection).post(payload))
if organization:
organization.add_user(self)
return self
page.register_page([resources.user,
(resources.users, 'post')], User)
class Users(page.PageList, User):
pass
page.register_page([resources.users,
resources.organization_admins,
resources.related_users,
resources.user_admin_organizations], Users)
class Me(Users):
pass
page.register_page(resources.me, Me)

View File

@@ -0,0 +1,39 @@
from awxkit.api.pages import base
from awxkit.api.resources import resources
from awxkit.utils import poll_until, seconds_since_date_string, suppress
from awxkit.exceptions import WaitUntilTimeout
from . import page
class WorkflowJobNode(base.Base):
def wait_for_job(self, interval=5, timeout=60, **kw):
"""Waits until node's job exists"""
adjusted_timeout = timeout - seconds_since_date_string(self.created)
with suppress(WaitUntilTimeout):
poll_until(self.job_exists, interval=interval, timeout=adjusted_timeout, **kw)
return self
def job_exists(self):
self.get()
try:
return self.job
except AttributeError:
return False
page.register_page(resources.workflow_job_node, WorkflowJobNode)
class WorkflowJobNodes(page.PageList, WorkflowJobNode):
pass
page.register_page([resources.workflow_job_nodes,
resources.workflow_job_workflow_nodes,
resources.workflow_job_node_always_nodes,
resources.workflow_job_node_failure_nodes,
resources.workflow_job_node_success_nodes], WorkflowJobNodes)

View File

@@ -0,0 +1,85 @@
import awxkit.exceptions as exc
from awxkit.api.pages import base, WorkflowJobTemplate, UnifiedJobTemplate, JobTemplate
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.api.resources import resources
from awxkit.utils import update_payload, PseudoNamespace, suppress
from . import page
class WorkflowJobTemplateNode(HasCreate, base.Base):
dependencies = [WorkflowJobTemplate, UnifiedJobTemplate]
def payload(self, workflow_job_template, unified_job_template, **kwargs):
payload = PseudoNamespace(workflow_job_template=workflow_job_template.id,
unified_job_template=unified_job_template.id)
optional_fields = ('diff_mode', 'extra_data', 'limit', 'job_tags', 'job_type', 'skip_tags', 'verbosity',
'extra_data')
update_payload(payload, optional_fields, kwargs)
for resource in ('credential', 'inventory'):
if resource in kwargs:
payload[resource] = kwargs[resource].id
return payload
def create_payload(self, workflow_job_template=WorkflowJobTemplate, unified_job_template=JobTemplate, **kwargs):
self.create_and_update_dependencies(workflow_job_template, unified_job_template)
payload = self.payload(workflow_job_template=self.ds.workflow_job_template,
unified_job_template=self.ds.unified_job_template, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, workflow_job_template=WorkflowJobTemplate, unified_job_template=JobTemplate, **kwargs):
payload = self.create_payload(workflow_job_template=workflow_job_template,
unified_job_template=unified_job_template, **kwargs)
return self.update_identity(WorkflowJobTemplateNodes(self.connection).post(payload))
def _add_node(self, endpoint, unified_job_template):
node = endpoint.post(dict(unified_job_template=unified_job_template.id))
node.create_and_update_dependencies(self.ds.workflow_job_template, unified_job_template)
return node
def add_always_node(self, unified_job_template):
return self._add_node(self.related.always_nodes, unified_job_template)
def add_failure_node(self, unified_job_template):
return self._add_node(self.related.failure_nodes, unified_job_template)
def add_success_node(self, unified_job_template):
return self._add_node(self.related.success_nodes, unified_job_template)
def add_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, associate=True))
def remove_credential(self, credential):
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=credential.id, disassociate=True))
def remove_all_credentials(self):
for cred in self.related.credentials.get().results:
with suppress(exc.NoContent):
self.related.credentials.post(
dict(id=cred.id, disassociate=True))
page.register_page([resources.workflow_job_template_node,
(resources.workflow_job_template_nodes, 'post')], WorkflowJobTemplateNode)
class WorkflowJobTemplateNodes(page.PageList, WorkflowJobTemplateNode):
pass
page.register_page([resources.workflow_job_template_nodes,
resources.workflow_job_template_workflow_nodes,
resources.workflow_job_template_node_always_nodes,
resources.workflow_job_template_node_failure_nodes,
resources.workflow_job_template_node_success_nodes], WorkflowJobTemplateNodes)

View File

@@ -0,0 +1,100 @@
import json
from awxkit.api.mixins import HasCreate, HasNotifications, HasSurvey, HasCopy, DSAdapter
from awxkit.api.pages import Organization, UnifiedJobTemplate
from awxkit.utils import filter_by_class, not_provided, update_payload, random_title, suppress, PseudoNamespace
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from . import base
from . import page
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
optional_dependencies = [Organization]
def launch(self, payload={}):
"""Launch using related->launch endpoint."""
# get related->launch
launch_pg = self.get_related('launch')
# launch the workflow_job_template
result = launch_pg.post(payload)
# return job
jobs_pg = self.related.workflow_jobs.get(id=result.workflow_job)
if jobs_pg.count != 1:
msg = "workflow_job_template launched (id:{}) but job not found in response at {}/workflow_jobs/" % \
(result.json['workflow_job'], self.url)
raise exc.UnexpectedAWXState(msg)
return jobs_pg.results[0]
def payload(self, **kwargs):
payload = PseudoNamespace(name=kwargs.get('name') or 'WorkflowJobTemplate - {}'.format(random_title()),
description=kwargs.get('description') or random_title(10))
optional_fields = ("allow_simultaneous", "ask_variables_on_launch", "survey_enabled")
update_payload(payload, optional_fields, kwargs)
extra_vars = kwargs.get('extra_vars', not_provided)
if extra_vars != not_provided:
if isinstance(extra_vars, dict):
extra_vars = json.dumps(extra_vars)
payload.update(extra_vars=extra_vars)
if kwargs.get('organization'):
payload.organization = kwargs.get('organization').id
if kwargs.get('inventory'):
payload.inventory = kwargs.get('inventory').id
if kwargs.get('ask_inventory_on_launch'):
payload.ask_inventory_on_launch = kwargs.get('ask_inventory_on_launch')
return payload
def create_payload(self, name='', description='', organization=None, **kwargs):
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
organization = self.ds.organization if organization else None
payload = self.payload(name=name, description=description, organization=organization, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', organization=None, **kwargs):
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))
def add_label(self, label):
if isinstance(label, page.Page):
label = label.json
with suppress(exc.NoContent):
self.related.labels.post(label)
page.register_page([resources.workflow_job_template,
(resources.workflow_job_templates, 'post'),
(resources.workflow_job_template_copy, 'post')], WorkflowJobTemplate)
class WorkflowJobTemplates(page.PageList, WorkflowJobTemplate):
pass
page.register_page([resources.workflow_job_templates], WorkflowJobTemplates)
class WorkflowJobTemplateLaunch(base.Base):
pass
page.register_page(resources.workflow_job_template_launch, WorkflowJobTemplateLaunch)
class WorkflowJobTemplateCopy(base.Base):
pass
page.register_page([resources.workflow_job_template_copy], WorkflowJobTemplateCopy)

View File

@@ -0,0 +1,38 @@
from awxkit.api.pages import UnifiedJob
from awxkit.api.resources import resources
from . import page
class WorkflowJob(UnifiedJob):
def __str__(self):
# TODO: Update after endpoint's fields are finished filling out
return super(UnifiedJob, self).__str__()
def relaunch(self, payload={}):
result = self.related.relaunch.post(payload)
return self.walk(result.url)
@property
def result_stdout(self):
# workflow jobs do not have result_stdout
# which is problematic for the UnifiedJob.is_successful reliance on
# related stdout endpoint.
if 'result_stdout' not in self.json:
return 'Unprovided AWX field.'
else:
return super(WorkflowJob, self).result_stdout
page.register_page(resources.workflow_job, WorkflowJob)
class WorkflowJobs(page.PageList, WorkflowJob):
pass
page.register_page([resources.workflow_jobs,
resources.workflow_job_template_jobs,
resources.job_template_slice_workflow_jobs],
WorkflowJobs)