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,2 @@
from .pages import * # NOQA
from .client import * # NOQA

121
awxkit/awxkit/api/client.py Normal file
View File

@@ -0,0 +1,121 @@
import logging
import requests
from awxkit import exceptions as exc
from awxkit.config import config
log = logging.getLogger(__name__)
class ConnectionException(exc.Common):
pass
class Token_Auth(requests.auth.AuthBase):
def __init__(self, token, auth_type='Token'):
self.token = token
self.auth_type = auth_type
def __call__(self, request):
request.headers['Authorization'] = '{0.auth_type} {0.token}'.format(self)
return request
def log_elapsed(r, *args, **kwargs): # requests hook to display API elapsed time
log.debug('"{0.request.method} {0.url}" elapsed: {0.elapsed}'.format(r))
class Connection(object):
"""A requests.Session wrapper for establishing connection w/ AWX instance"""
def __init__(self, server, verify=False):
self.server = server
self.verify = verify
if not self.verify:
requests.packages.urllib3.disable_warnings()
self.session = requests.Session()
self.uses_session_cookie = False
def get_session_requirements(self, next='/api/'):
self.get('/api/') # this causes a cookie w/ the CSRF token to be set
return dict(next=next)
def login(self, username=None, password=None, token=None, **kwargs):
if username and password:
_next = kwargs.get('next')
if _next:
headers = self.session.headers.copy()
self.post('/api/login/', headers=headers,
data=dict(username=username, password=password, next=_next))
self.session_id = self.session.cookies.get('sessionid')
self.uses_session_cookie = True
else:
self.session.auth = (username, password)
elif token:
self.session.auth = Token_Auth(token, auth_type=kwargs.get('auth_type', 'Token'))
else:
self.session.auth = None
def logout(self):
if self.uses_session_cookie:
self.session.cookies.pop('sessionid', None)
else:
self.session.auth = None
def request(self, relative_endpoint, method='get', json=None, data=None, query_parameters=None, headers=None):
"""Core requests.Session wrapper that returns requests.Response objects"""
session_request_method = getattr(self.session, method, None)
if not session_request_method:
raise ConnectionException(message="Unknown request method: {0}".format(method))
use_endpoint = relative_endpoint
if self.server.endswith('/') and use_endpoint.startswith('/'):
raise RuntimeError('AWX URL given with trailing slash, remove slash.')
url = '{0.server}{1}'.format(self, use_endpoint)
kwargs = dict(verify=self.verify, params=query_parameters, json=json, data=data,
hooks=dict(response=log_elapsed))
if headers is not None:
kwargs['headers'] = headers
if method in ('post', 'put', 'patch', 'delete'):
kwargs.setdefault('headers', {})['X-CSRFToken'] = self.session.cookies.get('csrftoken')
kwargs['headers']['Referer'] = url
for attempt in range(1, config.client_connection_attempts + 1):
try:
response = session_request_method(url, **kwargs)
break
except requests.exceptions.ConnectionError as err:
if attempt == config.client_connection_attempts:
raise err
log.exception('Failed to reach url: {0}. Retrying.'.format(url))
return response
def delete(self, relative_endpoint):
return self.request(relative_endpoint, method='delete')
def get(self, relative_endpoint, query_parameters=None, headers=None):
return self.request(relative_endpoint, method='get', query_parameters=query_parameters, headers=headers)
def head(self, relative_endpoint):
return self.request(relative_endpoint, method='head')
def options(self, relative_endpoint):
return self.request(relative_endpoint, method='options')
def patch(self, relative_endpoint, json):
return self.request(relative_endpoint, method='patch', json=json)
def post(self, relative_endpoint, json=None, data=None, headers=None):
return self.request(relative_endpoint, method='post', json=json, data=data, headers=headers)
def put(self, relative_endpoint, json):
return self.request(relative_endpoint, method='put', json=json)

View File

@@ -0,0 +1,7 @@
from .has_create import * # NOQA
from .has_instance_groups import HasInstanceGroups # NOQA
from .has_notifications import HasNotifications # NOQA
from .has_status import HasStatus # NOQA
from .has_survey import HasSurvey # NOQA
from .has_variables import HasVariables # NOQA
from .has_copy import HasCopy # NOQA

View File

@@ -0,0 +1,15 @@
from awxkit.api.pages import Page
from awxkit.utils import random_title
class HasCopy(object):
def can_copy(self):
return self.get_related('copy').can_copy
def copy(self, name=''):
"""Return a copy of current page"""
payload = {"name": name or "Copy - " + random_title()}
endpoint = self.json.related['copy']
page = Page(self.connection, endpoint=endpoint)
return page.post(payload)

View File

@@ -0,0 +1,395 @@
from collections import defaultdict
import inspect
from toposort import toposort
from awxkit.utils import get_class_if_instance, class_name_to_kw_arg, is_proper_subclass, super_dir_set
# HasCreate dependency resolution and creation utilities
def dependency_graph(page, *provided_dependencies):
"""Creates a dependency graph of the form
{page: set(page.dependencies[0:i]),
page.dependencies[0]: set(page.dependencies[0][0:j]
...
page.dependencies[i][j][...][n]: set(page.dependencies[i][j][...][n][0:z]),
...}
Any optional provided_dependencies will be included as if they were dependencies,
without affecting the value of each keyed page.
"""
graph = {}
dependencies = set(getattr(page, 'dependencies', [])) # Some HasCreate's can claim generic Base's w/o dependencies
graph[page] = dependencies
for dependency in dependencies | set(provided_dependencies):
graph.update(dependency_graph(dependency))
return graph
def optional_dependency_graph(page, *provided_dependencies):
"""Creates a dependency graph for a page including all dependencies and optional_dependencies
Any optional provided_dependencies will be included as if they were dependencies,
without affecting the value of each keyed page.
"""
graph = {}
dependencies = set(getattr(page, 'dependencies', []) + getattr(page, 'optional_dependencies', []))
graph[page] = dependencies
for dependency in dependencies | set(provided_dependencies):
graph.update(optional_dependency_graph(dependency))
return graph
def creation_order(graph):
"""returns a list of sets of HasCreate subclasses representing the order of page creation that will
resolve the dependencies of subsequent pages for any non-cyclic dependency_graph
ex:
[set(Organization), set(Inventory), set(Group)]
**The result is based entirely on the passed dependency graph and should be blind
to node attributes.**
"""
return list(toposort(graph))
def separate_async_optionals(creation_order):
"""In cases where creation group items share dependencies but as asymetric optionals,
those that create them as actual dependencies to be later sourced as optionals
need to be listed first
"""
actual_order = []
for group in creation_order:
if len(group) <= 1:
actual_order.append(group)
continue
by_count = defaultdict(set)
has_creates = [cand for cand in group if hasattr(cand, 'dependencies')]
counts = {has_create: 0 for has_create in has_creates}
for has_create in has_creates:
for dependency in has_create.dependencies:
for compared in [cand for cand in has_creates if cand != has_create]:
if dependency in compared.optional_dependencies:
counts[has_create] += 1
for has_create in group:
by_count[counts.get(has_create, 0)].add(has_create)
for count in sorted(by_count, reverse=True):
actual_order.append(by_count[count])
return actual_order
def page_creation_order(page=None, *provided_dependencies):
"""returns a creation_order() where HasCreate subclasses do not share creation group sets with members
of their optional_dependencies. All provided_dependencies and their dependencies will also be
included in the creation
"""
if not page:
return []
# dependency_graphs only care about class type
provided_dependencies = [x[0] if isinstance(x, tuple) else x for x in provided_dependencies]
provided_dependencies = [get_class_if_instance(x) for x in provided_dependencies]
# make a set of all pages we may need to create
to_create = set(dependency_graph(page, *provided_dependencies))
# creation order w/ the most accurate dependency graph
full_graph_order = creation_order(optional_dependency_graph(page, *provided_dependencies))
order = []
for group in full_graph_order:
to_append = group & to_create # we only care about pages we may need to create
if to_append:
order.append(to_append)
actual_order = separate_async_optionals(order)
return actual_order
def all_instantiated_dependencies(*potential_parents):
"""returns a list of all instantiated dependencies including parents themselves.
Will be in page_creation_order
"""
scope_provided_dependencies = []
instantiated = set([x for x in potential_parents
if not isinstance(x, type) and not isinstance(x, tuple)])
for potential_parent in [x for x in instantiated if hasattr(x, '_dependency_store')]:
for dependency in potential_parent._dependency_store.values():
if dependency and dependency not in scope_provided_dependencies:
scope_provided_dependencies.extend(all_instantiated_dependencies(dependency))
scope_provided_dependencies.extend(instantiated)
scope_provided_dependencies = list(set(scope_provided_dependencies))
class_to_provided = {}
for provided in scope_provided_dependencies:
if provided.__class__ in class_to_provided:
class_to_provided[provided.__class__].append(provided)
else:
class_to_provided[provided.__class__] = [provided]
all_instantiated = []
for group in page_creation_order(*scope_provided_dependencies):
for item in group:
if item in class_to_provided:
all_instantiated.extend(class_to_provided[item])
del class_to_provided[item]
elif item.__class__ in class_to_provided:
all_instantiated.extend(class_to_provided[item.__class__])
del class_to_provided[item.__class__]
return all_instantiated
class DSAdapter(object):
"""Access HasCreate._dependency_store dependencies by attribute instead of class.
ex:
```
base_sc = HasCreate().create(inventory=awxkit.api.Inventory)
base_sc._dependency_store[Inventory] == base.ds.inventory
```
"""
def __init__(self, owner, dependency_store):
self.owner = owner
self.dependency_store = dependency_store
self._lookup = {class_name_to_kw_arg(cls.__name__): cls for cls in dependency_store}
def __repr__(self):
return self.__str__()
def __str__(self):
return str(list(self._lookup.keys()))
def __getattr__(self, attr):
if attr in self._lookup:
dep = self.dependency_store[self._lookup[attr]]
if dep:
return dep
raise AttributeError('{0.owner} has no dependency "{1}"'.format(self, attr))
def __getitem__(self, item):
return getattr(self, item)
def __iter__(self):
return iter(self._lookup)
def __dir__(self):
attrs = super_dir_set(self.__class__)
if '_lookup' in self.__dict__ and hasattr(self._lookup, 'keys'):
attrs.update(self._lookup.keys())
return sorted(attrs)
# Hijack json.dumps and simplejson.dumps (used by requests)
# to allow HasCreate.create_payload() serialization without impacting payload.ds access
def filter_ds_from_payload(dumps):
def _filter_ds_from_payload(obj, *a, **kw):
if hasattr(obj, 'get') and isinstance(obj.get('ds'), DSAdapter):
filtered = obj.copy()
del filtered['ds']
else:
filtered = obj
return dumps(filtered, *a, **kw)
return _filter_ds_from_payload
import json # noqa
json.dumps = filter_ds_from_payload(json.dumps)
try:
import simplejson # noqa
simplejson.dumps = filter_ds_from_payload(simplejson.dumps)
except ImportError:
pass
class HasCreate(object):
# For reference only. Use self.ds, or self._dependency_store if mutating.
dependencies = []
optional_dependencies = []
# Provides introspection capability in recursive create_and_update_dependencies calls
_scoped_dependencies_by_frame = dict()
def __init__(self, *a, **kw):
dependency_store = kw.get('ds')
if dependency_store is None:
deps = self.dependencies + self.optional_dependencies
self._dependency_store = {base_subclass: None for base_subclass in deps}
self.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
else:
self._dependency_store = dependency_store.dependency_store
self.ds = dependency_store
super(HasCreate, self).__init__(*a, **kw)
def _update_dependencies(self, dependency_candidates):
"""updates self._dependency_store to reflect instantiated dependencies, if any."""
if self._dependency_store:
potentials = []
# in case the candidate is an instance of a desired base class
# (e.g. Project for self._dependency_store = {'UnifiedJobTemplate': None})
# we try each of its base classes until a match is found
base_lookup = {}
for candidate in dependency_candidates:
for cls_type in inspect.getmro(candidate[0].__class__):
if cls_type in self._dependency_store:
base_lookup[candidate[0]] = cls_type
potentials.append(candidate)
break
second_pass = []
for candidate, claimed in potentials:
if claimed:
self._dependency_store[base_lookup[candidate]] = candidate
else:
second_pass.append(candidate)
# Technical Debt: We need to iron out the expected behavior of multiple instances
# of unclaimed types. Right now the last one in potentials is marked as a dependency.
second_pass.reverse() # for the last one in the list to be marked we need to reverse.
for candidate in second_pass:
if not self._dependency_store[base_lookup[candidate]]:
self._dependency_store[base_lookup[candidate]] = candidate
def create_and_update_dependencies(self, *provided_and_desired_dependencies):
"""in order creation of dependencies and updating of self._dependency_store
to include instances, indexed by page class. If a (HasCreate, dict()) tuple is
provided as a desired dependency, the dict() will be unpacked as kwargs for the
`HasCreate.create(**dict())` call.
***
Providing (HasCreate, dict()) tuples for dependency args to this method
removes the assurance that all shared dependencies types will be the same instance
and only one instance of each type is created
(Tech Debt: can create orphans if default dependency isn't claimed).
The provided args are only in scope of the desired page, override any previously created
instance of the same class, and replace said instances in the continuing chain.
***
```
ex:
self.dependencies = [awxkit.api.pages.Inventory]
self.create_and_update_dependencies()
inventory = self._dependency_store[awxkit.api.pages.Inventory]
ex:
self.dependencies = [awxkit.api.pages.Inventory]
self.create_and_update_dependencies((awxkit.api.pages.Inventory, dict(attr_one=1, attr_two=2)))
inventory = self._dependency_store[awxkit.api.pages.Inventory]
# assume kwargs are set as attributes by Inventory.create()
inventory.attr_one == 1
> True
inventory.attr_two == 2
> True
ex:
self.dependencies = []
self.optional_dependencies = [awxkit.api.pages.Organization]
self.create_and_update_dependencies(awxkit.api.pages.Organization)
organization = self._dependency_store[awxkit.api.pages.Organization]
ex:
self.dependencies = [awxkit.api.pages.Inventory]
inventory = v2.inventories.create()
self.create_and_update_dependencies(inventory)
inventory == self._dependency_store[awxkit.api.pages.Inventory]
> True
```
"""
if not any((self.dependencies, self.optional_dependencies)):
return
# remove falsy values
provided_and_desired_dependencies = [x for x in provided_and_desired_dependencies if x]
# (HasCreate(), True) tells HasCreate._update_dependencies to link
provided_dependencies = [(x, True) for x in provided_and_desired_dependencies
if not isinstance(x, type) and not isinstance(x, tuple)]
# Since dependencies are often declared at runtime, we need to use some introspection
# to determine previously created ones for proper dependency store linking.
# This is done by keeping an updated dependency record by the root caller's frame
caller_frame = inspect.currentframe()
self.parent_frame = None
for frame in inspect.stack()[1:]:
if frame[3] == 'create_and_update_dependencies':
self.parent_frame = frame[0]
if not self.parent_frame:
# a maintained dict of instantiated resources keyed by lowercase class name to be
# expanded as keyword args during `create()` calls
all_instantiated = all_instantiated_dependencies(*[d[0] for d in provided_dependencies])
scoped_dependencies = {class_name_to_kw_arg(d.__class__.__name__): d for d in all_instantiated}
self._scoped_dependencies_by_frame[caller_frame] = [self, scoped_dependencies]
else:
scoped_dependencies = self._scoped_dependencies_by_frame[self.parent_frame][1]
desired_dependencies = []
desired_dependency_classes = []
for item in provided_and_desired_dependencies:
if isinstance(item, tuple):
item_cls = item[0]
elif inspect.isclass(item):
item_cls = item
else:
item_cls = item.__class__
if item_cls not in [x[0].__class__ for x in provided_dependencies]:
desired_dependency_classes.append(item_cls)
desired_dependencies.append(item)
if desired_dependencies:
ordered_desired_dependencies = []
creation_order = [item for s in page_creation_order(*desired_dependency_classes) for item in s]
for item in creation_order:
for desired in desired_dependency_classes:
if desired == item or is_proper_subclass(desired, item):
ordered_desired_dependencies.append(desired)
desired_dependency_classes.remove(desired)
break
# keep track of (HasCreate, kwarg_dict)
provided_with_kwargs = dict()
for page_cls, provided_kwargs in [x for x in desired_dependencies if isinstance(x, tuple)]:
provided_with_kwargs[page_cls] = provided_kwargs
for to_create in ordered_desired_dependencies:
scoped_args = dict(scoped_dependencies)
if to_create in provided_with_kwargs:
scoped_args.pop(to_create, None) # remove any conflicts in favor of explicit kwargs
scoped_args.update(provided_with_kwargs.pop(to_create))
scoped_args.pop(class_name_to_kw_arg(to_create.__name__), None)
created = to_create(self.connection).create(**scoped_args)
provided_dependencies.append((created, True))
for dependency, _ in provided_dependencies:
if dependency not in scoped_dependencies:
scoped_dependencies[class_name_to_kw_arg(dependency.__class__.__name__)] = dependency
self._update_dependencies(provided_dependencies)
if not self.parent_frame:
del self._scoped_dependencies_by_frame[caller_frame]
def teardown(self):
"""Calls `silent_cleanup()` on all dependencies and self in reverse page creation order."""
to_teardown = all_instantiated_dependencies(self)
to_teardown_types = set(map(get_class_if_instance, to_teardown))
order = [
set(
[
potential for potential in (
get_class_if_instance(x) for x in group) if potential in to_teardown_types
]
)
for group in page_creation_order(self, *to_teardown)
]
order.reverse()
for teardown_group in order:
for teardown_class in teardown_group:
instance = [x for x in to_teardown if isinstance(x, teardown_class)].pop()
instance.silent_cleanup()
for item in to_teardown:
for dep_type, dep in item._dependency_store.items():
if dep and dep_type in to_teardown_types:
item._dependency_store[dep_type] = None # Note that we don't call del

View File

@@ -0,0 +1,17 @@
from awxkit.utils import suppress
import awxkit.exceptions as exc
class HasInstanceGroups(object):
def add_instance_group(self, instance_group):
with suppress(exc.NoContent):
self.related['instance_groups'].post(dict(id=instance_group.id))
def remove_instance_group(self, instance_group):
with suppress(exc.NoContent):
self.related['instance_groups'].post(dict(id=instance_group.id, disassociate=instance_group.id))
def remove_all_instance_groups(self):
for ig in self.related.instance_groups.get().results:
self.remove_instance_group(ig)

View File

@@ -0,0 +1,23 @@
from awxkit.utils import suppress
import awxkit.exceptions as exc
notification_endpoints = ("notification_templates", "notification_templates_started", "notification_templates_error",
"notification_templates_success")
class HasNotifications(object):
def add_notification_template(self, notification_template, endpoint="notification_templates_success"):
if endpoint not in notification_endpoints:
raise ValueError('Unsupported notification endpoint "{0}". Please use one of {1}.'
.format(endpoint, notification_endpoints))
with suppress(exc.NoContent):
self.related[endpoint].post(dict(id=notification_template.id))
def remove_notification_template(self, notification_template, endpoint="notification_templates_success"):
if endpoint not in notification_endpoints:
raise ValueError('Unsupported notification endpoint "{0}". Please use one of {1}.'
.format(endpoint, notification_endpoints))
with suppress(exc.NoContent):
self.related[endpoint].post(dict(id=notification_template.id, disassociate=notification_template.id))

View File

@@ -0,0 +1,85 @@
from datetime import datetime
import json
from awxkit.utils import poll_until
def bytes_to_str(obj):
try:
return obj.decode()
except AttributeError:
return str(obj)
class HasStatus(object):
completed_statuses = ['successful', 'failed', 'error', 'canceled']
started_statuses = ['pending', 'running'] + completed_statuses
@property
def is_completed(self):
return self.status.lower() in self.completed_statuses
@property
def is_successful(self):
return self.status == 'successful'
def wait_until_status(self, status, interval=1, timeout=60, **kwargs):
status = [status] if not isinstance(status, (list, tuple)) else status
poll_until(lambda: getattr(self.get(), 'status') in status, interval=interval, timeout=timeout, **kwargs)
return self
def wait_until_completed(self, interval=5, timeout=60, **kwargs):
start_time = datetime.utcnow()
HasStatus.wait_until_status(self, self.completed_statuses, interval=interval, timeout=timeout, **kwargs)
if not getattr(self, 'event_processing_finished', True):
elapsed = datetime.utcnow() - start_time
time_left = timeout - elapsed.total_seconds()
poll_until(lambda: getattr(self.get(), 'event_processing_finished', True),
interval=interval, timeout=time_left, **kwargs)
return self
def wait_until_started(self, interval=1, timeout=60):
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
def assert_status(self, status_list, msg=None):
if isinstance(status_list, str):
status_list = [status_list]
if self.status in status_list:
# include corner cases in is_successful logic
if 'successful' not in status_list or self.is_successful:
return
if msg is None:
msg = ''
else:
msg += '\n'
msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(
self.type.title(), self.id, self.status, status_list
)
if getattr(self, 'job_explanation', ''):
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
if getattr(self, 'result_traceback', ''):
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
if getattr(self, 'result_stdout', ''):
output = bytes_to_str(self.result_stdout)
if output:
msg = msg + '\nstdout:\n{}'.format(output)
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
try:
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
dep_output = self.connection.get(
'{0}/api/v2/{1}s/{2}/stdout/'.format(
self.endpoint.split('/api')[0], data['job_type'], data['job_id']
),
query_parameters=dict(format='txt_download')
).content
msg += '\nDependency output:\n{}'.format(bytes_to_str(dep_output))
except Exception as e:
msg += '\nFailed to obtain dependency stdout: {}'.format(e)
msg += '\nTIME WHEN STATUS WAS FOUND: {} (UTC)\n'.format(datetime.utcnow())
raise AssertionError(msg)
def assert_successful(self, msg=None):
return self.assert_status('successful', msg=msg)

View File

@@ -0,0 +1,15 @@
from awxkit.utils import random_title
class HasSurvey(object):
def add_survey(self, spec=None, name=None, description=None, required=False, enabled=True):
payload = dict(name=name or 'Survey - {}'.format(random_title()),
description=description or random_title(10),
spec=spec or [dict(required=required,
question_name="What's the password?",
variable="secret",
type="password",
default="foo")])
if enabled != self.survey_enabled:
self.patch(survey_enabled=enabled)
return self.related.survey_spec.post(payload).get()

View File

@@ -0,0 +1,10 @@
import yaml
from awxkit.utils import PseudoNamespace
class HasVariables(object):
@property
def variables(self):
return PseudoNamespace(yaml.load(self.json.variables, Loader=yaml.FullLoader))

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)

View File

@@ -0,0 +1,161 @@
from collections import defaultdict
import logging
import re
from awxkit.utils import is_list_or_tuple, not_provided
log = logging.getLogger(__name__)
class URLRegistry(object):
def __init__(self):
self.store = defaultdict(dict)
self.default = {}
def url_pattern(self, pattern_str):
"""Converts some regex-friendly url pattern (Resources().resource string)
to a compiled pattern.
"""
# should account for any relative endpoint w/ query parameters
pattern = r'^' + pattern_str + r'(\?.*)*$'
return re.compile(pattern)
def _generate_url_iterable(self, url_iterable):
parsed_urls = []
for url in url_iterable:
method = not_provided
if is_list_or_tuple(url):
url, method = url
if not is_list_or_tuple(method):
methods = (method,)
else:
methods = method
for method in methods:
method_pattern = re.compile(method)
url_pattern = self.url_pattern(url)
parsed_urls.append((url_pattern, method_pattern))
return parsed_urls
def register(self, *args):
"""Registers a single resource (generic python type or object) to either
1. a single url string (internally coverted via URLRegistry.url_pattern) and optional method or method iterable
2. a list or tuple of url string and optional method or method iterables
for retrieval via get().
reg.register('/some/path/', ResourceOne)
reg.get('/some/path/')
-> ResourceOne
reg.register('/some/other/path/', 'method', ResourceTwo)
reg.get('/some/other/path/', 'method')
-> ResourceTwo
reg.register('/some/additional/path/', ('method_one', 'method_two'), ResourceThree)
reg.get('/some/additional/path/', 'method_one')
-> ResourceThree
reg.get('/some/additional/path/', 'method_two')
-> ResourceThree
reg.register(('/some/new/path/one/', '/some/new/path/two/',
('/some/other/new/path', 'method'),
('/some/other/additional/path/, ('method_one', 'method_two')), ResourceFour))
reg.get('/some/other/new/path/', 'method')
-> ResourceFour
"""
if not args or len(args) == 1:
raise TypeError('register needs at least a url and Resource.')
elif len(args) not in (2, 3):
raise TypeError('register takes at most 3 arguments ({} given).'.format(len(args)))
if len(args) == 3: # url, method (iterable), and Resource
url_iterable = (args[:2],)
resource = args[2]
else:
urls, resource = args
if not is_list_or_tuple(urls):
url_iterable = [(urls, not_provided)]
else:
url_iterable = urls
url_iterable = self._generate_url_iterable(url_iterable)
for url_pattern, method_pattern in url_iterable:
if url_pattern in self.store and method_pattern in self.store[url_pattern]:
if method_pattern.pattern == not_provided:
exc_msg = '"{0.pattern}" already has methodless registration.'.format(url_pattern, method_pattern)
else:
exc_msg = ('"{0.pattern}" already has registered method "{1.pattern}"'
.format(url_pattern, method_pattern))
raise TypeError(exc_msg)
self.store[url_pattern][method_pattern] = resource
def setdefault(self, *args):
"""Establishes a default return value for get() by optional method (iterable).
reg.setdefault(ResourceOne)
reg.get('/some/unregistered/path')
-> ResourceOne
reg.setdefault('method', ResourceTwo)
reg.get('/some/registered/methodless/path/', 'method')
-> ResourceTwo
reg.setdefault(('method_one', 'method_two'), ResourceThree)
reg.get('/some/unregistered/path', 'method_two')
-> ResourceThree
reg.setdefault('supports.*regex', ResourceFour)
reg.get('supports123regex')
-> ResourceFour
"""
if not args:
raise TypeError('setdefault needs at least a Resource.')
if len(args) == 1: # all methods
self.default[re.compile('.*')] = args[0]
elif len(args) == 2:
if is_list_or_tuple(args[0]):
methods = args[0]
else:
methods = (args[0],)
for method in methods:
method_pattern = re.compile(method)
self.default[method_pattern] = args[1]
else:
raise TypeError('setdefault takes at most 2 arguments ({} given).'.format(len(args)))
def get(self, url, method=not_provided):
"""Returns a single resource by previously registered path and optional method where
1. If a registration was methodless and a method is provided to get() the return value will be
None or, if applicable, a registry default (see setdefault()).
2. If a registration included a method (excluding the method wildcard '.*') and no method is provided to get()
the return value will be None or, if applicable, a registry default.
reg.register('/some/path/', ResourceOne)
reg.get('/some/path/')
-> ResourceOne
reg.get('/some/path/', 'method')
-> None
reg.register('/some/other/path/', 'method', ResourceTwo)
reg.get('/some/other/path/', 'method')
-> ResourceTwo
reg.get('/some/other/path')
-> None
reg.register('/some/additional/path/', '.*', ResourceThree)
reg.get('/some/additional/path/', 'method')
-> ResourceThree
reg.get('/some/additional/path/')
-> ResourceThree
"""
registered_type = None
default_methods = list(self.default)
# Make sure dot character evaluated last
default_methods.sort(key=lambda x: x.pattern == '.*')
for method_key in default_methods:
if method_key.match(method):
registered_type = self.default[method_key]
break
for re_key in self.store:
if re_key.match(url):
keys = list(self.store[re_key])
keys.sort(key=lambda x: x.pattern == '.*')
for method_key in keys:
if method_key.match(method):
registered_type = self.store[re_key][method_key]
break
log.debug('Retrieved {} by url: {}'.format(registered_type, url))
return registered_type

View File

@@ -0,0 +1,285 @@
class Resources(object):
_activity = r'activity_stream/\d+/'
_activity_stream = 'activity_stream/'
_ad_hoc_command = r'ad_hoc_commands/\d+/'
_ad_hoc_command_relaunch = r'ad_hoc_commands/\d+/relaunch/'
_ad_hoc_commands = 'ad_hoc_commands/'
_ad_hoc_event = r'ad_hoc_command_events/\d+/'
_ad_hoc_events = r'ad_hoc_commands/\d+/events/'
_ad_hoc_related_cancel = r'ad_hoc_commands/\d+/cancel/'
_ad_hoc_relaunch = r'ad_hoc_commands/\d+/relaunch/'
_ansible_facts = r'hosts/\d+/ansible_facts/'
_application = r'applications/\d+/'
_applications = 'applications/'
_auth = 'auth/'
_authtoken = 'authtoken/'
_config = 'config/'
_credential = r'credentials/\d+/'
_credential_access_list = r'credentials/\d+/access_list/'
_credential_copy = r'credentials/\d+/copy/'
_credential_input_source = r'credential_input_sources/\d+/'
_credential_input_sources = 'credential_input_sources/'
_credential_owner_teams = r'credentials/\d+/owner_teams/'
_credential_owner_users = r'credentials/\d+/owner_users/'
_credential_type = r'credential_types/\d+/'
_credential_types = 'credential_types/'
_credentials = 'credentials/'
_dashboard = 'dashboard/'
_fact_view = r'hosts/\d+/fact_view/'
_group = r'groups/\d+/'
_group_access_list = r'groups/\d+/access_list/'
_group_children = r'groups/\d+/children/'
_group_potential_children = r'groups/\d+/potential_children/'
_group_related_ad_hoc_commands = r'groups/\d+/ad_hoc_commands/'
_group_related_all_hosts = r'groups/\d+/all_hosts/'
_group_related_hosts = r'groups/\d+/hosts/'
_group_related_job_events = r'groups/\d+/job_events/'
_group_related_job_host_summaries = r'groups/\d+/job_host_summaries/'
_group_variable_data = r'groups/\d+/variable_data/'
_groups = 'groups/'
_host = r'hosts/\d+/'
_host_groups = r'hosts/\d+/groups/'
_host_insights = r'hosts/\d+/insights/'
_host_related_ad_hoc_commands = r'hosts/\d+/ad_hoc_commands/'
_host_related_fact_version = r'hosts/\d+/fact_versions/\d+/'
_host_related_fact_versions = r'hosts/\d+/fact_versions/'
_host_variable_data = r'hosts/\d+/variable_data/'
_hosts = 'hosts/'
_instance = r'instances/\d+/'
_instance_group = r'instance_groups/\d+/'
_instance_group_related_jobs = r'instance_groups/\d+/jobs/'
_instance_groups = 'instance_groups/'
_instance_related_jobs = r'instances/\d+/jobs/'
_instances = 'instances/'
_inventories = 'inventories/'
_inventory = r'inventories/\d+/'
_inventory_access_list = r'inventories/\d+/access_list/'
_inventory_copy = r'inventories/\d+/copy/'
_inventory_related_ad_hoc_commands = r'inventories/\d+/ad_hoc_commands/'
_inventory_related_groups = r'inventories/\d+/groups/'
_inventory_related_hosts = r'inventories/\d+/hosts/'
_inventory_related_root_groups = r'inventories/\d+/root_groups/'
_inventory_related_script = r'inventories/\d+/script/'
_inventory_related_update_inventory_sources = r'inventories/\d+/update_inventory_sources/'
_inventory_scan_job_templates = r'inventories/\d+/scan_job_templates/'
_inventory_script = r'inventory_scripts/\d+/'
_inventory_script_copy = r'inventory_scripts/\d+/copy/'
_inventory_scripts = 'inventory_scripts/'
_inventory_source = r'inventory_sources/\d+/'
_inventory_source_schedule = r'inventory_sources/\d+/schedules/\d+/'
_inventory_source_schedules = r'inventory_sources/\d+/schedules/'
_inventory_source_updates = r'inventory_sources/\d+/inventory_updates/'
_inventory_sources = 'inventory_sources/'
_inventory_sources_related_groups = r'inventory_sources/\d+/groups/'
_inventory_sources_related_hosts = r'inventory_sources/\d+/hosts/'
_inventory_sources_related_update = r'inventory_sources/\d+/update/'
_inventory_tree = r'inventories/\d+/tree/'
_inventory_update = r'inventory_updates/\d+/'
_inventory_update_cancel = r'inventory_updates/\d+/cancel/'
_inventory_update_events = r'inventory_updates/\d+/events/'
_inventory_updates = 'inventory_updates/'
_inventory_variable_data = r'inventories/\d+/variable_data/'
_job = r'jobs/\d+/'
_job_cancel = r'jobs/\d+/cancel/'
_job_create_schedule = r'jobs/\d+/create_schedule/'
_job_event = r'job_events/\d+/'
_job_event_children = r'job_events/\d+/children/'
_job_events = 'job_events/'
_job_extra_credentials = _job + 'extra_credentials/'
_job_host_summaries = r'jobs/\d+/job_host_summaries/'
_job_host_summary = r'job_host_summaries/\d+/'
_job_job_event = r'jobs/\d+/job_events/\d+/'
_job_job_events = r'jobs/\d+/job_events/'
_job_labels = r'jobs/\d+/labels/'
_job_notifications = r'jobs/\d+/notifications/'
_job_play = r'jobs/\d+/job_plays/\d+/'
_job_plays = r'jobs/\d+/job_plays/'
_job_relaunch = r'jobs/\d+/relaunch/'
_job_start = r'jobs/\d+/start/'
_job_task = r'jobs/\d+/job_tasks/\d+/'
_job_tasks = r'jobs/\d+/job_tasks/'
_job_template = r'job_templates/\d+/'
_job_template_access_list = r'job_templates/\d+/access_list/'
_job_template_callback = r'job_templates/\d+/callback/'
_job_template_copy = r'job_templates/\d+/copy/'
_job_template_extra_credentials = _job_template + 'extra_credentials/'
_job_template_jobs = r'job_templates/\d+/jobs/'
_job_template_labels = r'job_templates/\d+/labels/'
_job_template_launch = r'job_templates/\d+/launch/'
_job_template_schedule = r'job_templates/\d+/schedules/\d+/'
_job_template_schedules = r'job_templates/\d+/schedules/'
_job_template_slice_workflow_jobs = r'job_templates/\d+/slice_workflow_jobs/'
_job_template_survey_spec = r'job_templates/\d+/survey_spec/'
_job_templates = 'job_templates/'
_jobs = 'jobs/'
_label = r'labels/\d+/'
_labels = 'labels/'
_me = 'me/'
_metrics = 'metrics/'
_notification = r'notifications/\d+/'
_notification_template = r'notification_templates/\d+/'
_notification_template_any = r'\w+/\d+/notification_templates_any/\d+/'
_notification_template_copy = r'notification_templates/\d+/copy/'
_notification_template_error = r'\w+/\d+/notification_templates_error/\d+/'
_notification_template_success = r'\w+/\d+/notification_templates_success/\d+/'
_notification_template_test = r'notification_templates/\d+/test/'
_notification_templates = 'notification_templates/'
_notification_templates_any = r'\w+/\d+/notification_templates_any/'
_notification_templates_error = r'\w+/\d+/notification_templates_error/'
_notification_templates_success = r'\w+/\d+/notification_templates_success/'
_notifications = 'notifications/'
_object_activity_stream = r'[^/]+/\d+/activity_stream/'
_org_projects = r'organizations/\d+/projects/'
_org_teams = r'organizations/\d+/teams/'
_organization = r'organizations/\d+/'
_organization_access_list = r'organizations/\d+/access_list/'
_organization_admins = r'organizations/\d+/admins/'
_organization_applications = r'organizations/\d+/applications/'
_organization_inventories = r'organizations/\d+/inventories/'
_organization_users = r'organizations/\d+/users/'
_organizations = 'organizations/'
_ping = 'ping/'
_project = r'projects/\d+/'
_project_access_list = r'projects/\d+/access_list/'
_project_copy = r'projects/\d+/copy/'
_project_inventories = r'projects/\d+/inventories/'
_project_organizations = r'projects/\d+/organizations/'
_project_playbooks = r'projects/\d+/playbooks/'
_project_project_updates = r'projects/\d+/project_updates/'
_project_related_update = r'projects/\d+/update/'
_project_schedule = r'projects/\d+/schedules/\d+/'
_project_schedules = r'projects/\d+/schedules/'
_project_scm_inventory_sources = r'projects/\d+/scm_inventory_sources/'
_project_teams = r'projects/\d+/teams/'
_project_update = r'project_updates/\d+/'
_project_update_cancel = r'project_updates/\d+/cancel/'
_project_update_events = r'project_updates/\d+/events/'
_project_update_scm_inventory_updates = r'project_updates/\d+/scm_inventory_updates/'
_project_updates = 'project_updates/'
_projects = 'projects/'
_related_credentials = r'\w+/\d+/credentials/'
_related_input_sources = r'\w+/\d+/input_sources/'
_related_instance_groups = r'\w+/\d+/instance_groups/'
_related_instances = r'\w+/\d+/instances/'
_related_inventories = r'(?!projects)\w+/\d+/inventories/' # project related inventories are inventory files (.ini)
_related_inventory_sources = r'\w+/\d+/inventory_sources/'
_related_job_templates = r'\w+/\d+/job_templates/'
_related_notification_templates = r'\w+/\d+/notification_templates/'
_related_notifications = r'\w+/\d+/notifications/'
_related_object_roles = r'\w+/\d+/object_roles/'
_related_projects = r'\w+/\d+/projects/'
_related_roles = r'\w+/\d+/roles/'
_related_schedule = r'\w+/\d+/schedules/\d+/'
_related_schedules = r'\w+/\d+/schedules/'
_related_stdout = r'\w+/\d+/stdout/'
_related_teams = r'\w+/\d+/teams/'
_related_users = r'\w+/\d+/users/'
_related_workflow_job_templates = r'\w+/\d+/workflow_job_templates/'
_role = r'roles/\d+/'
_roles = 'roles/'
_roles_related_teams = r'roles/\d+/teams/'
_schedule = r'schedules/\d+/'
_schedules = 'schedules/'
_schedules_jobs = r'schedules/\d+/jobs/'
_schedules_preview = 'schedules/preview/'
_schedules_zoneinfo = 'schedules/zoneinfo/'
_setting = r'settings/\w+/'
_settings = 'settings/'
_settings_all = 'settings/all/'
_settings_authentication = 'settings/authentication/'
_settings_azuread_oauth2 = 'settings/azuread-oauth2/'
_settings_changed = 'settings/changed/'
_settings_github = 'settings/github/'
_settings_github_org = 'settings/github-org/'
_settings_github_team = 'settings/github-team/'
_settings_google_oauth2 = 'settings/google-oauth2/'
_settings_jobs = 'settings/jobs/'
_settings_ldap = 'settings/ldap/'
_settings_logging = 'settings/logging/'
_settings_named_url = 'settings/named-url/'
_settings_radius = 'settings/radius/'
_settings_saml = 'settings/saml/'
_settings_system = 'settings/system/'
_settings_tacacsplus = 'settings/tacacsplus/'
_settings_ui = 'settings/ui/'
_settings_user = 'settings/user/'
_settings_user_defaults = 'settings/user-defaults/'
_system_job = r'system_jobs/\d+/'
_system_job_cancel = r'system_jobs/\d+/cancel/'
_system_job_events = r'system_jobs/\d+/events/'
_system_job_template = r'system_job_templates/\d+/'
_system_job_template_jobs = r'system_job_templates/\d+/jobs/'
_system_job_template_launch = r'system_job_templates/\d+/launch/'
_system_job_template_schedule = r'system_job_templates/\d+/schedules/\d+/'
_system_job_template_schedules = r'system_job_templates/\d+/schedules/'
_system_job_templates = 'system_job_templates/'
_system_jobs = 'system_jobs/'
_team = r'teams/\d+/'
_team_access_list = r'teams/\d+/access_list/'
_team_credentials = r'teams/\d+/credentials/'
_team_permission = r'teams/\d+/permissions/\d+/'
_team_permissions = r'teams/\d+/permissions/'
_team_users = r'teams/\d+/users/'
_teams = 'teams/'
_token = r'tokens/\d+/'
_tokens = 'tokens/'
_unified_job_template = r'unified_job_templates/\d+/'
_unified_job_templates = 'unified_job_templates/'
_unified_jobs = 'unified_jobs/'
_user = r'users/\d+/'
_user_access_list = r'users/\d+/access_list/'
_user_admin_organizations = r'users/\d+/admin_of_organizations/'
_user_credentials = r'users/\d+/credentials/'
_user_organizations = r'users/\d+/organizations/'
_user_permission = r'users/\d+/permissions/\d+/'
_user_permissions = r'users/\d+/permissions/'
_user_teams = r'users/\d+/teams/'
_users = 'users/'
_variable_data = r'.*\/variable_data/'
_workflow_job = r'workflow_jobs/\d+/'
_workflow_job_cancel = r'workflow_jobs/\d+/cancel/'
_workflow_job_labels = r'workflow_jobs/\d+/labels/'
_workflow_job_node = r'workflow_job_nodes/\d+/'
_workflow_job_node_always_nodes = r'workflow_job_nodes/\d+/always_nodes/'
_workflow_job_node_failure_nodes = r'workflow_job_nodes/\d+/failure_nodes/'
_workflow_job_node_success_nodes = r'workflow_job_nodes/\d+/success_nodes/'
_workflow_job_nodes = 'workflow_job_nodes/'
_workflow_job_relaunch = r'workflow_jobs/\d+/relaunch/'
_workflow_job_template = r'workflow_job_templates/\d+/'
_workflow_job_template_copy = r'workflow_job_templates/\d+/copy/'
_workflow_job_template_jobs = r'workflow_job_templates/\d+/workflow_jobs/'
_workflow_job_template_labels = r'workflow_job_templates/\d+/labels/'
_workflow_job_template_launch = r'workflow_job_templates/\d+/launch/'
_workflow_job_template_node = r'workflow_job_template_nodes/\d+/'
_workflow_job_template_node_always_nodes = r'workflow_job_template_nodes/\d+/always_nodes/'
_workflow_job_template_node_failure_nodes = r'workflow_job_template_nodes/\d+/failure_nodes/'
_workflow_job_template_node_success_nodes = r'workflow_job_template_nodes/\d+/success_nodes/'
_workflow_job_template_nodes = 'workflow_job_template_nodes/'
_workflow_job_template_schedule = r'workflow_job_templates/\d+/schedules/\d+/'
_workflow_job_template_schedules = r'workflow_job_templates/\d+/schedules/'
_workflow_job_template_survey_spec = r'workflow_job_templates/\d+/survey_spec/'
_workflow_job_template_workflow_nodes = r'workflow_job_templates/\d+/workflow_nodes/'
_workflow_job_templates = 'workflow_job_templates/'
_workflow_job_workflow_nodes = r'workflow_jobs/\d+/workflow_nodes/'
_workflow_jobs = 'workflow_jobs/'
api = '/api/'
common = api + r'v\d+/'
v2 = api + 'v2/'
def __getattr__(self, resource):
if resource[:3] == '___':
raise AttributeError('No existing resource: {}'.format(resource))
# Currently we don't handle anything under:
# /api/o/
# /api/login/
# /api/logout/
# If/when we do we will probably need to modify this __getattr__ method
# Also, if we add another API version, this would be handled here
prefix = 'v2'
resource = '_' + resource
return '{0}{1}'.format(getattr(self, prefix), getattr(self, resource))
resources = Resources()