File awx-24.6.1.obscpio of Package python-awx-cli
07070100000000000081A400000000000000000000000166846B9200000469000000000000000000000000000000000000001600000000awx-24.6.1/.gitignore# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
report.xml
report.pylama
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
# vim
*.swp
# mac OS
*.DS_Store
# pytest
*.pytest_cache
07070100000001000081A400000000000000000000000166846B920000008A000000000000000000000000000000000000001700000000awx-24.6.1/MANIFEST.ininclude requirements.txt
include setup.py
include VERSION
recursive-include awxkit *.py *.yml *.md
recursive-include test *.py *.yml *.md
07070100000002000081A400000000000000000000000166846B9200000149000000000000000000000000000000000000001500000000awx-24.6.1/README.mdawxkit
======
A Python library that backs the provided `awx` command line client.
It can be installed by running `pip install awxkit`.
The PyPI respository can be found [here](https://pypi.org/project/awxkit/).
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs).07070100000003000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001200000000awx-24.6.1/awxkit07070100000004000081A400000000000000000000000166846B92000000A7000000000000000000000000000000000000001E00000000awx-24.6.1/awxkit/__init__.pyfrom awxkit.api import pages, client, resources # NOQA
from awxkit.config import config # NOQA
from awxkit import awx # NOQA
from awxkit.ws import WSClient # NOQA
07070100000005000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001600000000awx-24.6.1/awxkit/api07070100000006000081A400000000000000000000000166846B920000003B000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/api/__init__.pyfrom .pages import * # NOQA
from .client import * # NOQA
07070100000007000081A400000000000000000000000166846B920000141B000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/api/client.pyimport 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):
self.token = token
def __call__(self, request):
request.headers['Authorization'] = 'Bearer {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
# Note: We use the old sessionid here incase someone is trying to connect to an older AWX version
# There is a check below so that if AWX returns an X-API-Session-Cookie-Name we will grab it and
# connect with the new session cookie name.
self.session_cookie_name = 'sessionid'
if not self.verify:
requests.packages.urllib3.disable_warnings()
self.session = requests.Session()
self.uses_session_cookie = False
def get_session_requirements(self, next=config.api_base_path):
self.get(config.api_base_path) # 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()
response = self.post(f"{config.api_base_path}login/", headers=headers, data=dict(username=username, password=password, next=_next))
# The login causes a redirect so we need to search the history of the request to find the header
for historical_response in response.history:
if 'X-API-Session-Cookie-Name' in historical_response.headers:
self.session_cookie_name = historical_response.headers.get('X-API-Session-Cookie-Name')
self.session_id = self.session.cookies.get(self.session_cookie_name, None)
self.uses_session_cookie = True
else:
self.session.auth = (username, password)
elif token:
self.session.auth = Token_Auth(token)
else:
self.session.auth = None
def logout(self):
if self.uses_session_cookie:
self.session.cookies.pop(self.session_cookie_name, 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('/'):
self.server = self.server[:-1]
if use_endpoint.startswith('/'):
use_endpoint = use_endpoint[1:]
url = '/'.join([self.server, 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)
07070100000008000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001D00000000awx-24.6.1/awxkit/api/mixins07070100000009000081A400000000000000000000000166846B920000013F000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/mixins/__init__.pyfrom .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
0707010000000A000081A400000000000000000000000166846B92000001BA000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/mixins/has_copy.pyfrom 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)
0707010000000B000081A400000000000000000000000166846B92000041F5000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/mixins/has_create.pyfrom collections import defaultdict
import inspect
from awxkit.utils import get_class_if_instance, class_name_to_kw_arg, is_proper_subclass, super_dir_set
from awxkit.utils.toposort import toposort
# 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
0707010000000C000081A400000000000000000000000166846B9200000266000000000000000000000000000000000000003400000000awx-24.6.1/awxkit/api/mixins/has_instance_groups.pyfrom contextlib 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)
0707010000000D000081A400000000000000000000000166846B920000064F000000000000000000000000000000000000003200000000awx-24.6.1/awxkit/api/mixins/has_notifications.pyfrom contextlib import suppress
import awxkit.exceptions as exc
notification_endpoints = ("notification_templates", "notification_templates_started", "notification_templates_error", "notification_templates_success")
wfjt_notification_endpoints = notification_endpoints + ('notification_templates_approvals',)
class HasNotifications(object):
def add_notification_template(self, notification_template, endpoint="notification_templates_success"):
from awxkit.api.pages.workflow_job_templates import WorkflowJobTemplate
supported_endpoints = wfjt_notification_endpoints if isinstance(self, WorkflowJobTemplate) else notification_endpoints
if endpoint not in supported_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"):
from awxkit.api.pages.workflow_job_templates import WorkflowJobTemplate
supported_endpoints = wfjt_notification_endpoints if isinstance(self, WorkflowJobTemplate) else notification_endpoints
if endpoint not in supported_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))
0707010000000E000081A400000000000000000000000166846B92000010D7000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/mixins/has_status.pyfrom datetime import datetime
import json
from awxkit.utils import poll_until
from awxkit.exceptions import WaitUntilTimeout
from awxkit.config import config
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
try:
poll_until(lambda: getattr(self.get(), 'status') in status, interval=interval, timeout=timeout, **kwargs)
except WaitUntilTimeout:
# This will raise a more informative error than just "WaitUntilTimeout" error
self.assert_status(status)
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 failure_output_details(self):
msg = ''
if getattr(self, 'result_stdout', ''):
output = bytes_to_str(self.result_stdout)
if output:
msg = '\nstdout:\n{}'.format(output)
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))
return msg
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, 'execution_environment', ''):
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
if getattr(self, 'related', False):
ee = self.related.execution_environment.get()
msg += f'\nee_image: {ee.image}'
msg += f'\nee_credential: {ee.credential}'
msg += f'\nee_pull_option: {ee.pull}'
msg += f'\nee_summary_fields: {ee.summary_fields}'
msg += self.failure_output_details()
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
try:
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
dependency = self.walk('/{0}v2/{1}s/{2}/'.format(config.api_base_path, data['job_type'], data['job_id']))
if hasattr(dependency, 'failure_output_details'):
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
else:
msg += '\nDependency info:\n{}'.format(dependency)
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)
0707010000000F000081A400000000000000000000000166846B920000025E000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/mixins/has_survey.pyfrom 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()
07070100000010000081A400000000000000000000000166846B92000000BF000000000000000000000000000000000000002E00000000awx-24.6.1/awxkit/api/mixins/has_variables.pyimport yaml
from awxkit.utils import PseudoNamespace
class HasVariables(object):
@property
def variables(self):
return PseudoNamespace(yaml.safe_load(self.json.variables))
07070100000011000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001C00000000awx-24.6.1/awxkit/api/pages07070100000012000081A400000000000000000000000166846B9200000641000000000000000000000000000000000000002800000000awx-24.6.1/awxkit/api/pages/__init__.py# Order matters
from .page import * # NOQA
from .base import * # NOQA
from .bulk 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 .execution_environments 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 .workflow_approvals 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
from .subscriptions import * # NOQA
from .workflow_approval_templates import * # NOQA
from .host_metrics import * # NOQA
07070100000013000081A400000000000000000000000166846B92000001FE000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/pages/access_list.pyfrom 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,
)
07070100000014000081A400000000000000000000000166846B920000015C000000000000000000000000000000000000002F00000000awx-24.6.1/awxkit/api/pages/activity_stream.pyfrom 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)
07070100000015000081A400000000000000000000000166846B9200000A25000000000000000000000000000000000000002F00000000awx-24.6.1/awxkit/api/pages/ad_hoc_commands.pyfrom 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,
)
07070100000016000081A400000000000000000000000166846B920000478A000000000000000000000000000000000000002300000000awx-24.6.1/awxkit/api/pages/api.pyfrom collections import defaultdict
import itertools
import logging
from awxkit.api.resources import resources
import awxkit.exceptions as exc
from . import base
from . import page
from .. import utils
from ..mixins import has_create
log = logging.getLogger(__name__)
EXPORTABLE_RESOURCES = [
'users',
'organizations',
'teams',
'credential_types',
'credentials',
'notification_templates',
'projects',
'inventory',
'inventory_sources',
'job_templates',
'workflow_job_templates',
'execution_environments',
'applications',
'schedules',
]
EXPORTABLE_RELATIONS = ['Roles', 'NotificationTemplates', 'WorkflowJobTemplateNodes', 'Credentials', 'Hosts', 'Groups', 'ExecutionEnvironments', 'Schedules']
# These are special-case related objects, where we want only in this
# case to export a full object instead of a natural key reference.
DEPENDENT_EXPORT = [
('JobTemplate', 'Label'),
('JobTemplate', 'SurveySpec'),
('JobTemplate', 'Schedule'),
('WorkflowJobTemplate', 'Label'),
('WorkflowJobTemplate', 'SurveySpec'),
('WorkflowJobTemplate', 'Schedule'),
('WorkflowJobTemplate', 'WorkflowJobTemplateNode'),
('InventorySource', 'Schedule'),
('Inventory', 'Group'),
('Inventory', 'Host'),
('Inventory', 'Label'),
('WorkflowJobTemplateNode', 'WorkflowApprovalTemplate'),
]
# This is for related views where it is unneeded to export anything,
# such as because it is a calculated subset of objects covered by a
# different view.
DEPENDENT_NONEXPORT = [
('InventorySource', 'groups'),
('InventorySource', 'hosts'),
('Inventory', 'root_groups'),
('Group', 'all_hosts'),
('Group', 'potential_children'),
('Host', 'all_groups'),
('WorkflowJobTemplateNode', 'create_approval_template'),
]
class Api(base.Base):
pass
page.register_page(resources.api, Api)
class ApiV2(base.Base):
# Export methods
def _export(self, _page, post_fields):
# Drop any (credential_type) assets that are being managed by the instance.
if _page.json.get('managed'):
log.debug("%s is managed, skipping.", _page.endpoint)
return None
if post_fields is None: # Deprecated endpoint or insufficient permissions
log.error("Object export failed: %s", _page.endpoint)
self._has_error = True
return None
# Note: doing _page[key] automatically parses json blob strings, which can be a problem.
fields = {key: _page.json[key] for key in post_fields if key in _page.json and key not in _page.related and key != 'id'}
# iterate over direct fields in the object
for key in post_fields:
if key in _page.related:
related = _page.related[key]
else:
if post_fields[key]['type'] == 'id' and _page.json.get(key) is not None:
log.warning("Related link %r missing from %s, attempting to reconstruct endpoint.", key, _page.endpoint)
res_pattern, resource = getattr(resources, key, None), None
if res_pattern:
try:
top_level = res_pattern.split('/')[3]
resource = getattr(self, top_level, None)
except IndexError:
pass
if resource is None:
log.error("Unable to infer endpoint for %r on %s.", key, _page.endpoint)
self._has_error = True
continue
related = self._filtered_list(resource, _page.json[key]).results[0]
else:
continue
rel_endpoint = self._cache.get_page(related)
if rel_endpoint is None: # This foreign key is unreadable
if post_fields[key].get('required'):
log.error("Foreign key %r export failed for object %s.", key, _page.endpoint)
self._has_error = True
return None
log.warning("Foreign key %r export failed for object %s, setting to null", key, _page.endpoint)
continue
# Workflow approval templates have a special creation endpoint,
# therefore we are skipping the export via natural key.
if rel_endpoint.__item_class__.__name__ == 'WorkflowApprovalTemplate':
continue
rel_natural_key = rel_endpoint.get_natural_key(self._cache)
if rel_natural_key is None:
log.error("Unable to construct a natural key for foreign key %r of object %s.", key, _page.endpoint)
self._has_error = True
return None # This foreign key has unresolvable dependencies
fields[key] = rel_natural_key
# iterate over related fields in the object
related = {}
for key, rel_endpoint in _page.related.items():
# skip if no endpoint for this related object
if not rel_endpoint:
continue
rel = rel_endpoint._create()
if rel.__item_class__.__name__ != 'WorkflowApprovalTemplate':
if key in post_fields:
continue
is_relation = rel.__class__.__name__ in EXPORTABLE_RELATIONS
# determine if the parent object and the related object that we are processing through are related
# if this tuple is in the DEPENDENT_EXPORT than we output the full object
# else we output the natural key
is_dependent = (_page.__item_class__.__name__, rel.__item_class__.__name__) in DEPENDENT_EXPORT
is_blocked = (_page.__item_class__.__name__, key) in DEPENDENT_NONEXPORT
if is_blocked or not (is_relation or is_dependent):
continue
# if the rel is of WorkflowApprovalTemplate type, get rel_post_fields from create_approval_template endpoint
rel_option_endpoint = rel_endpoint
export_key = key
if rel.__item_class__.__name__ == 'WorkflowApprovalTemplate':
export_key = 'create_approval_template'
rel_option_endpoint = _page.related.get('create_approval_template')
rel_post_fields = utils.get_post_fields(rel_option_endpoint, self._cache)
if rel_post_fields is None:
log.debug("%s is a read-only endpoint.", rel_endpoint)
continue
is_attach = 'id' in rel_post_fields # This is not a create-only endpoint.
if is_dependent:
by_natural_key = False
elif is_relation and is_attach and not is_blocked:
by_natural_key = True
else:
continue
rel_page = self._cache.get_page(rel_endpoint)
if rel_page is None:
continue
if 'results' in rel_page:
results = (x.get_natural_key(self._cache) if by_natural_key else self._export(x, rel_post_fields) for x in rel_page.results)
related[export_key] = [x for x in results if x is not None]
elif rel.__item_class__.__name__ == 'WorkflowApprovalTemplate':
related[export_key] = self._export(rel_page, rel_post_fields)
else:
related[export_key] = rel_page.json
if related:
fields['related'] = related
if _page.__item_class__.__name__ != 'WorkflowApprovalTemplate':
natural_key = _page.get_natural_key(self._cache)
if natural_key is None:
log.error("Unable to construct a natural key for object %s.", _page.endpoint)
self._has_error = True
return None
fields['natural_key'] = natural_key
return fields
def _export_list(self, endpoint):
post_fields = utils.get_post_fields(endpoint, self._cache)
if post_fields is None:
return None
if isinstance(endpoint, page.TentativePage):
endpoint = self._cache.get_page(endpoint)
if endpoint is None:
return None
assets = (self._export(asset, post_fields) for asset in endpoint.results)
return [asset for asset in assets if asset is not None]
def _check_for_int(self, value):
return isinstance(value, int) or (isinstance(value, str) and value.isdecimal())
def _filtered_list(self, endpoint, value):
if isinstance(value, list) and len(value) == 1:
value = value[0]
if self._check_for_int(value):
return endpoint.get(id=int(value))
options = self._cache.get_options(endpoint)
identifier = next(field for field in options['search_fields'] if field in ('name', 'username', 'hostname'))
if isinstance(value, list):
if all(self._check_for_int(item) for item in value):
identifier = 'or__id'
else:
identifier = 'or__' + identifier
return endpoint.get(**{identifier: value}, all_pages=True)
def export_assets(self, **kwargs):
self._cache = page.PageCache(self.connection)
# If no resource kwargs are explicitly used, export everything.
all_resources = all(kwargs.get(resource) is None for resource in EXPORTABLE_RESOURCES)
data = {}
for resource in EXPORTABLE_RESOURCES:
value = kwargs.get(resource)
if all_resources or value is not None:
endpoint = getattr(self, resource)
if value:
endpoint = self._filtered_list(endpoint, value)
data[resource] = self._export_list(endpoint)
return data
# Import methods
def _dependent_resources(self):
page_resource = {}
for resource in self.json:
# The /api/v2/constructed_inventories endpoint is for the UI but will register as an Inventory endpoint
# We want to map the type to /api/v2/inventories/ which works for constructed too
if resource == 'constructed_inventory':
continue
page_resource[getattr(self, resource)._create().__item_class__] = resource
data_pages = [getattr(self, resource)._create().__item_class__ for resource in EXPORTABLE_RESOURCES]
for page_cls in itertools.chain(*has_create.page_creation_order(*data_pages)):
yield page_resource[page_cls]
def _import_list(self, endpoint, assets):
log.debug("_import_list -- endpoint: %s, assets: %s", endpoint.endpoint, repr(assets))
post_fields = utils.get_post_fields(endpoint, self._cache)
changed = False
for asset in assets:
post_data = {}
for field, value in asset.items():
if field not in post_fields:
continue
if post_fields[field]['type'] in ('id', 'integer') and isinstance(value, dict):
_page = self._cache.get_by_natural_key(value)
post_data[field] = _page['id'] if _page is not None else None
else:
post_data[field] = utils.remove_encrypted(value)
_page = self._cache.get_by_natural_key(asset['natural_key'])
try:
if _page is None:
if asset['natural_key']['type'] == 'user':
# We should only impose a default password if the resource doesn't exist.
post_data.setdefault('password', 'abc123')
try:
_page = endpoint.post(post_data)
except exc.NoContent:
# desired exception under some circumstances, e.g. labels that already exist
if _page is None and 'name' in post_data:
results = endpoint.get(all_pages=True).results
for item in results:
if item['name'] == post_data['name']:
_page = item.get()
break
else:
raise
changed = True
if asset['natural_key']['type'] == 'project':
# When creating a project, we need to wait for its
# first project update to finish so that associated
# JTs have valid options for playbook names
try:
_page.wait_until_completed(timeout=300)
except AssertionError:
# If the project update times out, try to
# carry on in the hopes that it will
# finish before it is needed.
pass
else:
# If we are an existing project and our scm_tpye is not changing don't try and import the local_path setting
if asset['natural_key']['type'] == 'project' and 'local_path' in post_data and _page['scm_type'] == post_data['scm_type']:
del post_data['local_path']
if asset['natural_key']['type'] == 'user':
_page = _page.patch(**post_data)
else:
_page = _page.put(post_data)
changed = True
except (exc.Common, AssertionError) as e:
identifier = asset.get("name", None) or asset.get("username", None) or asset.get("hostname", None)
log.error(f'{endpoint} "{identifier}": {e}.')
self._has_error = True
log.debug("post_data: %r", post_data)
continue
self._cache.set_page(_page)
# Queue up everything related to be either created or assigned.
for name, S in asset.get('related', {}).items():
if not S:
continue
if name == 'roles':
indexed_roles = defaultdict(list)
for role in S:
if role.get('content_object') is None:
continue
indexed_roles[role['content_object']['type']].append(role)
self._roles.append((_page, indexed_roles))
else:
self._related.append((_page, name, S))
return changed
def _assign_role(self, endpoint, role):
if 'content_object' not in role:
return
obj_page = self._cache.get_by_natural_key(role['content_object'])
if obj_page is None:
return
role_page = obj_page.get_object_role(role['name'], by_name=True)
try:
endpoint.post({'id': role_page['id']})
except exc.NoContent: # desired exception on successful (dis)association
pass
except exc.Common as e:
log.error("Role assignment failed: %s.", e)
self._has_error = True
log.debug("post_data: %r", {'id': role_page['id']})
def _assign_membership(self):
for _page, indexed_roles in self._roles:
role_endpoint = _page.json['related']['roles']
for content_type in ('organization', 'team'):
for role in indexed_roles.get(content_type, []):
self._assign_role(role_endpoint, role)
def _assign_roles(self):
for _page, indexed_roles in self._roles:
role_endpoint = _page.json['related']['roles']
for content_type in set(indexed_roles) - {'organization', 'team'}:
for role in indexed_roles.get(content_type, []):
self._assign_role(role_endpoint, role)
def _assign_related(self):
for _page, name, related_set in self._related:
endpoint = _page.related[name]
if isinstance(related_set, dict): # Related that are just json blobs, e.g. survey_spec
endpoint.post(related_set)
continue
if 'natural_key' not in related_set[0]: # It is an attach set
# Try to impedance match
related = endpoint.get(all_pages=True)
existing = {rel['id'] for rel in related.results}
for item in related_set:
rel_page = self._cache.get_by_natural_key(item)
if rel_page is None:
log.error("Could not find matching object in Tower for imported relation, item: %r", item)
self._has_error = True
continue
if rel_page['id'] in existing:
continue
try:
post_data = {'id': rel_page['id']}
endpoint.post(post_data)
log.error("endpoint: %s, id: %s", endpoint.endpoint, rel_page['id'])
self._has_error = True
except exc.NoContent: # desired exception on successful (dis)association
pass
except exc.Common as e:
log.error("Object association failed: %s.", e)
self._has_error = True
log.debug("post_data: %r", post_data)
else: # It is a create set
self._cache.get_page(endpoint)
self._import_list(endpoint, related_set)
# FIXME: deal with pruning existing relations that do not match the import set
def import_assets(self, data):
self._cache = page.PageCache(self.connection)
self._related = []
self._roles = []
changed = False
for resource in self._dependent_resources():
endpoint = getattr(self, resource)
imported = self._import_list(endpoint, data.get(resource) or [])
changed = changed or imported
self._assign_related()
self._assign_membership()
self._assign_roles()
return changed
page.register_page(resources.v2, ApiV2)
07070100000017000081A400000000000000000000000166846B9200000CCB000000000000000000000000000000000000002C00000000awx-24.6.1/awxkit/api/pages/applications.pyfrom 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]
NATURAL_KEY = ('organization', 'name')
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)
07070100000018000081A400000000000000000000000166846B92000000AD000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/pages/authtoken.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class AuthToken(base.Base):
pass
page.register_page(resources.authtoken, AuthToken)
07070100000019000081A400000000000000000000000166846B92000023F8000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/api/pages/base.pyimport 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
except (exc.BadRequest, exc.Conflict) as e:
if 'Job has not finished processing events' in e.msg:
pass
if 'Resource is being used' in e.msg:
pass
else:
raise e
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, description='AWX CLI', 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(
f"{config.api_base_path}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(
f"{config.api_base_path}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(
'{0}v2/users/{1}/personal_tokens/'.format(config.api_base_path, username),
json={"description": description, "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 = '{}v2/{}s/{}/'.format(config.api_base_path, 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
0707010000001A000081A400000000000000000000000166846B92000002D3000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/api/pages/bulk.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class Bulk(base.Base):
def get(self, **query_parameters):
request = self.connection.get(self.endpoint, query_parameters, headers={'Accept': 'application/json'})
return self.page_identity(request)
page.register_page([resources.bulk, (resources.bulk, 'get')], Bulk)
class BulkJobLaunch(base.Base):
def post(self, payload={}):
result = self.connection.post(self.endpoint, payload)
if 'url' in result.json():
return self.walk(result.json()['url'])
else:
return self.page_identity(result, request_json={})
page.register_page(resources.bulk_job_launch, BulkJobLaunch)
0707010000001B000081A400000000000000000000000166846B92000004B7000000000000000000000000000000000000002600000000awx-24.6.1/awxkit/api/pages/config.pyfrom 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_valid_license(self):
return self.license_info.get('valid_key', False) 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_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]
class ConfigAttach(page.Page):
def attach(self, **kwargs):
return self.post(json=kwargs).json
page.register_page(resources.config, Config)
page.register_page(resources.config_attach, ConfigAttach)
0707010000001C000081A400000000000000000000000166846B9200000196000000000000000000000000000000000000003800000000awx-24.6.1/awxkit/api/pages/credential_input_sources.pyfrom 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)
0707010000001D000081A400000000000000000000000166846B9200002D05000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/pages/credentials.pyimport logging
import http.client as http
import awxkit.exceptions as exc
from awxkit.api.mixins import DSAdapter, HasCopy, HasCreate
from awxkit.api.pages import Organization, Team, User
from awxkit.api.resources import resources
from awxkit.config import config
from awxkit.utils import (
PseudoNamespace,
cloud_types,
filter_by_class,
not_provided,
random_title,
update_payload,
)
from . import base, page
from .page import exception_from_status_code
from urllib.parse import urljoin
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',
'gpg_public_key',
)
def generate_private_key():
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
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',
'container registry': 'registry',
'ansible galaxy/automation hub api token': 'galaxy',
'red hat ansible automation platform': 'controller',
'google compute engine': 'gce',
'insights': 'insights',
'openshift or kubernetes api bearer token': 'kubernetes',
'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',
'gpg public key': 'gpg_public_key',
'terraform backend configuration': 'terraform',
}
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:
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):
NATURAL_KEY = ('name', 'kind')
def silent_delete(self):
if not self.managed:
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))
def test(self, data):
"""Test the credential type endpoint."""
response = self.connection.post(urljoin(str(self.url), 'test/'), data)
exception = exception_from_status_code(response.status_code)
exc_str = "%s (%s) received" % (http.responses[response.status_code], response.status_code)
if exception:
raise exception(exc_str, response.json())
elif response.status_code == http.FORBIDDEN:
raise exc.Forbidden(exc_str, response.json())
return response
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]
NATURAL_KEY = ('organization', 'name', 'credential_type')
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=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=None, 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)
def test(self, data):
"""Test the credential endpoint."""
response = self.connection.post(urljoin(str(self.url), 'test/'), data)
exception = exception_from_status_code(response.status_code)
exc_str = "%s (%s) received" % (http.responses[response.status_code], response.status_code)
if exception:
raise exception(exc_str, response.json())
elif response.status_code == http.FORBIDDEN:
raise exc.Forbidden(exc_str, response.json())
return response
@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'), (resources.organization_galaxy_credentials, 'post')],
Credential,
)
class Credentials(page.PageList, Credential):
pass
page.register_page([resources.credentials, resources.related_credentials, resources.organization_galaxy_credentials], Credentials)
class CredentialCopy(base.Base):
pass
page.register_page(resources.credential_copy, CredentialCopy)
0707010000001E000081A400000000000000000000000166846B92000000AD000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/pages/dashboard.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class Dashboard(base.Base):
pass
page.register_page(resources.dashboard, Dashboard)
0707010000001F000081A400000000000000000000000166846B9200000944000000000000000000000000000000000000003600000000awx-24.6.1/awxkit/api/pages/execution_environments.pyimport logging
from awxkit.api.mixins import DSAdapter, HasCreate, HasCopy
from awxkit.api.pages import (
Credential,
Organization,
)
from awxkit.api.resources import resources
from awxkit.utils import random_title, PseudoNamespace, filter_by_class
from . import base
from . import page
log = logging.getLogger(__name__)
class ExecutionEnvironment(HasCreate, HasCopy, base.Base):
dependencies = [Organization, Credential]
NATURAL_KEY = ('name',)
# fields are name, image, organization, managed, credential
def create(self, name='', image='quay.io/ansible/awx-ee:latest', organization=Organization, credential=None, pull='', **kwargs):
# we do not want to make a credential by default
payload = self.create_payload(name=name, image=image, organization=organization, credential=credential, pull=pull, **kwargs)
ret = self.update_identity(ExecutionEnvironments(self.connection).post(payload))
return ret
def create_payload(self, name='', organization=Organization, credential=None, **kwargs):
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(name=name, organization=organization, credential=credential, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def payload(self, name='', image=None, organization=None, credential=None, pull='', **kwargs):
payload = PseudoNamespace(
name=name or "EE - {}".format(random_title()),
image=image or "example.invalid/component:tagname",
organization=organization.id if organization else None,
credential=credential.id if credential else None,
pull=pull,
**kwargs
)
return payload
page.register_page(
[resources.execution_environment, (resources.execution_environments, 'post'), (resources.organization_execution_environments, 'post')], ExecutionEnvironment
)
class ExecutionEnvironments(page.PageList, ExecutionEnvironment):
pass
page.register_page([resources.execution_environments, resources.organization_execution_environments], ExecutionEnvironments)
07070100000020000081A400000000000000000000000166846B92000001DF000000000000000000000000000000000000002C00000000awx-24.6.1/awxkit/api/pages/host_metrics.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class HostMetric(base.Base):
def get(self, **query_parameters):
request = self.connection.get(self.endpoint, query_parameters, headers={'Accept': 'application/json'})
return self.page_identity(request)
class HostMetrics(page.PageList, HostMetric):
pass
page.register_page([resources.host_metric], HostMetric)
page.register_page([resources.host_metrics], HostMetrics)
07070100000021000081A400000000000000000000000166846B920000066F000000000000000000000000000000000000002F00000000awx-24.6.1/awxkit/api/pages/instance_groups.pyfrom contextlib import suppress
from awxkit.utils import PseudoNamespace, random_title, update_payload, set_payload_foreign_key_args
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', 'is_container_group', 'max_forks', 'max_concurrent_jobs')
update_payload(payload, fields, kwargs)
set_payload_foreign_key_args(payload, ('credential',), 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)
07070100000022000081A400000000000000000000000166846B92000002AC000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/pages/instances.pyfrom 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, resources.instance_peers], Instances)
class InstanceInstallBundle(page.Page):
def extract_data(self, response):
# The actual content of this response will be in the full set
# of bytes from response.content, which will be exposed via
# the Page.bytes interface.
return {}
page.register_page(resources.instance_install_bundle, InstanceInstallBundle)
07070100000023000081A400000000000000000000000166846B9200003E12000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/pages/inventory.pyfrom contextlib import suppress
import logging
import json
from awxkit.api.pages import Credential, Organization, Project, UnifiedJob, UnifiedJobTemplate
from awxkit.utils import filter_by_class, random_title, update_payload, not_provided, PseudoNamespace, poll_until
from awxkit.api.mixins import DSAdapter, HasCreate, HasInstanceGroups, HasNotifications, HasVariables, HasCopy
from awxkit.config import config
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]
NATURAL_KEY = ('organization', 'name')
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', 'kind', 'variables', 'prevent_instance_group_fallback')
update_payload(payload, optional_fields, kwargs)
if 'variables' in payload and isinstance(payload.variables, dict):
payload.variables = json.dumps(payload.variables)
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 silent_delete(self):
try:
if not config.prevent_teardown:
r = self.delete()
self.wait_until_deleted()
return r
except (exc.NoContent, exc.NotFound, exc.Forbidden):
pass
except (exc.BadRequest, exc.Conflict) as e:
if 'Resource is being used' in e.msg:
pass
else:
raise e
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.constructed_inventory,
(resources.inventories, 'post'),
(resources.inventory_copy, 'post'),
(resources.constructed_inventories, 'post'),
],
Inventory,
)
class Inventories(page.PageList, Inventory):
pass
page.register_page([resources.inventories, resources.related_inventories, resources.constructed_inventories], Inventories)
class Group(HasCreate, HasVariables, base.Base):
dependencies = [Inventory]
optional_dependencies = [Credential]
NATURAL_KEY = ('name', 'inventory')
@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, **kwargs):
self.create_and_update_dependencies(inventory, credential)
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]
NATURAL_KEY = ('name', '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='localhost', ansible_connection='local', ansible_python_interpreter='{{ ansible_playbook_python }}')
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, Project]
NATURAL_KEY = ('organization', 'name', 'inventory')
def payload(self, inventory, source='scm', credential=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 project:
payload.source_project = project.id
optional_fields = (
'source_path',
'source_vars',
'scm_branch',
'timeout',
'overwrite',
'overwrite_vars',
'update_cache_timeout',
'update_on_launch',
'verbosity',
)
update_payload(payload, optional_fields, kwargs)
return payload
def create_payload(self, name='', description='', source='scm', inventory=Inventory, credential=None, project=None, **kwargs):
if source == 'scm':
kwargs.setdefault('overwrite_vars', True)
kwargs.setdefault('source_path', 'inventories/script_migrations/script_source.py')
if project is None:
project = Project
inventory, credential, project = filter_by_class((inventory, Inventory), (credential, Credential), (project, Project))
self.create_and_update_dependencies(inventory, credential, project)
if credential:
credential = self.ds.credential
if project:
project = self.ds.project
payload = self.payload(inventory=self.ds.inventory, source=source, credential=credential, project=project, name=name, description=description, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', source='scm', inventory=Inventory, credential=None, project=None, **kwargs):
payload = self.create_payload(name=name, description=description, source=source, inventory=inventory, credential=credential, 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)
class InventoryCopy(base.Base):
pass
page.register_page(resources.inventory_copy, InventoryCopy)
07070100000024000081A400000000000000000000000166846B9200001C3F000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/api/pages/job_templates.pyfrom contextlib import suppress
import json
from awxkit.utils import filter_by_class, not_provided, random_title, update_payload, set_payload_foreign_key_args, 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]
NATURAL_KEY = ('organization', 'name')
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',
'ask_execution_environment_on_launch',
'ask_labels_on_launch',
'ask_forks_on_launch',
'ask_job_slice_count_on_launch',
'ask_timeout_on_launch',
'ask_instance_groups_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',
'webhook_service',
'webhook_credential',
'scm_branch',
'prevent_instance_group_fallback',
)
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)
payload = set_payload_foreign_key_args(payload, ('inventory', 'credential', 'webhook_credential', 'execution_environment'), kwargs)
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:
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_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)
class JobTemplateCopy(base.Base):
pass
page.register_page([resources.job_template_copy], JobTemplateCopy)
07070100000025000081A400000000000000000000000166846B92000006D5000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/api/pages/jobs.pyfrom 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)
07070100000026000081A400000000000000000000000166846B92000006F6000000000000000000000000000000000000002600000000awx-24.6.1/awxkit/api/pages/labels.pyfrom 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]
NATURAL_KEY = ('organization', 'name')
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.inventory_labels, resources.job_labels, resources.job_template_labels, resources.workflow_job_template_labels], Labels
)
07070100000027000081A400000000000000000000000166846B92000000BD000000000000000000000000000000000000002F00000000awx-24.6.1/awxkit/api/pages/mesh_visualizer.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class MeshVisualizer(base.Base):
pass
page.register_page(resources.mesh_visualizer, MeshVisualizer)
07070100000028000081A400000000000000000000000166846B920000017D000000000000000000000000000000000000002700000000awx-24.6.1/awxkit/api/pages/metrics.pyfrom 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, headers={'Accept': 'application/json'})
return self.page_identity(request)
page.register_page([resources.metrics, (resources.metrics, 'get')], Metrics)
07070100000029000081A400000000000000000000000166846B9200001F3C000000000000000000000000000000000000003600000000awx-24.6.1/awxkit/api/pages/notification_templates.pyfrom contextlib import suppress
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, PseudoNamespace
from . import base
from . import page
job_results = ('any', 'error', 'success')
notification_types = ('awssns', 'email', 'irc', 'pagerduty', 'slack', 'twilio', 'webhook', 'mattermost', 'grafana', 'rocketchat')
class NotificationTemplate(HasCopy, HasCreate, base.Base):
dependencies = [Organization]
NATURAL_KEY = ('organization', 'name')
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', messages=not_provided, **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,
)
if messages != not_provided:
payload['messages'] = messages
notification_configuration = kwargs.get('notification_configuration', {})
payload.notification_configuration = notification_configuration
if payload.notification_configuration == {}:
services = config.credentials.notification_services
if notification_type == 'awssns':
fields = ('aws_region', 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token', 'sns_topic_arn')
cred = services.awssns
elif 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
elif notification_type == 'grafana':
fields = ('grafana_url', 'grafana_key')
cred = services.grafana
elif notification_type == 'rocketchat':
fields = ('rocketchat_url', 'rocketchat_no_verify_ssl')
cred = services.rocketchat
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, messages=not_provided, **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, messages=messages, **kwargs
)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload
def create(self, name='', description='', notification_type='slack', organization=Organization, messages=not_provided, **kwargs):
payload = self.create_payload(
name=name, description=description, notification_type=notification_type, organization=organization, messages=messages, **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_started,
resources.notification_template_error,
resources.notification_template_success,
resources.notification_template_approval,
],
NotificationTemplate,
)
class NotificationTemplates(page.PageList, NotificationTemplate):
pass
page.register_page(
[
resources.notification_templates,
resources.related_notification_templates,
resources.notification_templates_any,
resources.notification_templates_started,
resources.notification_templates_error,
resources.notification_templates_success,
resources.notification_templates_approvals,
],
NotificationTemplates,
)
class NotificationTemplateCopy(base.Base):
pass
page.register_page(resources.notification_template_copy, NotificationTemplateCopy)
class NotificationTemplateTest(base.Base):
pass
page.register_page(resources.notification_template_test, NotificationTemplateTest)
0707010000002A000081A400000000000000000000000166846B9200000812000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/api/pages/notifications.pyfrom 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)
0707010000002B000081A400000000000000000000000166846B92000009FB000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/api/pages/organizations.pyfrom contextlib import suppress
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, DSAdapter
from awxkit.utils import random_title, set_payload_foreign_key_args, 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):
NATURAL_KEY = ('name',)
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 add_galaxy_credential(self, credential):
if isinstance(credential, page.Page):
credential = credential.json
with suppress(exc.NoContent):
self.related.galaxy_credentials.post(
{
"id": credential.id,
}
)
def remove_galaxy_credential(self, credential):
if isinstance(credential, page.Page):
credential = credential.json
with suppress(exc.NoContent):
self.related.galaxy_credentials.post(
{
"id": credential.id,
"disassociate": True,
}
)
def payload(self, **kwargs):
payload = PseudoNamespace(
name=kwargs.get('name') or 'Organization - {}'.format(random_title()), description=kwargs.get('description') or random_title(10)
)
payload = set_payload_foreign_key_args(payload, ('default_environment',), kwargs)
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)
0707010000002C000081A400000000000000000000000166846B92000053CA000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/api/pages/page.pyfrom contextlib import suppress
import inspect
import logging
import json
import re
from requests import Response
import http.client as http
from awxkit.utils import PseudoNamespace, is_relative_endpoint, are_same_endpoint, super_dir_set, is_list_or_tuple, to_str
from awxkit.api import utils
from awxkit.api.client import Connection
from awxkit.api.registry import URLRegistry
from awxkit.api.resources import resources
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, connection=None):
resp = Response()
data = json.dumps(raw)
resp._content = bytes(data, 'utf-8')
resp.encoding = 'utf-8'
resp.status_code = 200
return cls(r=resp, connection=connection)
@property
def bytes(self):
if self.r is None:
return b''
return self.r.content
def extract_data(self, response):
"""Takes a `requests.Response` and returns a data dict."""
try:
data = response.json()
except ValueError as e: # If there was no json to parse
data = {}
if response.text or response.status_code not in (200, 202, 204):
text = response.text
if len(text) > 1024:
text = text[:1024] + '... <<< Truncated >>> ...'
log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text))
return data
def page_identity(self, response, request_json=None):
"""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
if request_path == '/migrations_notran/':
raise exc.IsMigrating('You have been redirected to the migration-in-progress page.')
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
data = self.extract_data(response)
exc_str = "%s (%s) received" % (http.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.OK, http.CREATED, http.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.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.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 getattr(page, 'next', None):
paged_results = [r.json()['results']]
while page.next:
r = self.connection.get(self.next)
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, connection=self.connection)
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)
def get_natural_key(self, cache=None):
if cache is None:
cache = PageCache()
if not getattr(self, 'NATURAL_KEY', None):
log.warning("This object does not have a natural key: %s", getattr(self, 'endpoint', ''))
return None
natural_key = {}
for key in self.NATURAL_KEY:
if key in self.related:
related_endpoint = cache.get_page(self.related[key])
if related_endpoint is not None:
natural_key[key] = related_endpoint.get_natural_key(cache=cache)
else:
natural_key[key] = None
elif key in self:
natural_key[key] = self[key]
natural_key['type'] = self['type']
return natural_key
_exception_map = {
http.NO_CONTENT: exc.NoContent,
http.NOT_FOUND: exc.NotFound,
http.INTERNAL_SERVER_ERROR: exc.InternalServerError,
http.BAD_GATEWAY: exc.BadGateway,
http.METHOD_NOT_ALLOWED: exc.MethodNotAllowed,
http.UNAUTHORIZED: exc.Unauthorized,
http.PAYMENT_REQUIRED: exc.PaymentRequired,
http.CONFLICT: exc.Conflict,
}
def exception_from_status_code(status_code):
return _exception_map.get(status_code, None)
class PageList(object):
NATURAL_KEY = None
@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)
def get_natural_key(self, cache=None):
log.warning("This object does not have a natural key: %s", getattr(self, 'endpoint', ''))
return None
class TentativePage(str):
def __new__(cls, endpoint, connection):
return super(TentativePage, cls).__new__(cls, to_str(endpoint))
def __init__(self, endpoint, connection):
self.endpoint = to_str(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
class PageCache(object):
def __init__(self, connection=None):
self.options = {}
self.pages_by_url = {}
self.pages_by_natural_key = {}
self.connection = connection or Connection(config.base_url, not config.assume_untrusted)
def get_options(self, page):
url = page.endpoint if isinstance(page, Page) else str(page)
if url in self.options:
return self.options[url]
try:
options = page.options()
except exc.Common:
log.error("This endpoint raised an error: %s", url)
return self.options.setdefault(url, None)
warning = options.r.headers.get('Warning', '')
if '299' in warning and 'deprecated' in warning:
log.warning("This endpoint is deprecated: %s", url)
return self.options.setdefault(url, None)
return self.options.setdefault(url, options)
def set_page(self, page):
log.debug("set_page: %s %s", type(page), page.endpoint)
self.pages_by_url[page.endpoint] = page
if getattr(page, 'NATURAL_KEY', None):
log.debug("set_page has natural key fields.")
natural_key = page.get_natural_key(cache=self)
if natural_key is not None:
log.debug("set_page natural_key: %s", repr(natural_key))
self.pages_by_natural_key[utils.freeze(natural_key)] = page.endpoint
if 'results' in page:
for p in page.results:
self.set_page(p)
return page
def get_page(self, page):
url = page.endpoint if isinstance(page, Page) else str(page)
if url in self.pages_by_url:
return self.pages_by_url[url]
try:
page = page.get(all_pages=True)
except exc.Common:
log.error("This endpoint raised an error: %s", url)
return self.pages_by_url.setdefault(url, None)
warning = page.r.headers.get('Warning', '')
if '299' in warning and 'deprecated' in warning:
log.warning("This endpoint is deprecated: %s", url)
return self.pages_by_url.setdefault(url, None)
log.debug("get_page: %s", page.endpoint)
return self.set_page(page)
def get_by_natural_key(self, natural_key):
page = self.pages_by_natural_key.get(utils.freeze(natural_key))
if page is None:
# We need some way to get ahold of the top-level resource
# list endpoint from the natural_key type. The resources
# object more or less has that for each of the detail
# views. Just chop off the /<id>/ bit.
endpoint = getattr(resources, natural_key['type'], None)
if endpoint is None:
return
endpoint = ''.join([endpoint.rsplit('/', 2)[0], '/'])
page_type = get_registered_page(endpoint)
kwargs = {}
for k, v in natural_key.items():
if isinstance(v, str) and k != 'type':
kwargs[k] = v
# Do a filtered query against the list endpoint, usually
# with the name of the object but sometimes more.
list_page = page_type(self.connection, endpoint=endpoint).get(all_pages=True, **kwargs)
if 'results' in list_page:
for p in list_page.results:
self.set_page(p)
page = self.pages_by_natural_key.get(utils.freeze(natural_key))
log.debug("get_by_natural_key: %s, endpoint: %s", repr(natural_key), page)
if page:
return self.get_page(page)
0707010000002D000081A400000000000000000000000166846B920000009E000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/api/pages/ping.pyfrom awxkit.api.resources import resources
from . import base
from . import page
class Ping(base.Base):
pass
page.register_page(resources.ping, Ping)
0707010000002E000081A400000000000000000000000166846B920000174D000000000000000000000000000000000000002800000000awx-24.6.1/awxkit/api/pages/projects.pyimport json
from awxkit.api.pages import Credential, Organization, UnifiedJob, UnifiedJobTemplate
from awxkit.utils import filter_by_class, random_title, update_payload, set_payload_foreign_key_args, 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()
NATURAL_KEY = ('organization', 'name')
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_track_submodules',
'scm_update_cache_timeout',
'scm_update_on_launch',
'scm_refspec',
'allow_override',
'signature_validation_credential',
)
update_payload(payload, fields, kwargs)
payload = set_payload_foreign_key_args(payload, ('execution_environment', 'default_environment'), 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):
update = self.related.current_update.get()
update.wait_until_completed().assert_successful()
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 ProjectCopy(base.Base):
pass
page.register_page(resources.project_copy, ProjectCopy)
class Playbooks(base.Base):
pass
page.register_page(resources.project_playbooks, Playbooks)
0707010000002F000081A400000000000000000000000166846B9200000418000000000000000000000000000000000000002500000000awx-24.6.1/awxkit/api/pages/roles.pyimport logging
from awxkit.api.resources import resources
from . import base
from . import page
log = logging.getLogger(__name__)
class Role(base.Base):
NATURAL_KEY = ('name',)
def get_natural_key(self, cache=None):
if cache is None:
cache = page.PageCache()
natural_key = super(Role, self).get_natural_key(cache=cache)
related_objs = [related for name, related in self.related.items() if name not in ('users', 'teams')]
if related_objs:
related_endpoint = cache.get_page(related_objs[0])
if related_endpoint is None:
log.error("Unable to obtain content_object %s for role %s", related_objs[0], self.endpoint)
return None
natural_key['content_object'] = related_endpoint.get_natural_key(cache=cache)
return natural_key
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)
07070100000030000081A400000000000000000000000166846B9200000925000000000000000000000000000000000000002900000000awx-24.6.1/awxkit/api/pages/schedules.pyfrom contextlib import suppress
from awxkit.api.pages import JobTemplate, SystemJobTemplate, Project, InventorySource
from awxkit.api.pages.workflow_job_templates import WorkflowJobTemplate
from awxkit.api.mixins import HasCreate
from awxkit.api.resources import resources
from awxkit.config import config
import awxkit.exceptions as exc
from . import page
from . import base
class Schedule(HasCreate, base.Base):
dependencies = [JobTemplate, SystemJobTemplate, Project, InventorySource, WorkflowJobTemplate]
NATURAL_KEY = ('unified_job_template', 'name')
def silent_delete(self):
"""
In every case, we start by disabling the schedule
to avoid cascading errors from a cleanup failure.
Then, if we are told to prevent_teardown of schedules, we keep them
"""
try:
self.patch(enabled=False)
if not config.prevent_teardown:
return self.delete()
except (exc.NoContent, exc.NotFound, exc.Forbidden):
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))
def add_label(self, label):
with suppress(exc.NoContent):
self.related.labels.post(dict(id=label.id))
def add_instance_group(self, instance_group):
with suppress(exc.NoContent):
self.related.instance_groups.post(dict(id=instance_group.id))
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)
07070100000031000081A400000000000000000000000166846B9200000490000000000000000000000000000000000000002800000000awx-24.6.1/awxkit/api/pages/settings.pyfrom 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)
07070100000032000081A400000000000000000000000166846B92000000F7000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/api/pages/subscriptions.pyfrom awxkit.api.resources import resources
from . import page
class Subscriptions(page.Page):
def get_possible_licenses(self, **kwargs):
return self.post(json=kwargs).json
page.register_page(resources.subscriptions, Subscriptions)
07070100000033000081A400000000000000000000000166846B920000033B000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/pages/survey_spec.pyfrom . 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)
07070100000034000081A400000000000000000000000166846B920000039B000000000000000000000000000000000000003400000000awx-24.6.1/awxkit/api/pages/system_job_templates.pyfrom awxkit.api.mixins import HasNotifications
from awxkit.api.pages import UnifiedJobTemplate
from awxkit.api.resources import resources
from . import page
class SystemJobTemplate(UnifiedJobTemplate, HasNotifications):
NATURAL_KEY = ('name', 'organization')
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)
07070100000035000081A400000000000000000000000166846B92000001A4000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/api/pages/system_jobs.pyfrom 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)
07070100000036000081A400000000000000000000000166846B92000006DB000000000000000000000000000000000000002500000000awx-24.6.1/awxkit/api/pages/teams.pyfrom contextlib import suppress
from awxkit.api.mixins import HasCreate, DSAdapter
from awxkit.utils import 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]
NATURAL_KEY = ('organization', 'name')
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.credential_owner_teams, resources.related_teams], Teams)
07070100000037000081A400000000000000000000000166846B9200000B8C000000000000000000000000000000000000003500000000awx-24.6.1/awxkit/api/pages/unified_job_templates.pyfrom 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',
'forks',
'timeout',
'job_slice_count',
'execution_environment',
)
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)
schedule = self.related.schedules.post(payload)
# register schedule in temporary dependency store as means of
# getting its teardown method to run on cleanup
if not hasattr(self, '_schedules_store'):
self._schedules_store = set()
if schedule not in self._schedules_store:
self._schedules_store.add(schedule)
return schedule
def silent_delete(self):
if hasattr(self, '_schedules_store'):
for schedule in self._schedules_store:
schedule.silent_delete()
return super(UnifiedJobTemplate, self).silent_delete()
@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)
07070100000038000081A400000000000000000000000166846B9200001A6F000000000000000000000000000000000000002C00000000awx-24.6.1/awxkit/api/pages/unified_jobs.pyfrom 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()
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.
"""
self.wait_until_completed()
stdout = self.result_stdout
if replace_newlines is not None:
# make text into string with no line breaks, but watch out for trailing whitespace
stdout = replace_newlines.join([line.strip() for line in stdout.split('\n')])
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.safe_load(arg)
except (yaml.parser.ParserError, yaml.scanner.ScannerError):
return str(arg)
args = []
if not self.json.job_args:
return ""
for arg in yaml.safe_load(self.json.job_args):
try:
args.append(yaml.safe_load(arg))
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
@property
def controller_dir(self):
"""Returns the path to the private_data_dir on the controller node for the job
This can be used if trying to shell in and inspect the files used by the job
Cannot use job_cwd, because that is path inside EE container
"""
self.get()
job_args = self.job_args
expected_prefix = '/tmp/awx_{}'.format(self.id)
for arg1, arg2 in zip(job_args[:-1], job_args[1:]):
if arg1 == '-v':
if ':' in arg2:
host_loc = arg2.split(':')[0]
if host_loc.startswith(expected_prefix):
return host_loc
raise RuntimeError(
'Could not find a controller private_data_dir for this job. Searched for volume mount to {} inside of args {}'.format(expected_prefix, job_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)
07070100000039000081A400000000000000000000000166846B920000076B000000000000000000000000000000000000002500000000awx-24.6.1/awxkit/api/pages/users.pyfrom 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):
NATURAL_KEY = ('username',)
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.credential_owner_users, resources.user_admin_organizations], Users
)
class Me(Users):
pass
page.register_page(resources.me, Me)
0707010000003A000081A400000000000000000000000166846B9200000227000000000000000000000000000000000000003B00000000awx-24.6.1/awxkit/api/pages/workflow_approval_templates.pyfrom awxkit.api.pages.unified_job_templates import UnifiedJobTemplate
from awxkit.api.resources import resources
from . import page
class WorkflowApprovalTemplate(UnifiedJobTemplate):
pass
page.register_page(
[
resources.workflow_approval_template,
resources.workflow_job_template_node_create_approval_template,
],
WorkflowApprovalTemplate,
)
class WorkflowApprovalTemplates(page.PageList, WorkflowApprovalTemplate):
pass
page.register_page(resources.workflow_approval_templates, WorkflowApprovalTemplates)
0707010000003B000081A400000000000000000000000166846B9200000278000000000000000000000000000000000000003200000000awx-24.6.1/awxkit/api/pages/workflow_approvals.pyfrom awxkit.api.pages import UnifiedJob
from awxkit.api.resources import resources
from . import page
from awxkit import exceptions
class WorkflowApproval(UnifiedJob):
def approve(self):
try:
self.related.approve.post()
except exceptions.NoContent:
pass
def deny(self):
try:
self.related.deny.post()
except exceptions.NoContent:
pass
page.register_page(resources.workflow_approval, WorkflowApproval)
class WorkflowApprovals(page.PageList, WorkflowApproval):
pass
page.register_page(resources.workflow_approvals, WorkflowApprovals)
0707010000003C000081A400000000000000000000000166846B9200000420000000000000000000000000000000000000003200000000awx-24.6.1/awxkit/api/pages/workflow_job_nodes.pyfrom awxkit.api.pages import base
from awxkit.api.resources import resources
from awxkit.utils import poll_until, seconds_since_date_string
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)
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,
)
0707010000003D000081A400000000000000000000000166846B9200001489000000000000000000000000000000000000003B00000000awx-24.6.1/awxkit/api/pages/workflow_job_template_nodes.pyfrom contextlib import suppress
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, random_title
from . import page
class WorkflowJobTemplateNode(HasCreate, base.Base):
dependencies = [WorkflowJobTemplate, UnifiedJobTemplate]
NATURAL_KEY = ('workflow_job_template', 'identifier')
def payload(self, workflow_job_template, unified_job_template, **kwargs):
if not unified_job_template:
# May pass "None" to explicitly create an approval node
payload = PseudoNamespace(workflow_job_template=workflow_job_template.id)
else:
payload = PseudoNamespace(workflow_job_template=workflow_job_template.id, unified_job_template=unified_job_template.id)
optional_fields = (
'diff_mode',
'extra_data',
'limit',
'scm_branch',
'job_tags',
'job_type',
'skip_tags',
'verbosity',
'extra_data',
'identifier',
'all_parents_must_converge',
# prompt fields for JTs
'job_slice_count',
'forks',
'timeout',
'execution_environment',
)
update_payload(payload, optional_fields, kwargs)
if 'inventory' in kwargs:
payload['inventory'] = kwargs['inventory'].id
return payload
def create_payload(self, workflow_job_template=WorkflowJobTemplate, unified_job_template=JobTemplate, **kwargs):
if not unified_job_template:
self.create_and_update_dependencies(workflow_job_template)
payload = self.payload(workflow_job_template=self.ds.workflow_job_template, unified_job_template=None, **kwargs)
else:
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, **kwargs):
node = endpoint.post(dict(unified_job_template=unified_job_template.id, **kwargs))
node.create_and_update_dependencies(self.ds.workflow_job_template, unified_job_template)
return node
def add_always_node(self, unified_job_template, **kwargs):
return self._add_node(self.related.always_nodes, unified_job_template, **kwargs)
def add_failure_node(self, unified_job_template, **kwargs):
return self._add_node(self.related.failure_nodes, unified_job_template, **kwargs)
def add_success_node(self, unified_job_template, **kwargs):
return self._add_node(self.related.success_nodes, unified_job_template, **kwargs)
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))
def make_approval_node(self, **kwargs):
if 'name' not in kwargs:
kwargs['name'] = 'approval node {}'.format(random_title())
self.related.create_approval_template.post(kwargs)
return self.get()
def get_job_node(self, workflow_job):
candidates = workflow_job.get_related('workflow_nodes', identifier=self.identifier)
return candidates.results.pop()
def add_label(self, label):
with suppress(exc.NoContent):
self.related.labels.post(dict(id=label.id))
def add_instance_group(self, instance_group):
with suppress(exc.NoContent):
self.related.instance_groups.post(dict(id=instance_group.id))
page.register_page(
[resources.workflow_job_template_node, (resources.workflow_job_template_nodes, 'post'), (resources.workflow_job_template_workflow_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,
)
0707010000003E000081A400000000000000000000000166846B920000117B000000000000000000000000000000000000003600000000awx-24.6.1/awxkit/api/pages/workflow_job_templates.pyfrom contextlib import suppress
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, 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]
NATURAL_KEY = ('organization', 'name')
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/".format(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",
"ask_inventory_on_launch",
"ask_scm_branch_on_launch",
"ask_limit_on_launch",
"ask_labels_on_launch",
"ask_skip_tags_on_launch",
"ask_tags_on_launch",
"limit",
"scm_branch",
"survey_enabled",
"webhook_service",
"webhook_credential",
)
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('webhook_credential'):
webhook_cred = kwargs.get('webhook_credential')
if isinstance(webhook_cred, int):
payload.update(webhook_credential=int(webhook_cred))
elif hasattr(webhook_cred, 'id'):
payload.update(webhook_credential=webhook_cred.id)
else:
raise AttributeError("Webhook credential must either be integer of pkid or Credential object")
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, resources.related_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)
0707010000003F000081A400000000000000000000000166846B92000007AF000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/api/pages/workflow_jobs.pyfrom 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)
def failure_output_details(self):
"""Special implementation of this part of assert_status so that
workflow_job.assert_successful() will give a breakdown of failure
"""
node_list = self.related.workflow_nodes.get().results
msg = '\nNode summary:'
for node in node_list:
msg += '\n{}: {}'.format(node.id, node.summary_fields.get('job'))
for rel in ('failure_nodes', 'always_nodes', 'success_nodes'):
val = getattr(node, rel, [])
if val:
msg += ' {} {}'.format(rel, val)
msg += '\n\nUnhandled individual job failures:\n'
for node in node_list:
# nodes without always or failure paths consider failures unhandled
if node.job and not (node.failure_nodes or node.always_nodes):
job = node.related.job.get()
try:
job.assert_successful()
except Exception as e:
msg += str(e)
return msg
@property
def result_stdout(self):
# workflow jobs do not have result_stdout
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)
07070100000040000081A400000000000000000000000166846B9200001A1F000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/api/registry.pyfrom 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)
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
07070100000041000081A400000000000000000000000166846B9200003D4C000000000000000000000000000000000000002300000000awx-24.6.1/awxkit/api/resources.pyfrom awxkit.config import config
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/'
_bulk = 'bulk/'
_bulk_job_launch = 'bulk/job_launch/'
_config = 'config/'
_config_attach = 'config/attach/'
_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/'
_execution_environment = r'execution_environments/\d+/'
_execution_environments = 'execution_environments/'
_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_metrics = 'host_metrics/'
_host_metric = r'host_metrics/\d+/'
_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_install_bundle = r'instances/\d+/install_bundle/'
_instance_peers = r'instances/\d+/peers/'
_instance_related_jobs = r'instances/\d+/jobs/'
_instances = 'instances/'
_inventories = 'inventories/'
_constructed_inventories = 'constructed_inventories/'
_inventory = r'inventories/\d+/'
_constructed_inventory = r'constructed_inventories/\d+/'
_inventory_access_list = r'inventories/\d+/access_list/'
_inventory_copy = r'inventories/\d+/copy/'
_inventory_labels = r'inventories/\d+/labels/'
_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_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/'
_workflow_approval = r'workflow_approvals/\d+/'
_workflow_approvals = 'workflow_approvals/'
_workflow_approval_template = r'workflow_approval_templates/\d+/'
_workflow_approval_templates = 'workflow_approval_templates/'
_workflow_job_template_node_create_approval_template = r'workflow_job_template_nodes/\d+/create_approval_template/'
_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_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_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/'
_mesh_visualizer = 'mesh_visualizer/'
_notification = r'notifications/\d+/'
_notification_template = r'notification_templates/\d+/'
_notification_template_any = r'\w+/\d+/notification_templates_any/\d+/'
_notification_template_started = r'\w+/\d+/notification_templates_started/\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_approval = r'\w+/\d+/notification_templates_approvals/\d+/'
_notification_template_test = r'notification_templates/\d+/test/'
_notification_templates = 'notification_templates/'
_notification_templates_any = r'\w+/\d+/notification_templates_any/'
_notification_templates_started = r'\w+/\d+/notification_templates_started/'
_notification_templates_error = r'\w+/\d+/notification_templates_error/'
_notification_templates_success = r'\w+/\d+/notification_templates_success/'
_notification_templates_approvals = r'\w+/\d+/notification_templates_approvals/'
_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_execution_environments = r'organizations/\d+/execution_environments/'
_organization_galaxy_credentials = r'organizations/\d+/galaxy_credentials/'
_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/'
_subscriptions = 'config/subscriptions/'
_workflow_jobs = 'workflow_jobs/'
api = str(config.api_base_path)
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()
07070100000042000081A400000000000000000000000166846B92000005D3000000000000000000000000000000000000001F00000000awx-24.6.1/awxkit/api/utils.pyimport logging
import re
log = logging.getLogger(__name__)
descRE = re.compile(r'^[*] `(\w+)`: [^(]*\((\w+), ([^)]+)\)')
def freeze(key):
if key is None:
return None
return frozenset((k, freeze(v) if isinstance(v, dict) else v) for k, v in key.items())
def parse_description(desc):
options = {}
desc_lines = []
if 'POST' in desc:
desc_lines = desc[desc.index('POST') :].splitlines()
else:
desc_lines = desc.splitlines()
for line in desc_lines:
match = descRE.match(line)
if not match:
continue
options[match.group(1)] = {'type': match.group(2), 'required': match.group(3) == 'required'}
return options
def remove_encrypted(value):
if value == '$encrypted$':
return ''
if isinstance(value, list):
return [remove_encrypted(item) for item in value]
if isinstance(value, dict):
return {k: remove_encrypted(v) for k, v in value.items()}
return value
def get_post_fields(page, cache):
options_page = cache.get_options(page)
if options_page is None:
return None
if 'POST' not in options_page.r.headers.get('Allow', ''):
return None
if 'POST' in options_page.json['actions']:
return options_page.json['actions']['POST']
else:
log.warning("Insufficient privileges on %s, inferring POST fields from description.", options_page.endpoint)
return parse_description(options_page.json['description'])
07070100000043000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001600000000awx-24.6.1/awxkit/awx07070100000044000081A400000000000000000000000166846B9200000067000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/awx/__init__.pyfrom distutils.version import LooseVersion
def version_cmp(x, y):
return LooseVersion(x)._cmp(y)
07070100000045000081A400000000000000000000000166846B9200001376000000000000000000000000000000000000002300000000awx-24.6.1/awxkit/awx/inventory.pyimport optparse
import json
from awxkit.utils import random_title
def upload_inventory(ansible_runner, nhosts=10, ini=False):
"""Helper to upload inventory script to target host"""
# Create an inventory script
if ini:
copy_mode = '0644'
copy_dest = '/tmp/inventory{}.ini'.format(random_title(non_ascii=False))
copy_content = ini_inventory(nhosts)
else:
copy_mode = '0755'
copy_dest = '/tmp/inventory{}.sh'.format(random_title(non_ascii=False))
copy_content = '''#!/bin/bash
cat <<EOF
%s
EOF''' % json_inventory(
nhosts
)
# Copy script to test system
contacted = ansible_runner.copy(dest=copy_dest, force=True, mode=copy_mode, content=copy_content)
for result in contacted.values():
assert not result.get('failed', False), "Failed to create inventory file: %s" % result
return copy_dest
def generate_inventory(nhosts=100):
"""Generate a somewhat complex inventory with a configurable number of hosts"""
inv_list = {
'_meta': {
'hostvars': {},
},
}
for n in range(nhosts):
hostname = 'host-%08d.example.com' % n
group_evens_odds = 'evens.example.com' if n % 2 == 0 else 'odds.example.com'
group_threes = 'threes.example.com' if n % 3 == 0 else ''
group_fours = 'fours.example.com' if n % 4 == 0 else ''
group_fives = 'fives.example.com' if n % 5 == 0 else ''
group_sixes = 'sixes.example.com' if n % 6 == 0 else ''
group_sevens = 'sevens.example.com' if n % 7 == 0 else ''
group_eights = 'eights.example.com' if n % 8 == 0 else ''
group_nines = 'nines.example.com' if n % 9 == 0 else ''
group_tens = 'tens.example.com' if n % 10 == 0 else ''
group_by_10s = 'group-%07dX.example.com' % (n / 10)
group_by_100s = 'group-%06dXX.example.com' % (n / 100)
group_by_1000s = 'group-%05dXXX.example.com' % (n / 1000)
for group in [group_evens_odds, group_threes, group_fours, group_fives, group_sixes, group_sevens, group_eights, group_nines, group_tens, group_by_10s]:
if not group:
continue
if group in inv_list:
inv_list[group]['hosts'].append(hostname)
else:
inv_list[group] = {'hosts': [hostname], 'children': [], 'vars': {'group_prefix': group.split('.')[0]}}
if group_by_1000s not in inv_list:
inv_list[group_by_1000s] = {'hosts': [], 'children': [], 'vars': {'group_prefix': group_by_1000s.split('.')[0]}}
if group_by_100s not in inv_list:
inv_list[group_by_100s] = {'hosts': [], 'children': [], 'vars': {'group_prefix': group_by_100s.split('.')[0]}}
if group_by_100s not in inv_list[group_by_1000s]['children']:
inv_list[group_by_1000s]['children'].append(group_by_100s)
if group_by_10s not in inv_list[group_by_100s]['children']:
inv_list[group_by_100s]['children'].append(group_by_10s)
inv_list['_meta']['hostvars'][hostname] = {
'ansible_user': 'example',
'ansible_connection': 'local',
'host_prefix': hostname.split('.')[0],
'host_id': n,
}
return inv_list
def json_inventory(nhosts=10):
"""Return a JSON representation of inventory"""
return json.dumps(generate_inventory(nhosts), indent=4)
def ini_inventory(nhosts=10):
"""Return a .INI representation of inventory"""
output = list()
inv_list = generate_inventory(nhosts)
for group in inv_list.keys():
if group == '_meta':
continue
# output host groups
output.append('[%s]' % group)
for host in inv_list[group].get('hosts', []):
output.append(host)
output.append('') # newline
# output child groups
output.append('[%s:children]' % group)
for child in inv_list[group].get('children', []):
output.append(child)
output.append('') # newline
# output group vars
output.append('[%s:vars]' % group)
for k, v in inv_list[group].get('vars', {}).items():
output.append('%s=%s' % (k, v))
output.append('') # newline
return '\n'.join(output)
if __name__ == '__main__':
parser = optparse.OptionParser()
parser.add_option('--json', action='store_true', dest='json')
parser.add_option('--ini', action='store_true', dest='ini')
parser.add_option('--host', dest='hostname', default='')
parser.add_option('--nhosts', dest='nhosts', action='store', type='int', default=10)
options, args = parser.parse_args()
if options.json:
print(json_inventory(nhosts=options.nhosts))
elif options.ini:
print(ini_inventory(nhosts=options.nhosts))
elif options.hostname:
print(json_inventory(nhosts=options.nhosts)['_meta']['hostvars'][options.hostname])
else:
print(json.dumps({}, indent=4))
07070100000046000081A400000000000000000000000166846B9200000F72000000000000000000000000000000000000001F00000000awx-24.6.1/awxkit/awx/utils.pyfrom contextlib import contextmanager, suppress
from awxkit import api, exceptions
from awxkit.config import config
__all__ = ('as_user', 'check_related', 'delete_all', 'uses_sessions')
def get_all(endpoint):
results = []
while True:
get_args = dict(page_size=200) if 'page_size' not in endpoint else dict()
resource = endpoint.get(**get_args)
results.extend(resource.results)
if not resource.next:
return results
endpoint = resource.next
def _delete_all(endpoint):
while True:
resource = endpoint.get()
for item in resource.results:
try:
item.delete()
except Exception as e:
print(e)
if not resource.next:
return
def delete_all(v):
for endpoint in (
v.unified_jobs,
v.job_templates,
v.workflow_job_templates,
v.notification_templates,
v.projects,
v.inventory,
v.hosts,
v.labels,
v.credentials,
v.teams,
v.users,
v.organizations,
v.schedules,
):
_delete_all(endpoint)
def check_related(resource):
examined = []
for related in resource.related.values():
if related in examined:
continue
print(related)
with suppress(exceptions.NotFound):
child_related = related.get()
examined.append(related)
if 'results' in child_related and child_related.results:
child_related = child_related.results.pop()
if 'related' in child_related:
for _related in child_related.related.values():
if not isinstance(_related, api.page.TentativePage) or _related in examined:
continue
print(_related)
with suppress(exceptions.NotFound):
_related.get()
examined.append(_related)
@contextmanager
def as_user(v, username, password=None):
"""Context manager to allow running tests as an alternative login user."""
access_token = False
if not isinstance(v, api.client.Connection):
connection = v.connection
else:
connection = v
if isinstance(username, api.User):
password = username.password
username = username.username
if isinstance(username, api.OAuth2AccessToken):
access_token = username.token
username = None
password = None
try:
if config.use_sessions:
session_id = None
domain = None
# requests doesn't provide interface for retrieving
# domain segregated cookies other than iterating.
for cookie in connection.session.cookies:
if cookie.name == connection.session_cookie_name:
session_id = cookie.value
domain = cookie.domain
break
if session_id:
del connection.session.cookies[connection.session_cookie_name]
if access_token:
kwargs = dict(token=access_token)
else:
kwargs = connection.get_session_requirements()
else:
previous_auth = connection.session.auth
kwargs = dict()
connection.login(username, password, **kwargs)
yield
finally:
if config.use_sessions:
if access_token:
connection.session.auth = None
del connection.session.cookies[connection.session_cookie_name]
if session_id:
connection.session.cookies.set(connection.session_cookie_name, session_id, domain=domain)
else:
connection.session.auth = previous_auth
def uses_sessions(connection):
session_login = connection.get(f"{config.api_base_path}login/")
return session_login.status_code == 200
07070100000047000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001600000000awx-24.6.1/awxkit/cli07070100000048000081A400000000000000000000000166846B9200000947000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/cli/__init__.pyimport json
import sys
import traceback
import yaml
import urllib3
from requests.exceptions import ConnectionError, SSLError
from .client import CLI
from awxkit.utils import to_str
from awxkit.exceptions import Unauthorized, Common
from awxkit.cli.utils import cprint
# you'll only see these warnings if you've explicitly *disabled* SSL
# verification, so they're a little annoying, redundant
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def run(stdout=sys.stdout, stderr=sys.stderr, argv=[]):
cli = CLI(stdout=stdout, stderr=stderr)
try:
cli.parse_args(argv or sys.argv)
cli.connect()
cli.parse_resource()
except KeyboardInterrupt:
sys.exit(1)
except ConnectionError as e:
cli.parser.print_help()
msg = (
'\nThere was a network error of some kind trying to reach '
'{}.\nYou might need to specify (or double-check) '
'--conf.host'.format(cli.get_config('host'))
)
if isinstance(e, SSLError):
msg = (
'\nCould not establish a secure connection. '
'\nPlease add your server to your certificate authority.'
'\nYou can also run this command by specifying '
'-k or --conf.insecure'
)
cprint(msg + '\n', 'red', file=stderr)
cprint(e, 'red', file=stderr)
sys.exit(1)
except Unauthorized as e:
cli.parser.print_help()
msg = '\nValid credentials were not provided.\n$ awx login --help'
cprint(msg + '\n', 'red', file=stderr)
if cli.verbose:
cprint(e.__class__, 'red', file=stderr)
sys.exit(1)
except Common as e:
if cli.verbose:
print(traceback.format_exc(), sys.stderr)
if cli.get_config('format') == 'json':
json.dump(e.msg, sys.stdout)
print('')
elif cli.get_config('format') == 'yaml':
sys.stdout.write(to_str(yaml.safe_dump(e.msg, default_flow_style=False, encoding='utf-8', allow_unicode=True)))
elif cli.get_config('format') == 'human':
sys.stdout.write(e.__class__.__name__)
print('')
sys.exit(1)
except Exception as e:
if cli.verbose:
e = traceback.format_exc()
cprint(e, 'red', file=stderr)
sys.exit(1)
07070100000049000081ED00000000000000000000000166846B92000031DF000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/cli/client.pyfrom __future__ import print_function
import logging
import os
import pkg_resources
import sys
from requests.exceptions import RequestException
from .custom import handle_custom_actions
from .format import add_authentication_arguments, add_output_formatting_arguments, FORMATTERS, format_response
from .options import ResourceOptionsParser, UNIQUENESS_RULES
from .resource import parse_resource, is_control_resource
from awxkit import api, config, utils, exceptions, WSClient # noqa
from awxkit.cli.utils import HelpfulArgumentParser, cprint, disable_color, colored
from awxkit.awx.utils import uses_sessions # noqa
__version__ = pkg_resources.get_distribution('awxkit').version
class CLI(object):
"""A programmatic HTTP OPTIONS-based CLI for AWX/Ansible Tower.
This CLI works by:
- Configuring CLI options via Python's argparse (authentication, formatting
options, etc...)
- Discovering AWX API endpoints at /api/v2/ and mapping them to _resources_
- Discovering HTTP OPTIONS _actions_ on resources to determine how
resources can be interacted with (e.g., list, modify, delete, etc...)
- Parsing sys.argv to map CLI arguments and flags to
awxkit SDK calls
~ awx <resource> <action> --parameters
e.g.,
~ awx users list -v
GET /api/ HTTP/1.1" 200
GET /api/v2/ HTTP/1.1" 200
POST /api/login/ HTTP/1.1" 302
OPTIONS /api/v2/users/ HTTP/1.1" 200
GET /api/v2/users/
{
"count": 2,
"results": [
...
Interacting with this class generally involves a few critical methods:
1. parse_args() - this method is used to configure and parse global CLI
flags, such as formatting flags, and arguments which represent client
configuration (including authentication details)
2. connect() - once configuration is parsed, this method fetches /api/v2/
and itemizes the list of supported resources
3. parse_resource() - attempts to parse the <resource> specified on the
command line (e.g., users, organizations), including logic
for discovering available actions for endpoints using HTTP OPTIONS
requests
At multiple stages of this process, an internal argparse.ArgumentParser()
is progressively built and parsed based on sys.argv, (meaning, that if you
supply invalid or incomplete arguments, argparse will print the usage
message and an explanation of what you got wrong).
"""
subparsers = {}
original_action = None
def __init__(self, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin):
self.stdout = stdout
self.stderr = stderr
self.stdin = stdin
def get_config(self, key):
"""Helper method for looking up the value of a --conf.xyz flag"""
return getattr(self.args, 'conf.{}'.format(key))
@property
def help(self):
return '--help' in self.argv or '-h' in self.argv
def authenticate(self):
"""Configure the current session (or OAuth2.0 token)"""
token = self.get_config('token')
if token:
self.root.connection.login(
None,
None,
token=token,
)
else:
config.use_sessions = True
self.root.load_session().get()
def connect(self):
"""Fetch top-level resources from /api/v2"""
config.base_url = self.get_config('host')
config.client_connection_attempts = 1
config.assume_untrusted = False
if self.get_config('insecure'):
config.assume_untrusted = True
config.credentials = utils.PseudoNamespace(
{
'default': {
'username': self.get_config('username'),
'password': self.get_config('password'),
}
}
)
_, remainder = self.parser.parse_known_args()
if remainder and remainder[0] == 'config':
# the config command is special; it doesn't require
# API connectivity
return
# ...otherwise, set up a awxkit connection because we're
# likely about to do some requests to /api/v2/
self.root = api.Api()
try:
self.fetch_version_root()
except RequestException:
# If we can't reach the API root (this usually means that the
# hostname is wrong, or the credentials are wrong)
if self.help:
# ...but the user specified -h...
known, unknown = self.parser.parse_known_args(self.argv)
if len(unknown) == 1 and os.path.basename(unknown[0]) == 'awx':
return
raise
def fetch_version_root(self):
try:
self.v2 = self.root.get().available_versions.v2.get()
except AttributeError:
raise RuntimeError('An error occurred while fetching {}/api/'.format(self.get_config('host')))
def parse_resource(self, skip_deprecated=False):
"""Attempt to parse the <resource> (e.g., jobs) specified on the CLI
If a valid resource is discovered, the user will be authenticated
(either via an OAuth2.0 token or session-based auth) and the remaining
CLI arguments will be processed (to determine the requested action
e.g., list, create, delete)
:param skip_deprecated: when False (the default), deprecated resource
names from the open source tower-cli project
will be allowed
"""
self.resource = parse_resource(self, skip_deprecated=skip_deprecated)
if self.resource:
self.authenticate()
resource = getattr(self.v2, self.resource)
if is_control_resource(self.resource):
# control resources are special endpoints that you can only
# do an HTTP GET to, and which return plain JSON metadata
# examples are `/api/v2/ping/`, `/api/v2/config/`, etc...
if self.help:
self.subparsers[self.resource].print_help()
raise SystemExit()
self.method = 'get'
response = getattr(resource, self.method)()
else:
response = self.parse_action(resource)
_filter = self.get_config('filter')
# human format for metrics, settings is special
if self.resource in ('metrics', 'settings') and self.get_config('format') == 'human':
response.json = {'count': len(response.json), 'results': [{'key': k, 'value': v} for k, v in response.json.items()]}
_filter = 'key, value'
if self.get_config('format') == 'human' and _filter == '.' and self.resource in UNIQUENESS_RULES:
_filter = ', '.join(UNIQUENESS_RULES[self.resource])
formatted = format_response(
response, fmt=self.get_config('format'), filter=_filter, changed=self.original_action in ('modify', 'create', 'associate', 'disassociate')
)
if formatted:
print(utils.to_str(formatted), file=self.stdout)
if hasattr(response, 'rc'):
raise SystemExit(response.rc)
else:
self.parser.print_help()
def parse_action(self, page, from_sphinx=False):
"""Perform an HTTP OPTIONS request
This method performs an HTTP OPTIONS request to build a list of valid
actions, and (if provided) runs the code for the action specified on
the CLI
:param page: a awxkit.api.pages.TentativePage object representing the
top-level resource in question (e.g., /api/v2/jobs)
:param from_sphinx: a flag specified by our sphinx plugin, which allows
us to walk API OPTIONS using this function
_without_ triggering a SystemExit (argparse's
behavior if required arguments are missing)
"""
subparsers = self.subparsers[self.resource].add_subparsers(dest='action', metavar='action')
subparsers.required = True
# parse the action from OPTIONS
parser = ResourceOptionsParser(self.v2, page, self.resource, subparsers)
if parser.deprecated:
description = 'This resource has been deprecated and will be removed in a future release.'
if not from_sphinx:
description = colored(description, 'yellow')
self.subparsers[self.resource].description = description
if from_sphinx:
# Our Sphinx plugin runs `parse_action` for *every* available
# resource + action in the API so that it can generate usage
# strings for automatic doc generation.
#
# Because of this behavior, we want to silently ignore the
# `SystemExit` argparse will raise when you're missing required
# positional arguments (which some actions have).
try:
self.parser.parse_known_args(self.argv)[0]
except SystemExit:
pass
else:
self.parser.parse_known_args()[0]
# parse any action arguments
if self.resource != 'settings':
for method in ('list', 'modify', 'create'):
if method in parser.parser.choices:
parser.build_query_arguments(method, 'GET' if method == 'list' else 'POST')
if from_sphinx:
parsed, extra = self.parser.parse_known_args(self.argv)
else:
parsed, extra = self.parser.parse_known_args()
if extra and self.verbose:
# If extraneous arguments were provided, warn the user
cprint('{}: unrecognized arguments: {}'.format(self.parser.prog, ' '.join(extra)), 'yellow', file=self.stdout)
# build a dictionary of all of the _valid_ flags specified on the
# command line so we can pass them on to the underlying awxkit call
# we ignore special global flags like `--help` and `--conf.xyz`, and
# the positional resource argument (i.e., "jobs")
# everything else is a flag used as a query argument for the HTTP
# request we'll make (e.g., --username="Joe", --verbosity=3)
parsed = parsed.__dict__
parsed = dict((k, v) for k, v in parsed.items() if (v is not None and k not in ('help', 'resource') and not k.startswith('conf.')))
# if `id` is one of the arguments, it's a detail view
if 'id' in parsed:
page.endpoint += '{}/'.format(str(parsed.pop('id')))
# determine the awxkit method to call
action = self.original_action = parsed.pop('action')
page, action = handle_custom_actions(self.resource, action, page)
self.method = {
'list': 'get',
'modify': 'patch',
}.get(action, action)
if self.method == 'patch' and not parsed:
# If we're doing an HTTP PATCH with an empty payload,
# just print the help message (it's a no-op anyways)
parser.parser.choices['modify'].print_help()
return
if self.help:
# If --help is specified on a subarg parser, bail out
# and print its help text
parser.parser.choices[self.original_action].print_help()
return
if self.original_action == 'create':
return page.post(parsed)
return getattr(page, self.method)(**parsed)
def parse_args(self, argv, env=None):
"""Configure the global parser.ArgumentParser object and apply
global flags (such as --help, authentication, and formatting arguments)
"""
env = env or os.environ
self.argv = argv
self.parser = HelpfulArgumentParser(add_help=False)
self.parser.add_argument(
'--help',
action='store_true',
help='prints usage information for the awx tool',
)
self.parser.add_argument('--version', dest='conf.version', action='version', help='display awx CLI version', version=__version__)
add_authentication_arguments(self.parser, env)
add_output_formatting_arguments(self.parser, env)
self.args = self.parser.parse_known_args(self.argv)[0]
self.verbose = self.get_config('verbose')
if self.verbose:
logging.basicConfig(level='DEBUG')
self.color = self.get_config('color')
if not self.color:
disable_color()
fmt = self.get_config('format')
if fmt not in FORMATTERS.keys():
self.parser.error('No formatter %s available.' % (fmt))
0707010000004A000081A400000000000000000000000166846B9200004F57000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/cli/custom.pyimport functools
import json
from .stdout import monitor, monitor_workflow
from .utils import CustomRegistryMeta, color_enabled
from awxkit import api
from awxkit.config import config
from awxkit.exceptions import NoContent
def handle_custom_actions(resource, action, page):
key = ' '.join([resource, action])
if key in CustomAction.registry:
page = CustomAction.registry[key](page)
action = 'perform'
return page, action
class CustomActionRegistryMeta(CustomRegistryMeta):
@property
def name(self):
return ' '.join([self.resource, self.action])
class CustomAction(metaclass=CustomActionRegistryMeta):
"""Base class for defining a custom action for a resource."""
def __init__(self, page):
self.page = page
@property
def action(self):
raise NotImplementedError()
@property
def resource(self):
raise NotImplementedError()
@property
def perform(self):
raise NotImplementedError()
def add_arguments(self, parser, resource_options_parser):
pass
class Launchable(object):
@property
def options_endpoint(self):
return self.page.endpoint + '1/{}/'.format(self.action)
def add_arguments(self, parser, resource_options_parser, with_pk=True):
from .options import pk_or_name
if with_pk:
parser.choices[self.action].add_argument('id', type=functools.partial(pk_or_name, None, self.resource, page=self.page), help='')
parser.choices[self.action].add_argument('--monitor', action='store_true', help='If set, prints stdout of the launched job until it finishes.')
parser.choices[self.action].add_argument('--action-timeout', type=int, help='If set with --monitor or --wait, time out waiting on job completion.')
parser.choices[self.action].add_argument('--wait', action='store_true', help='If set, waits until the launched job finishes.')
parser.choices[self.action].add_argument(
'--interval',
type=float,
help='If set with --monitor or --wait, amount of time to wait in seconds between api calls. Minimum value is 2.5 seconds to avoid overwhelming the api',
)
launch_time_options = self.page.connection.options(self.options_endpoint)
if launch_time_options.ok:
launch_time_options = launch_time_options.json()['actions']['POST']
resource_options_parser.options['LAUNCH'] = launch_time_options
resource_options_parser.build_query_arguments(self.action, 'LAUNCH')
def monitor(self, response, **kwargs):
mon = monitor_workflow if response.type == 'workflow_job' else monitor
if kwargs.get('monitor') or kwargs.get('wait'):
status = mon(
response,
self.page.connection.session,
print_stdout=not kwargs.get('wait'),
action_timeout=kwargs.get('action_timeout'),
interval=kwargs.get('interval'),
)
if status:
response.json['status'] = status
if status in ('failed', 'error'):
setattr(response, 'rc', 1)
return response
def perform(self, **kwargs):
monitor_kwargs = {
'monitor': kwargs.pop('monitor', False),
'wait': kwargs.pop('wait', False),
'action_timeout': kwargs.pop('action_timeout', False),
'interval': kwargs.pop('interval', 5),
}
response = self.page.get().related.get(self.action).post(kwargs)
self.monitor(response, **monitor_kwargs)
return response
class JobTemplateLaunch(Launchable, CustomAction):
action = 'launch'
resource = 'job_templates'
class BulkJobLaunch(Launchable, CustomAction):
action = 'job_launch'
resource = 'bulk'
@property
def options_endpoint(self):
return self.page.endpoint + '{}/'.format(self.action)
def add_arguments(self, parser, resource_options_parser):
Launchable.add_arguments(self, parser, resource_options_parser, with_pk=False)
def perform(self, **kwargs):
monitor_kwargs = {
'monitor': kwargs.pop('monitor', False),
'wait': kwargs.pop('wait', False),
'action_timeout': kwargs.pop('action_timeout', False),
}
response = self.page.get().job_launch.post(kwargs)
self.monitor(response, **monitor_kwargs)
return response
class BulkHostCreate(CustomAction):
action = 'host_create'
resource = 'bulk'
@property
def options_endpoint(self):
return self.page.endpoint + '{}/'.format(self.action)
def add_arguments(self, parser, resource_options_parser):
options = self.page.connection.options(self.options_endpoint)
if options.ok:
options = options.json()['actions']['POST']
resource_options_parser.options['HOSTCREATEPOST'] = options
resource_options_parser.build_query_arguments(self.action, 'HOSTCREATEPOST')
def perform(self, **kwargs):
response = self.page.get().host_create.post(kwargs)
return response
class BulkHostDelete(CustomAction):
action = 'host_delete'
resource = 'bulk'
@property
def options_endpoint(self):
return self.page.endpoint + '{}/'.format(self.action)
def add_arguments(self, parser, resource_options_parser):
options = self.page.connection.options(self.options_endpoint)
if options.ok:
options = options.json()['actions']['POST']
resource_options_parser.options['HOSTDELETEPOST'] = options
resource_options_parser.build_query_arguments(self.action, 'HOSTDELETEPOST')
def perform(self, **kwargs):
response = self.page.get().host_delete.post(kwargs)
return response
class ProjectUpdate(Launchable, CustomAction):
action = 'update'
resource = 'projects'
class ProjectCreate(CustomAction):
action = 'create'
resource = 'projects'
def add_arguments(self, parser, resource_options_parser):
parser.choices[self.action].add_argument('--monitor', action='store_true', help=('If set, prints stdout of the project update until ' 'it finishes.'))
parser.choices[self.action].add_argument('--wait', action='store_true', help='If set, waits until the new project has updated.')
def post(self, kwargs):
should_monitor = kwargs.pop('monitor', False)
wait = kwargs.pop('wait', False)
response = self.page.post(kwargs)
if should_monitor or wait:
update = response.related.project_updates.get(order_by='-created').results[0]
monitor(
update,
self.page.connection.session,
print_stdout=not wait,
)
return response
class InventoryUpdate(Launchable, CustomAction):
action = 'update'
resource = 'inventory_sources'
class AdhocCommandLaunch(Launchable, CustomAction):
action = 'create'
resource = 'ad_hoc_commands'
def add_arguments(self, parser, resource_options_parser):
Launchable.add_arguments(self, parser, resource_options_parser, with_pk=False)
def perform(self, **kwargs):
monitor_kwargs = {
'monitor': kwargs.pop('monitor', False),
'wait': kwargs.pop('wait', False),
}
response = self.page.post(kwargs)
self.monitor(response, **monitor_kwargs)
return response
def post(self, kwargs):
return self.perform(**kwargs)
class WorkflowLaunch(Launchable, CustomAction):
action = 'launch'
resource = 'workflow_job_templates'
class HasStdout(object):
action = 'stdout'
def add_arguments(self, parser, resource_options_parser):
from .options import pk_or_name
parser.choices['stdout'].add_argument('id', type=functools.partial(pk_or_name, None, self.resource, page=self.page), help='')
def perform(self):
fmt = 'txt_download'
if color_enabled():
fmt = 'ansi_download'
return self.page.connection.get(self.page.get().related.stdout, query_parameters=dict(format=fmt)).content.decode('utf-8')
class JobStdout(HasStdout, CustomAction):
resource = 'jobs'
class ProjectUpdateStdout(HasStdout, CustomAction):
resource = 'project_updates'
class InventoryUpdateStdout(HasStdout, CustomAction):
resource = 'inventory_updates'
class AdhocCommandStdout(HasStdout, CustomAction):
resource = 'ad_hoc_commands'
class AssociationMixin(object):
action = 'associate'
def add_arguments(self, parser, resource_options_parser):
from .options import pk_or_name
parser.choices[self.action].add_argument('id', type=functools.partial(pk_or_name, None, self.resource, page=self.page), help='')
group = parser.choices[self.action].add_mutually_exclusive_group(required=True)
for param, endpoint in self.targets.items():
field, model_name = endpoint
if not model_name:
model_name = param
help_text = 'The ID (or name) of the {} to {}'.format(model_name, self.action)
class related_page(object):
def __init__(self, connection, resource):
self.conn = connection
self.resource = {
'approval_notification': 'notification_templates',
'start_notification': 'notification_templates',
'success_notification': 'notification_templates',
'failure_notification': 'notification_templates',
'credential': 'credentials',
'galaxy_credential': 'credentials',
}[resource]
def get(self, **kwargs):
v2 = api.Api(connection=self.conn).get().current_version.get()
return getattr(v2, self.resource).get(**kwargs)
group.add_argument(
'--{}'.format(param),
metavar='',
type=functools.partial(pk_or_name, None, param, page=related_page(self.page.connection, param)),
help=help_text,
)
def perform(self, **kwargs):
for k, v in kwargs.items():
endpoint, _ = self.targets[k]
try:
self.page.get().related[endpoint].post({'id': v, self.action: True})
except NoContent:
# we expect to enter this block because these endpoints return
# HTTP 204 on success
pass
return self.page.get().related[endpoint].get()
class NotificationAssociateMixin(AssociationMixin):
targets = {
'start_notification': ['notification_templates_started', 'notification_template'],
'success_notification': ['notification_templates_success', 'notification_template'],
'failure_notification': ['notification_templates_error', 'notification_template'],
}
class JobTemplateNotificationAssociation(NotificationAssociateMixin, CustomAction):
resource = 'job_templates'
action = 'associate'
targets = NotificationAssociateMixin.targets.copy()
class JobTemplateNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
resource = 'job_templates'
action = 'disassociate'
targets = NotificationAssociateMixin.targets.copy()
JobTemplateNotificationAssociation.targets.update(
{
'credential': ['credentials', None],
}
)
JobTemplateNotificationDisAssociation.targets.update(
{
'credential': ['credentials', None],
}
)
class WorkflowJobTemplateNotificationAssociation(NotificationAssociateMixin, CustomAction):
resource = 'workflow_job_templates'
action = 'associate'
targets = NotificationAssociateMixin.targets.copy()
class WorkflowJobTemplateNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
resource = 'workflow_job_templates'
action = 'disassociate'
targets = NotificationAssociateMixin.targets.copy()
WorkflowJobTemplateNotificationAssociation.targets.update(
{
'approval_notification': ['notification_templates_approvals', 'notification_template'],
}
)
WorkflowJobTemplateNotificationDisAssociation.targets.update(
{
'approval_notification': ['notification_templates_approvals', 'notification_template'],
}
)
class ProjectNotificationAssociation(NotificationAssociateMixin, CustomAction):
resource = 'projects'
action = 'associate'
class ProjectNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
resource = 'projects'
action = 'disassociate'
class InventorySourceNotificationAssociation(NotificationAssociateMixin, CustomAction):
resource = 'inventory_sources'
action = 'associate'
class InventorySourceNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
resource = 'inventory_sources'
action = 'disassociate'
class OrganizationNotificationAssociation(NotificationAssociateMixin, CustomAction):
resource = 'organizations'
action = 'associate'
targets = NotificationAssociateMixin.targets.copy()
class OrganizationNotificationDisAssociation(NotificationAssociateMixin, CustomAction):
resource = 'organizations'
action = 'disassociate'
targets = NotificationAssociateMixin.targets.copy()
OrganizationNotificationAssociation.targets.update(
{
'approval_notification': ['notification_templates_approvals', 'notification_template'],
'galaxy_credential': ['galaxy_credentials', 'credential'],
}
)
OrganizationNotificationDisAssociation.targets.update(
{
'approval_notification': ['notification_templates_approvals', 'notification_template'],
'galaxy_credential': ['galaxy_credentials', 'credential'],
}
)
class SettingsList(CustomAction):
action = 'list'
resource = 'settings'
def add_arguments(self, parser, resource_options_parser):
parser.choices['list'].add_argument('--slug', help='optional setting category/slug', default='all')
def perform(self, slug):
self.page.endpoint = self.page.endpoint + '{}/'.format(slug)
return self.page.get()
class RoleMixin(object):
has_roles = [
['organizations', 'organization'],
['projects', 'project'],
['inventories', 'inventory'],
['teams', 'team'],
['credentials', 'credential'],
['job_templates', 'job_template'],
['workflow_job_templates', 'workflow_job_template'],
['instance_groups', 'instance_group'],
]
roles = {} # this is calculated once
def add_arguments(self, parser, resource_options_parser):
from .options import pk_or_name
if not RoleMixin.roles:
for resource, flag in self.has_roles:
options = self.page.__class__(self.page.endpoint.replace(self.resource, resource), self.page.connection).options()
RoleMixin.roles[flag] = [role.replace('_role', '') for role in options.json.get('object_roles', [])]
possible_roles = set()
for v in RoleMixin.roles.values():
possible_roles.update(v)
resource_group = parser.choices[self.action].add_mutually_exclusive_group(required=True)
parser.choices[self.action].add_argument(
'id',
type=functools.partial(pk_or_name, None, self.resource, page=self.page),
help='The ID (or name) of the {} to {} access to/from'.format(self.resource, self.action),
)
for _type in RoleMixin.roles.keys():
if _type == 'team' and self.resource == 'team':
# don't add a team to a team
continue
class related_page(object):
def __init__(self, connection, resource):
self.conn = connection
if resource == 'inventories':
resource = 'inventory' # d'oh, this is special
self.resource = resource
def get(self, **kwargs):
v2 = api.Api(connection=self.conn).get().current_version.get()
return getattr(v2, self.resource).get(**kwargs)
resource_group.add_argument(
'--{}'.format(_type),
type=functools.partial(pk_or_name, None, _type, page=related_page(self.page.connection, dict((v, k) for k, v in self.has_roles)[_type])),
metavar='ID',
help='The ID (or name) of the target {}'.format(_type),
)
parser.choices[self.action].add_argument(
'--role', type=str, choices=possible_roles, required=True, help='The name of the role to {}'.format(self.action)
)
def perform(self, **kwargs):
for resource, flag in self.has_roles:
if flag in kwargs:
role = kwargs['role']
if role not in RoleMixin.roles[flag]:
options = ', '.join(RoleMixin.roles[flag])
raise ValueError("invalid choice: '{}' must be one of {}".format(role, options))
value = kwargs[flag]
target = '{}v2/{}/{}'.format(config.api_base_path, resource, value)
detail = self.page.__class__(target, self.page.connection).get()
object_roles = detail['summary_fields']['object_roles']
actual_role = object_roles[role + '_role']
params = {'id': actual_role['id']}
if self.action == 'grant':
params['associate'] = True
if self.action == 'revoke':
params['disassociate'] = True
try:
self.page.get().related.roles.post(params)
except NoContent:
# we expect to enter this block because these endpoints return
# HTTP 204 on success
pass
class UserGrant(RoleMixin, CustomAction):
resource = 'users'
action = 'grant'
class UserRevoke(RoleMixin, CustomAction):
resource = 'users'
action = 'revoke'
class TeamGrant(RoleMixin, CustomAction):
resource = 'teams'
action = 'grant'
class TeamRevoke(RoleMixin, CustomAction):
resource = 'teams'
action = 'revoke'
class SettingsModify(CustomAction):
action = 'modify'
resource = 'settings'
def add_arguments(self, parser, resource_options_parser):
options = self.page.__class__(self.page.endpoint + 'all/', self.page.connection).options()
parser.choices['modify'].add_argument('key', choices=sorted(options['actions']['PUT'].keys()), metavar='key', help='')
parser.choices['modify'].add_argument('value', help='')
def perform(self, key, value):
self.page.endpoint = self.page.endpoint + 'all/'
patch_value = value
if self.is_json(value):
patch_value = json.loads(value)
resp = self.page.patch(**{key: patch_value})
return resp.from_json({'key': key, 'value': resp[key]})
def is_json(self, data):
try:
json.loads(data)
except json.decoder.JSONDecodeError:
return False
return True
class HasMonitor(object):
action = 'monitor'
def add_arguments(self, parser, resource_options_parser):
from .options import pk_or_name
parser.choices[self.action].add_argument('id', type=functools.partial(pk_or_name, None, self.resource, page=self.page), help='')
def perform(self, **kwargs):
response = self.page.get()
mon = monitor_workflow if response.type == 'workflow_job' else monitor
if not response.failed and response.status != 'successful':
status = mon(
response,
self.page.connection.session,
)
if status:
response.json['status'] = status
if status in ('failed', 'error'):
setattr(response, 'rc', 1)
else:
return 'Unable to monitor finished job'
class JobMonitor(HasMonitor, CustomAction):
resource = 'jobs'
class WorkflowJobMonitor(HasMonitor, CustomAction):
resource = 'workflow_jobs'
0707010000004B000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001B00000000awx-24.6.1/awxkit/cli/docs0707010000004C000081A400000000000000000000000166846B920000027E000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/cli/docs/Makefile# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
0707010000004D000081A400000000000000000000000166846B9200000181000000000000000000000000000000000000002500000000awx-24.6.1/awxkit/cli/docs/README.mdBuilding the Documentation
--------------------------
To build the docs, spin up a real AWX server, `pip install sphinx sphinxcontrib-autoprogram`, and run:
~ CONTROLLER_HOST=https://awx.example.org CONTROLLER_USERNAME=example CONTROLLER_PASSWORD=secret make clean html
~ cd build/html/ && python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ..
0707010000004E000081A400000000000000000000000166846B920000031F000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/cli/docs/make.bat@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
0707010000004F000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/cli/docs/source07070100000050000081A400000000000000000000000166846B92000008A9000000000000000000000000000000000000003500000000awx-24.6.1/awxkit/cli/docs/source/authentication.rst.. _authentication:
Authentication
==============
Generating a Personal Access Token
----------------------------------
The preferred mechanism for authenticating with AWX and |RHAT| is by generating and storing an OAuth2.0 token. Tokens can be scoped for read/write permissions, are easily revoked, and are more suited to third party tooling integration than session-based authentication.
|prog| provides a simple login command for generating a personal access token from your username and password.
.. code:: bash
CONTROLLER_HOST=https://awx.example.org \
CONTROLLER_USERNAME=alice \
CONTROLLER_PASSWORD=secret \
awx login
As a convenience, the ``awx login -f human`` command prints a shell-formatted token
value:
.. code:: bash
export CONTROLLER_OAUTH_TOKEN=6E5SXhld7AMOhpRveZsLJQsfs9VS8U
By ingesting this token, you can run subsequent CLI commands without having to
specify your username and password each time:
.. code:: bash
export CONTROLLER_HOST=https://awx.example.org
$(CONTROLLER_USERNAME=alice CONTROLLER_PASSWORD=secret awx login -f human)
awx config
Working with OAuth2.0 Applications
----------------------------------
AWX and |RHAT| allow you to configure OAuth2.0 applications scoped to specific
organizations. To generate an application token (instead of a personal access
token), specify the **Client ID** and **Client Secret** generated when the
application was created.
.. code:: bash
CONTROLLER_USERNAME=alice CONTROLLER_PASSWORD=secret awx login \
--conf.client_id <value> --conf.client_secret <value>
OAuth2.0 Token Scoping
----------------------
By default, tokens created with ``awx login`` are write-scoped. To generate
a read-only token, specify ``--scope read``:
.. code:: bash
CONTROLLER_USERNAME=alice CONTROLLER_PASSWORD=secret \
awx login --conf.scope read
Session Authentication
----------------------
If you do not want or need to generate a long-lived token, |prog| allows you to
specify your username and password on every invocation:
.. code:: bash
CONTROLLER_USERNAME=alice CONTROLLER_PASSWORD=secret awx jobs list
awx --conf.username alice --conf.password secret jobs list
07070100000051000081A400000000000000000000000166846B92000007E9000000000000000000000000000000000000002A00000000awx-24.6.1/awxkit/cli/docs/source/conf.py# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'AWX CLI'
copyright = '2024, Ansible by Red Hat'
author = 'Ansible by Red Hat'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['awxkit.cli.sphinx']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'classic'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
rst_epilog = '''
.. |prog| replace:: awx
.. |at| replace:: automation controller
.. |At| replace:: Automation controller
.. |RHAT| replace:: Red Hat Ansible Automation Platform controller
'''
07070100000052000081A400000000000000000000000166846B920000093E000000000000000000000000000000000000002F00000000awx-24.6.1/awxkit/cli/docs/source/examples.rstUsage Examples
==============
Verifying CLI Configuration
---------------------------
To confirm that you've properly configured ``awx`` to point at the correct
AWX/|RHAT| host, and that your authentication credentials are correct, run:
.. code:: bash
awx config
.. note:: For help configuring authentication settings with the awx CLI, see :ref:`authentication`.
Printing the History of a Particular Job
----------------------------------------
To print a table containing the recent history of any jobs named ``Example Job Template``:
.. code:: bash
awx jobs list --all --name 'Example Job Template' \
-f human --filter 'name,created,status'
Creating and Launching a Job Template
-------------------------------------
Assuming you have an existing Inventory named ``Demo Inventory``, here's how
you might set up a new project from a GitHub repository, and run (and monitor
the output of) a playbook from that repository:
.. code:: bash
awx projects create --wait \
--organization 1 --name='Example Project' \
--scm_type git --scm_url 'https://github.com/ansible/ansible-tower-samples' \
-f human
awx job_templates create \
--name='Example Job Template' --project 'Example Project' \
--playbook hello_world.yml --inventory 'Demo Inventory' \
-f human
awx job_templates launch 'Example Job Template' --monitor -f human
Updating a Job Template with Extra Vars
---------------------------------------
.. code:: bash
awx job_templates modify 1 --extra_vars "@vars.yml"
awx job_templates modify 1 --extra_vars "@vars.json"
Importing an SSH Key
--------------------
.. code:: bash
awx credentials create --credential_type 'Machine' \
--name 'My SSH Key' --user 'alice' \
--inputs '{"username": "server-login", "ssh_key_data": "@~/.ssh/id_rsa"}'
Import/Export
-------------
Intended to be similar to `tower-cli send` and `tower-cli receive`.
Exporting everything:
.. code:: bash
awx export
Exporting everything of some particular type or types:
.. code:: bash
awx export --users
Exporting a particular named resource:
.. code:: bash
awx export --users admin
Exporting a resource by id:
.. code:: bash
awx export --users 42
Importing a set of resources stored as a file:
.. code:: bash
awx import < resources.json
07070100000053000081A400000000000000000000000166846B92000003D5000000000000000000000000000000000000002C00000000awx-24.6.1/awxkit/cli/docs/source/index.rst.. AWX CLI documentation master file, created by
sphinx-quickstart on Mon Jul 22 11:39:10 2019.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
AWX Command Line Interface
==========================
|prog| is the official command-line client for AWX and |RHAT|. It:
* Uses naming and structure consistent with the AWX HTTP API
* Provides consistent output formats with optional machine-parsable formats
* To the extent possible, auto-detects API versions, available endpoints, and
feature support across multiple versions of AWX and |RHAT|.
Potential uses include:
* Configuring and launching jobs/playbooks
* Checking on the status and output of job runs
* Managing objects like organizations, users, teams, etc...
.. toctree::
:maxdepth: 3
usage
authentication
output
examples
reference
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
07070100000054000081A400000000000000000000000166846B9200000141000000000000000000000000000000000000002E00000000awx-24.6.1/awxkit/cli/docs/source/install.rstThe preferred way to install the AWX CLI is through pip:
.. code:: bash
pip install "git+https://github.com/ansible/awx.git@$VERSION#egg=awxkit&subdirectory=awxkit"
...where ``$VERSION`` is the version of AWX you're running. To see a list of all available releases, visit: https://github.com/ansible/awx/releases
07070100000055000081A400000000000000000000000166846B92000005E2000000000000000000000000000000000000002D00000000awx-24.6.1/awxkit/cli/docs/source/output.rst.. _formatting:
Output Formatting
=================
By default, awx prints valid JSON for successful commands. The ``-f`` (or
``--conf.format``) global flag can be used to specify alternative output
formats.
YAML Formatting
---------------
To print results in YAML, specify ``-f yaml``:
.. code:: bash
awx jobs list -f yaml
Human-Readable (Tabular) Formatting
-----------------------------------
|prog| also provides support for printing results in a human-readable
ASCII table format:
.. code:: bash
awx jobs list -f human
awx jobs list -f human --filter name,created,status
awx jobs list -f human --filter *
Custom Formatting with jq
-------------------------
|prog| provides *optional* support for filtering results using the ``jq`` JSON
processor, but it requires an additional Python software dependency,
``jq``.
To use ``-f jq``, you must install the optional dependency via ``pip
install jq``. Note that some platforms may require additional programs to
build ``jq`` from source (like ``libtool``). See https://pypi.org/project/jq/ for instructions.
.. code:: bash
awx jobs list \
-f jq --filter '.results[] | .name + " is " + .status'
For details on ``jq`` filtering usage, see the ``jq`` manual at https://stedolan.github.io/jq/
Colorized Output
----------------
By default, |prog| prints colorized output using ANSI color codes. To disable
this functionality, specify ``--conf.color f`` or set the environment variable
``CONTROLLER_COLOR=f``.
07070100000056000081A400000000000000000000000166846B920000004A000000000000000000000000000000000000003000000000awx-24.6.1/awxkit/cli/docs/source/reference.rst.. autoprogram:: awxkit.cli.sphinx:parser
:prog: awx
:maxdepth: 3
07070100000057000081A400000000000000000000000166846B9200000BF8000000000000000000000000000000000000002C00000000awx-24.6.1/awxkit/cli/docs/source/usage.rstBasic Usage
===========
Installation
------------
.. include:: install.rst
Synopsis
--------
|prog| commands follow a simple format:
.. code:: bash
awx [<global-options>] <resource> <action> [<arguments>]
awx --help
The ``resource`` is a type of object within AWX (a noun), such as ``users`` or ``organizations``.
The ``action`` is the thing you want to do (a verb). Resources generally have a base set of actions (``get``, ``list``, ``create``, ``modify``, and ``delete``), and have options corresponding to fields on the object in AWX. Some resources have special actions, like ``job_templates launch``.
Getting Started
---------------
Using |prog| requires some initial configuration. Here is a simple example for interacting with an AWX or |RHAT| server:
.. code:: bash
awx --conf.host https://awx.example.org \
--conf.username joe --conf.password secret \
--conf.insecure \
users list
There are multiple ways to configure and authenticate with an AWX or |RHAT| server. For more details, see :ref:`authentication`.
By default, |prog| prints valid JSON for successful commands. Certain commands (such as those for printing job stdout) print raw text and do not allow for custom formatting. For details on customizing |prog|'s output format, see :ref:`formatting`.
Resources and Actions
---------------------
To get a list of available resources:
.. code:: bash
awx --conf.host https://awx.example.org --help
To get a description of a specific resource, and list its available actions (and their arguments):
.. code:: bash
awx --conf.host https://awx.example.org users --help
awx --conf.host https://awx.example.org users create --help
.. note:: The list of resources and actions may vary based on context. For
example, certain resources may not be available based on role-based access
control (e.g., if you do not have permission to launch certain Job Templates,
`launch` may not show up as an action for certain `job_templates` objects.
Global Options
--------------
|prog| accepts global options that control overall behavior. In addition to CLI flags, most global options have a corresponding environment variable that may be used to set the value. If both are provided, the command line option takes priority.
A few of the most important ones are:
``-h, --help``
Prints usage information for the |prog| tool
``-v, --verbose``
prints debug-level logs, including HTTP(s) requests made
``-f, --conf.format``
used to specify a custom output format (the default is json)
``--conf.host, CONTROLLER_HOST``
the full URL of the AWX/|RHAT| host (i.e., https://my.awx.example.org)
``-k, --conf.insecure, CONTROLLER_VERIFY_SSL``
allows insecure server connections when using SSL
``--conf.username, CONTROLLER_USERNAME``
the AWX username to use for authentication
``--conf.password, CONTROLLER_PASSWORD``
the AWX password to use for authentication
``--conf.token, CONTROLLER_OAUTH_TOKEN``
an OAuth2.0 token to use for authentication
07070100000058000081A400000000000000000000000166846B92000019F9000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/cli/format.pyimport locale
import json
from distutils.util import strtobool
import yaml
from awxkit.cli.utils import colored
from awxkit import config
def get_config_credentials():
"""Load username and password from config.credentials.default.
In order to respect configurations from AWXKIT_CREDENTIAL_FILE.
"""
default_username = 'admin'
default_password = 'password'
if not hasattr(config, 'credentials'):
return default_username, default_password
default = config.credentials.get('default', {})
return (default.get('username', default_username), default.get('password', default_password))
def add_authentication_arguments(parser, env):
auth = parser.add_argument_group('authentication')
auth.add_argument(
'--conf.host',
default=env.get('CONTROLLER_HOST', env.get('TOWER_HOST', 'https://127.0.0.1:443')),
metavar='https://example.awx.org',
)
auth.add_argument(
'--conf.token',
default=env.get('CONTROLLER_OAUTH_TOKEN', env.get('CONTROLLER_TOKEN', env.get('TOWER_OAUTH_TOKEN', env.get('TOWER_TOKEN', '')))),
help='an OAuth2.0 token (get one by using `awx login`)',
metavar='TEXT',
)
config_username, config_password = get_config_credentials()
# options configured via cli args take higher precedence than those from the config
auth.add_argument(
'--conf.username',
default=env.get('CONTROLLER_USERNAME', env.get('TOWER_USERNAME', config_username)),
metavar='TEXT',
)
auth.add_argument(
'--conf.password',
default=env.get('CONTROLLER_PASSWORD', env.get('TOWER_PASSWORD', config_password)),
metavar='TEXT',
)
auth.add_argument(
'-k',
'--conf.insecure',
help='Allow insecure server connections when using SSL',
default=not strtobool(env.get('CONTROLLER_VERIFY_SSL', env.get('TOWER_VERIFY_SSL', 'True'))),
action='store_true',
)
def add_verbose(formatting, env):
formatting.add_argument(
'-v',
'--verbose',
dest='conf.verbose',
help='print debug-level logs, including requests made',
default=strtobool(env.get('CONTROLLER_VERBOSE', env.get('TOWER_VERBOSE', 'f'))),
action="store_true",
)
def add_formatting_import_export(parser, env):
formatting = parser.add_argument_group('input/output formatting')
formatting.add_argument(
'-f',
'--conf.format',
dest='conf.format',
choices=['json', 'yaml'],
default=env.get('CONTROLLER_FORMAT', env.get('TOWER_FORMAT', 'json')),
help=('specify a format for the input and output'),
)
add_verbose(formatting, env)
def add_output_formatting_arguments(parser, env):
formatting = parser.add_argument_group('input/output formatting')
formatting.add_argument(
'-f',
'--conf.format',
dest='conf.format',
choices=FORMATTERS.keys(),
default=env.get('CONTROLLER_FORMAT', env.get('TOWER_FORMAT', 'json')),
help=('specify a format for the input and output'),
)
formatting.add_argument(
'--filter',
dest='conf.filter',
default='.',
metavar='TEXT',
help=('specify an output filter (only valid with jq or human format)'),
)
formatting.add_argument(
'--conf.color',
metavar='BOOLEAN',
help='Display colorized output. Defaults to True',
default=env.get('CONTROLLER_COLOR', env.get('TOWER_COLOR', 't')),
type=strtobool,
)
add_verbose(formatting, env)
def format_response(response, fmt='json', filter='.', changed=False):
if response is None:
return # HTTP 204
if isinstance(response, str):
return response
if 'results' in response.__dict__:
results = getattr(response, 'results')
else:
results = [response]
for result in results:
if 'related' in result.json:
result.json.pop('related')
formatted = FORMATTERS[fmt](response.json, filter)
if changed:
formatted = colored(formatted, 'green')
return formatted
def format_jq(output, fmt):
try:
import jq
except ImportError:
if fmt == '.':
return output
raise ImportError(
'To use `-f jq`, you must install the optional jq dependency.\n`pip install jq`\n',
'Note that some platforms may require additional programs to '
'build jq from source (like `libtool`).\n'
'See https://pypi.org/project/jq/ for instructions.',
)
results = []
for x in jq.jq(fmt).transform(output, multiple_output=True):
if x not in (None, ''):
if isinstance(x, str):
results.append(x)
else:
results.append(json.dumps(x))
return '\n'.join(results)
def format_json(output, fmt):
return json.dumps(output, indent=5)
def format_yaml(output, fmt):
output = json.loads(json.dumps(output))
return yaml.safe_dump(output, default_flow_style=False, allow_unicode=True)
def format_human(output, fmt):
lines = []
if fmt == '.':
fmt = 'id,name'
column_names = [col.strip() for col in fmt.split(',')]
if 'count' in output:
output = output['results']
else:
output = [output]
if fmt == '*' and len(output):
column_names = list(output[0].keys())
for k in ('summary_fields', 'related'):
if k in column_names:
column_names.remove(k)
table = [column_names]
table.extend([[record.get(col, '') for col in column_names] for record in output])
col_paddings = []
def format_num(v):
try:
return locale.format_string("%.*f", (0, int(v)), True)
except (ValueError, TypeError):
if isinstance(v, (list, dict)):
return json.dumps(v)
if v is None:
return ''
return v
# calculate the max width of each column
for i, _ in enumerate(column_names):
max_width = max([len(format_num(row[i])) for row in table])
col_paddings.append(max_width)
# insert a row of === header lines
table.insert(1, ['=' * i for i in col_paddings])
# print each row of the table data, justified based on col_paddings
for row in table:
line = ''
for i, value in enumerate(row):
line += format_num(value).ljust(col_paddings[i] + 1)
lines.append(line)
return '\n'.join(lines)
FORMATTERS = {'json': format_json, 'yaml': format_yaml, 'jq': format_jq, 'human': format_human}
07070100000059000081A400000000000000000000000166846B9200003128000000000000000000000000000000000000002100000000awx-24.6.1/awxkit/cli/options.pyimport argparse
import functools
import json
import os
import re
import sys
import yaml
from distutils.util import strtobool
from .custom import CustomAction
from .format import add_output_formatting_arguments
from .resource import DEPRECATED_RESOURCES_REVERSE
UNIQUENESS_RULES = {
'me': ('id', 'username'),
'users': ('id', 'username'),
'instances': ('id', 'hostname'),
}
def pk_or_name_list(v2, model_name, value, page=None):
return [pk_or_name(v2, model_name, v.strip(), page=page) for v in value.split(',')]
def pk_or_name(v2, model_name, value, page=None):
if isinstance(value, int):
return value
if re.match(r'^[\d]+$', value):
return int(value)
identity = 'name'
if not page:
if not hasattr(v2, model_name):
if model_name in DEPRECATED_RESOURCES_REVERSE:
model_name = DEPRECATED_RESOURCES_REVERSE[model_name]
if hasattr(v2, model_name):
page = getattr(v2, model_name)
if model_name in UNIQUENESS_RULES:
identity = UNIQUENESS_RULES[model_name][-1]
# certain related fields follow a pattern of <foo>_<model> e.g.,
# target_credential etc...
if not page and '_' in model_name:
return pk_or_name(v2, model_name.split('_')[-1], value, page)
if page:
results = page.get(**{identity: value})
if results.count == 1:
return int(results.results[0].id)
if results.count > 1:
raise argparse.ArgumentTypeError(
'Multiple {0} exist with that {1}. To look up an ID, run:\nawx {0} list --{1} "{2}" -f human'.format(model_name, identity, value)
)
raise argparse.ArgumentTypeError('Could not find any {0} with that {1}.'.format(model_name, identity))
return value
class JsonDumpsAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
# This Action gets called repeatedly on each instance of the flag that it is
# tied to, and unfortunately doesn't come with a good way of noticing we are at
# the end. So it's necessary to keep doing json.loads and json.dumps each time.
json_vars = json.loads(getattr(namespace, self.dest, None) or '{}')
json_vars.update(values)
setattr(namespace, self.dest, json.dumps(json_vars))
class ResourceOptionsParser(object):
deprecated = False
def __init__(self, v2, page, resource, parser):
"""Used to submit an OPTIONS request to the appropriate endpoint
and apply the appropriate argparse arguments
:param v2: a awxkit.api.pages.page.TentativePage instance
:param page: a awxkit.api.pages.page.TentativePage instance
:param resource: a string containing the resource (e.g., jobs)
:param parser: an argparse.ArgumentParser object to append new args to
"""
self.v2 = v2
self.page = page
self.resource = resource
self.parser = parser
self.options = getattr(self.page.options().json, 'actions', {'GET': {}})
self.get_allowed_options()
if self.resource != 'settings':
# /api/v2/settings is a special resource that doesn't have
# traditional list/detail endpoints
self.build_list_actions()
self.build_detail_actions()
self.handle_custom_actions()
def get_allowed_options(self):
options = self.page.connection.options(self.page.endpoint + '1/')
warning = options.headers.get('Warning', '')
if '299' in warning and 'deprecated' in warning:
self.deprecated = True
self.allowed_options = options.headers.get('Allow', '').split(', ')
def build_list_actions(self):
action_map = {
'GET': 'list',
'POST': 'create',
}
for method, action in self.options.items():
method = action_map[method]
parser = self.parser.add_parser(method, help='')
if method == 'list':
parser.add_argument(
'--all',
dest='all_pages',
action='store_true',
help=('fetch all pages of content from the API when returning results (instead of just the first page)'),
)
parser.add_argument(
'--order_by',
dest='order_by',
help=(
'order results by given field name, '
'prefix the field name with a dash (-) to sort in reverse eg --order_by=\'-name\','
'multiple sorting fields may be specified by separating the field names with a comma (,)'
),
)
add_output_formatting_arguments(parser, {})
def build_detail_actions(self):
allowed = ['get']
if 'PUT' in self.allowed_options:
allowed.append('modify')
if 'DELETE' in self.allowed_options:
allowed.append('delete')
for method in allowed:
parser = self.parser.add_parser(method, help='')
self.parser.choices[method].add_argument(
'id', type=functools.partial(pk_or_name, self.v2, self.resource), help='the ID (or unique name) of the resource'
)
if method == 'get':
add_output_formatting_arguments(parser, {})
def build_query_arguments(self, method, http_method):
required_group = None
for k, param in self.options.get(http_method, {}).items():
required = method == 'create' and param.get('required', False) is True
help_text = param.get('help_text', '')
args = ['--{}'.format(k)]
if method == 'list':
if k == 'id':
# don't allow `awx <resource> list` to filter on `--id`
# it's weird, and that's what awx <resource> get is for
continue
help_text = 'only list {} with the specified {}'.format(self.resource, k)
if method == 'list' and param.get('filterable') is False:
continue
def list_of_json_or_yaml(v):
return json_or_yaml(v, expected_type=list)
def json_or_yaml(v, expected_type=dict):
if v.startswith('@'):
v = open(os.path.expanduser(v[1:])).read()
try:
parsed = json.loads(v)
except Exception:
try:
parsed = yaml.safe_load(v)
except Exception:
raise argparse.ArgumentTypeError("{} is not valid JSON or YAML".format(v))
if not isinstance(parsed, expected_type):
raise argparse.ArgumentTypeError("{} is not valid JSON or YAML".format(v))
if expected_type is dict:
for k, v in parsed.items():
# add support for file reading at top-level JSON keys
# (to make things like SSH key data easier to work with)
if isinstance(v, str) and v.startswith('@'):
path = os.path.expanduser(v[1:])
parsed[k] = open(path).read()
return parsed
kwargs = {
'help': help_text,
'required': required,
'type': {
'string': str,
'field': int,
'integer': int,
'boolean': strtobool,
'id': functools.partial(pk_or_name, self.v2, k),
'json': json_or_yaml,
'list_of_ids': functools.partial(pk_or_name_list, self.v2, k),
}.get(param['type'], str),
}
meta_map = {
'string': 'TEXT',
'integer': 'INTEGER',
'boolean': 'BOOLEAN',
'id': 'ID', # foreign key
'list_of_ids': '[ID, ID, ...]',
'json': 'JSON/YAML',
}
if param.get('choices', []):
kwargs['choices'] = [c[0] for c in param['choices']]
# if there are choices, try to guess at the type (we can't
# just assume it's a list of str, but the API doesn't actually
# explicitly tell us in OPTIONS all the time)
sphinx = 'sphinx-build' in ' '.join(sys.argv)
if isinstance(kwargs['choices'][0], int) and not sphinx:
kwargs['type'] = int
else:
kwargs['choices'] = [str(choice) for choice in kwargs['choices']]
elif param['type'] in meta_map:
kwargs['metavar'] = meta_map[param['type']]
if param['type'] == 'id' and not kwargs.get('help'):
kwargs['help'] = 'the ID of the associated {}'.format(k)
if param['type'] == 'list_of_ids':
kwargs['help'] = 'a list of comma-delimited {} to associate (IDs or unique names)'.format(k)
if param['type'] == 'json' and method != 'list':
help_parts = []
if kwargs.get('help'):
help_parts.append(kwargs['help'])
else:
help_parts.append('a JSON or YAML string.')
help_parts.append('You can optionally specify a file path e.g., @path/to/file.yml')
kwargs['help'] = ' '.join(help_parts)
# SPECIAL CUSTOM LOGIC GOES HERE :'(
# There are certain requirements that aren't captured well by our
# HTTP OPTIONS due to $reasons
# This is where custom handling for those goes.
if self.resource == 'users' and method == 'create' and k == 'password':
kwargs['required'] = required = True
if self.resource == 'ad_hoc_commands' and method == 'create' and k in ('inventory', 'credential'):
kwargs['required'] = required = True
if self.resource == 'job_templates' and method == 'create' and k in ('project', 'playbook'):
kwargs['required'] = required = True
# unlike *other* actual JSON fields in the API, inventory and JT
# variables *actually* want json.dumps() strings (ugh)
# see: https://github.com/ansible/awx/issues/2371
if (self.resource in ('job_templates', 'workflow_job_templates') and k == 'extra_vars') or (
self.resource in ('inventory', 'groups', 'hosts') and k == 'variables'
):
kwargs['type'] = json_or_yaml
kwargs['action'] = JsonDumpsAction
if k == 'extra_vars':
args.append('-e')
# special handling for bulk endpoints
if self.resource == 'bulk':
if method == "host_create":
if k == "inventory":
kwargs['required'] = required = True
if k == 'hosts':
kwargs['type'] = list_of_json_or_yaml
kwargs['required'] = required = True
if method == "host_delete":
if k == 'hosts':
kwargs['type'] = list_of_json_or_yaml
kwargs['required'] = required = True
if method == "job_launch":
if k == 'jobs':
kwargs['type'] = list_of_json_or_yaml
kwargs['required'] = required = True
if required:
if required_group is None:
required_group = self.parser.choices[method].add_argument_group('required arguments')
# put the required group first (before the optional args group)
self.parser.choices[method]._action_groups.reverse()
required_group.add_argument(*args, **kwargs)
else:
self.parser.choices[method].add_argument(*args, **kwargs)
def handle_custom_actions(self):
for _, action in CustomAction.registry.items():
if action.resource != self.resource:
continue
if action.action not in self.parser.choices:
self.parser.add_parser(action.action, help='')
action(self.page).add_arguments(self.parser, self)
0707010000005A000081A400000000000000000000000166846B9200002259000000000000000000000000000000000000002200000000awx-24.6.1/awxkit/cli/resource.pyimport yaml
import json
import os
from awxkit import api, config, yaml_file
from awxkit.exceptions import ImportExportError
from awxkit.utils import to_str
from awxkit.api.pages import Page
from awxkit.api.pages.api import EXPORTABLE_RESOURCES
from awxkit.cli.format import FORMATTERS, format_response, add_authentication_arguments, add_formatting_import_export
from awxkit.cli.utils import CustomRegistryMeta, cprint
CONTROL_RESOURCES = ['ping', 'config', 'me', 'metrics', 'mesh_visualizer']
DEPRECATED_RESOURCES = {
'ad_hoc_commands': 'ad_hoc',
'applications': 'application',
'credentials': 'credential',
'credential_types': 'credential_type',
'groups': 'group',
'hosts': 'host',
'instances': 'instance',
'instance_groups': 'instance_group',
'inventory': 'inventories',
'inventory_sources': 'inventory_source',
'inventory_updates': 'inventory_update',
'jobs': 'job',
'job_templates': 'job_template',
'execution_environments': 'execution_environment',
'labels': 'label',
'workflow_job_template_nodes': 'node',
'notification_templates': 'notification_template',
'organizations': 'organization',
'projects': 'project',
'project_updates': 'project_update',
'roles': 'role',
'schedules': 'schedule',
'settings': 'setting',
'teams': 'team',
'workflow_job_templates': 'workflow',
'workflow_jobs': 'workflow_job',
'users': 'user',
}
DEPRECATED_RESOURCES_REVERSE = dict((v, k) for k, v in DEPRECATED_RESOURCES.items())
class CustomCommand(metaclass=CustomRegistryMeta):
"""Base class for implementing custom commands.
Custom commands represent static code which should run - they are
responsible for returning and formatting their own output (which may or may
not be JSON/YAML).
"""
help_text = ''
@property
def name(self):
raise NotImplementedError()
def handle(self, client, parser):
"""To be implemented by subclasses.
Should return a dictionary that is JSON serializable
"""
raise NotImplementedError()
class Login(CustomCommand):
name = 'login'
help_text = 'authenticate and retrieve an OAuth2 token'
def print_help(self, parser):
add_authentication_arguments(parser, os.environ)
parser.print_help()
def handle(self, client, parser):
auth = parser.add_argument_group('OAuth2.0 Options')
auth.add_argument('--description', help='description of the generated OAuth2.0 token', metavar='TEXT')
auth.add_argument('--conf.client_id', metavar='TEXT')
auth.add_argument('--conf.client_secret', metavar='TEXT')
auth.add_argument('--conf.scope', choices=['read', 'write'], default='write')
if client.help:
self.print_help(parser)
raise SystemExit()
parsed = parser.parse_known_args()[0]
kwargs = {
'client_id': getattr(parsed, 'conf.client_id', None),
'client_secret': getattr(parsed, 'conf.client_secret', None),
'scope': getattr(parsed, 'conf.scope', None),
}
if getattr(parsed, 'description', None):
kwargs['description'] = parsed.description
try:
token = api.Api().get_oauth2_token(**kwargs)
except Exception as e:
self.print_help(parser)
cprint('Error retrieving an OAuth2.0 token ({}).'.format(e.__class__), 'red')
else:
fmt = client.get_config('format')
if fmt == 'human':
print('export CONTROLLER_OAUTH_TOKEN={}'.format(token))
else:
print(to_str(FORMATTERS[fmt]({'token': token}, '.')).strip())
class Config(CustomCommand):
name = 'config'
help_text = 'print current configuration values'
def handle(self, client, parser):
if client.help:
parser.print_help()
raise SystemExit()
return {
'base_url': config.base_url,
'token': client.get_config('token'),
'use_sessions': config.use_sessions,
'credentials': config.credentials,
}
class Import(CustomCommand):
name = 'import'
help_text = 'import resources into Tower'
def handle(self, client, parser):
if parser:
parser.usage = 'awx import < exportfile'
parser.description = 'import resources from stdin'
add_formatting_import_export(parser, {})
if client.help:
parser.print_help()
raise SystemExit()
fmt = client.get_config('format')
if fmt == 'json':
data = json.load(client.stdin)
elif fmt == 'yaml':
data = yaml.load(client.stdin, Loader=yaml_file.Loader)
else:
raise ImportExportError("Unsupported format for Import: " + fmt)
client.authenticate()
client.v2.import_assets(data)
self._has_error = getattr(client.v2, '_has_error', False)
return {}
class Export(CustomCommand):
name = 'export'
help_text = 'export resources from Tower'
def extend_parser(self, parser):
resources = parser.add_argument_group('resources')
for resource in EXPORTABLE_RESOURCES:
# This parsing pattern will result in 3 different possible outcomes:
# 1) the resource flag is not used at all, which will result in the attr being None
# 2) the resource flag is used with no argument, which will result in the attr being ''
# 3) the resource flag is used with an argument, and the attr will be that argument's value
resources.add_argument('--{}'.format(resource), nargs='*')
def handle(self, client, parser):
self.extend_parser(parser)
parser.usage = 'awx export > exportfile'
parser.description = 'export resources to stdout'
add_formatting_import_export(parser, {})
if client.help:
parser.print_help()
raise SystemExit()
parsed = parser.parse_known_args()[0]
kwargs = {resource: getattr(parsed, resource, None) for resource in EXPORTABLE_RESOURCES}
client.authenticate()
data = client.v2.export_assets(**kwargs)
self._has_error = getattr(client.v2, '_has_error', False)
return data
def parse_resource(client, skip_deprecated=False):
subparsers = client.parser.add_subparsers(
dest='resource',
metavar='resource',
)
_system_exit = 0
# check if the user is running a custom command
for command in CustomCommand.__subclasses__():
client.subparsers[command.name] = subparsers.add_parser(command.name, help=command.help_text)
if hasattr(client, 'v2'):
for k in client.v2.json.keys():
if k in ('dashboard', 'config'):
# - the Dashboard API is deprecated and not supported
# - the Config command is already dealt with by the
# CustomCommand section above
continue
# argparse aliases are *only* supported in Python3 (not 2.7)
kwargs = {}
if not skip_deprecated:
if k in DEPRECATED_RESOURCES:
kwargs['aliases'] = [DEPRECATED_RESOURCES[k]]
client.subparsers[k] = subparsers.add_parser(k, help='', **kwargs)
resource = client.parser.parse_known_args()[0].resource
if resource in DEPRECATED_RESOURCES.values():
client.argv[client.argv.index(resource)] = DEPRECATED_RESOURCES_REVERSE[resource]
resource = DEPRECATED_RESOURCES_REVERSE[resource]
if resource in CustomCommand.registry:
parser = client.subparsers[resource]
command = CustomCommand.registry[resource]()
response = command.handle(client, parser)
if getattr(command, '_has_error', False):
_system_exit = 1
if response:
_filter = client.get_config('filter')
if resource == 'config' and client.get_config('format') == 'human':
response = {'count': len(response), 'results': [{'key': k, 'value': v} for k, v in response.items()]}
_filter = 'key, value'
try:
connection = client.root.connection
except AttributeError:
connection = None
formatted = format_response(Page.from_json(response, connection=connection), fmt=client.get_config('format'), filter=_filter)
print(formatted)
raise SystemExit(_system_exit)
else:
return resource
def is_control_resource(resource):
# special root level resources that don't don't represent database
# entities that follow the list/detail semantic
return resource in CONTROL_RESOURCES
0707010000005B000081A400000000000000000000000166846B9200000B98000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/cli/sphinx.pyimport os
from docutils.nodes import Text, paragraph
from sphinxcontrib.autoprogram import AutoprogramDirective
from .client import CLI
from .resource import is_control_resource, CustomCommand
class CustomAutoprogramDirective(AutoprogramDirective):
def run(self):
nodes = super(CustomAutoprogramDirective, self).run()
# By default, the document generated by sphinxcontrib.autoprogram
# just has a page title which is the program name ("awx")
# The code here changes this slightly so the reference guide starts
# with a human-friendly title and preamble
# configure a custom page heading (not `awx`)
heading = Text('Reference Guide')
heading.parent = nodes[0][0]
nodes[0][0].children = [heading]
# add a descriptive top synopsis of the reference guide
nodes[0].children.insert(1, paragraph(text=('This is an exhaustive guide of every available command in the awx CLI tool.')))
disclaimer = (
'The commands and parameters documented here can (and will) '
'vary based on a variety of factors, such as the AWX API '
'version, AWX settings, and access level of the authenticated '
'user. For the most accurate view of available commands, '
'invoke the awx CLI using the --help flag.'
)
nodes[0].children.insert(2, paragraph(text=disclaimer))
return nodes
def render():
# This function is called by Sphinx when making the docs.
#
# It loops over every resource at `/api/v2/` and performs an HTTP OPTIONS
# request to determine all of the supported actions and their arguments.
#
# The return value of this function is an argparse.ArgumentParser, which
# the sphinxcontrib.autoprogram plugin crawls and generates an indexed
# Sphinx document from.
for e in (
('CONTROLLER_HOST', 'TOWER_HOST'),
('CONTROLLER_USERNAME', 'TOWER_USERNAME'),
('CONTROLLER_PASSWORD', 'TOWER_PASSWORD'),
):
if not os.environ.get(e[0]) and not os.environ.get(e[1]):
raise SystemExit('Please specify a valid {} for a real (running) installation.'.format(e[0])) # noqa
cli = CLI()
cli.parse_args(['awx', '--help'])
cli.connect()
cli.authenticate()
try:
cli.parse_resource(skip_deprecated=True)
except SystemExit:
pass
for resource in cli.subparsers.keys():
cli.argv = [resource, '--help']
cli.resource = resource
if resource in CustomCommand.registry or is_control_resource(resource):
pass
else:
page = getattr(cli.v2, resource, None)
if page:
try:
cli.parse_action(page, from_sphinx=True)
except SystemExit:
pass
return cli.parser
def setup(app):
app.add_directive('autoprogram', CustomAutoprogramDirective)
parser = render()
0707010000005C000081A400000000000000000000000166846B9200000F1E000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/cli/stdout.py# -*- coding: utf-8 -*-
from __future__ import print_function
import sys
import time
from .utils import cprint, color_enabled, STATUS_COLORS
from awxkit.config import config
from awxkit.utils import to_str
def monitor_workflow(response, session, print_stdout=True, action_timeout=None, interval=5):
get = response.url.get
payload = {
'order_by': 'finished',
'unified_job_node__workflow_job': response.id,
}
def fetch(seen):
results = response.connection.get(f"{config.api_base_path}v2/unified_jobs", payload).json()['results']
# erase lines we've previously printed
if print_stdout and sys.stdout.isatty():
for _ in seen:
sys.stdout.write('\x1b[1A')
sys.stdout.write('\x1b[2K')
for result in results:
result['name'] = to_str(result['name'])
if print_stdout:
print(' ↳ {id} - {name} '.format(**result), end='')
status = result['status']
if color_enabled():
color = STATUS_COLORS.get(status, 'white')
cprint(status, color)
else:
print(status)
seen.add(result['id'])
if print_stdout:
cprint('------Starting Standard Out Stream------', 'red')
if print_stdout:
print('Launching {}...'.format(to_str(get().json.name)))
started = time.time()
seen = set()
while True:
if action_timeout and time.time() - started > action_timeout:
if print_stdout:
cprint('Monitoring aborted due to action-timeout.', 'red')
break
if sys.stdout.isatty():
# if this is a tty-like device, we can send ANSI codes
# to draw an auto-updating view
# otherwise, just wait for the job to finish and print it *once*
# all at the end
fetch(seen)
time.sleep(max(2.5, interval))
json = get().json
if json.finished:
fetch(seen)
break
if print_stdout:
cprint('------End of Standard Out Stream--------\n', 'red')
return get().json.status
def monitor(response, session, print_stdout=True, action_timeout=None, interval=5):
get = response.url.get
payload = {'order_by': 'start_line', 'no_truncate': True}
if response.type == 'job':
events = response.related.job_events.get
else:
events = response.related.events.get
next_line = 0
def fetch(next_line):
for result in events(**payload).json.results:
if result['start_line'] != next_line:
# If this event is a line from _later_ in the stdout,
# it means that the events didn't arrive in order;
# skip it for now and wait until the prior lines arrive and are
# printed
continue
stdout = to_str(result.get('stdout'))
if stdout and print_stdout:
print(stdout)
next_line = result['end_line']
return next_line
if print_stdout:
cprint('------Starting Standard Out Stream------', 'red')
started = time.time()
while True:
if action_timeout and time.time() - started > action_timeout:
if print_stdout:
cprint('Monitoring aborted due to action-timeout.', 'red')
break
next_line = fetch(next_line)
if next_line:
payload['start_line__gte'] = next_line
time.sleep(max(2.5, interval))
json = get().json
if json.event_processing_finished is True or json.status in ('error', 'canceled'):
fetch(next_line)
break
if print_stdout:
cprint('------End of Standard Out Stream--------\n', 'red')
return get().json.status
0707010000005D000081A400000000000000000000000166846B92000008E2000000000000000000000000000000000000001F00000000awx-24.6.1/awxkit/cli/utils.pyfrom __future__ import print_function
from argparse import ArgumentParser
import os
import sys
import threading
_color = threading.local()
_color.enabled = True
__all__ = ['CustomRegistryMeta', 'HelpfulArgumentParser', 'disable_color', 'color_enabled', 'colored', 'cprint', 'STATUS_COLORS']
STATUS_COLORS = {
'new': 'grey',
'pending': 'grey',
'running': 'yellow',
'successful': 'green',
'failed': 'red',
'error': 'red',
'canceled': 'grey',
}
class CustomRegistryMeta(type):
@property
def registry(cls):
return dict((command.name, command) for command in cls.__subclasses__())
class HelpfulArgumentParser(ArgumentParser):
def error(self, message): # pragma: nocover
"""Prints a usage message incorporating the message to stderr and
exits.
If you override this in a subclass, it should not return -- it
should either exit or raise an exception.
"""
self.print_help(sys.stderr)
self._print_message('\n')
self.exit(2, '%s: %s\n' % (self.prog, message))
def _parse_known_args(self, args, ns):
for arg in ('-h', '--help'):
# the -h argument is extraneous; if you leave it off,
# awx-cli will just print usage info
if arg in args:
args.remove(arg)
return super(HelpfulArgumentParser, self)._parse_known_args(args, ns)
def color_enabled():
return _color.enabled
def disable_color():
_color.enabled = False
COLORS = dict(
list(
zip(
[
'grey',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white',
],
list(range(30, 38)),
)
)
)
def colored(text, color=None):
'''Colorize text w/ ANSI color sequences'''
if _color.enabled and os.getenv('ANSI_COLORS_DISABLED') is None:
fmt_str = '\033[%dm%s'
if color is not None:
text = fmt_str % (COLORS[color], text)
text += '\033[0m'
return text
def cprint(text, color, **kwargs):
if _color.enabled:
print(colored(text, color), **kwargs)
else:
print(text, **kwargs)
0707010000005E000081A400000000000000000000000166846B92000003D9000000000000000000000000000000000000001C00000000awx-24.6.1/awxkit/config.pyimport types
import os
from .utils import (
PseudoNamespace,
load_credentials,
load_projects,
to_bool,
)
config = PseudoNamespace()
def getvalue(self, name):
return self.__getitem__(name)
if os.getenv('AWXKIT_BASE_URL'):
config.base_url = os.getenv('AWXKIT_BASE_URL')
if os.getenv('AWXKIT_CREDENTIAL_FILE'):
config.credentials = load_credentials(os.getenv('AWXKIT_CREDENTIAL_FILE'))
if os.getenv('AWXKIT_PROJECT_FILE'):
config.project_urls = load_projects(config.get('AWXKIT_PROJECT_FILE'))
# kludge to mimic pytest.config
config.getvalue = types.MethodType(getvalue, config)
config.assume_untrusted = config.get('assume_untrusted', True)
config.client_connection_attempts = int(os.getenv('AWXKIT_CLIENT_CONNECTION_ATTEMPTS', 5))
config.prevent_teardown = to_bool(os.getenv('AWXKIT_PREVENT_TEARDOWN', False))
config.use_sessions = to_bool(os.getenv('AWXKIT_SESSIONS', False))
config.api_base_path = os.getenv('AWXKIT_API_BASE_PATH', '/api/')
0707010000005F000081A400000000000000000000000166846B92000004DB000000000000000000000000000000000000002000000000awx-24.6.1/awxkit/exceptions.pyclass Common(Exception):
def __init__(self, status_string='', message=''):
if isinstance(status_string, Exception):
self.status_string = ''
return super(Common, self).__init__(*status_string)
self.status_string = status_string
self.msg = message
def __getitem__(self, val):
return (self.status_string, self.msg)[val]
def __repr__(self):
return self.__str__()
def __str__(self):
return '{} - {}'.format(self.status_string, self.msg)
class BadRequest(Common):
pass
class Conflict(Common):
pass
class Duplicate(Common):
pass
class Forbidden(Common):
pass
class InternalServerError(Common):
pass
class BadGateway(Common):
pass
class LicenseExceeded(Common):
pass
class LicenseInvalid(Common):
pass
class MethodNotAllowed(Common):
pass
class NoContent(Common):
message = ''
class NotFound(Common):
pass
class PaymentRequired(Common):
pass
class Unauthorized(Common):
pass
class Unknown(Common):
pass
class WaitUntilTimeout(Common):
pass
class UnexpectedAWXState(Common):
pass
class IsMigrating(Common):
pass
class ImportExportError(Exception):
pass
07070100000060000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001A00000000awx-24.6.1/awxkit/scripts07070100000061000081A400000000000000000000000166846B9200000000000000000000000000000000000000000000002600000000awx-24.6.1/awxkit/scripts/__init__.py07070100000062000081ED00000000000000000000000166846B9200000E6D000000000000000000000000000000000000002B00000000awx-24.6.1/awxkit/scripts/basic_session.pyfrom argparse import ArgumentParser
import logging
import pdb # noqa
import sys
import os
from awxkit import api, config, utils, exceptions, WSClient # noqa
from awxkit.awx.utils import check_related, delete_all, get_all, uses_sessions # noqa
from awxkit.awx.utils import as_user as _as_user
if str(os.getenv('AWXKIT_DEBUG', 'false')).lower() in ['true', '1']:
logging.basicConfig(level='DEBUG')
def parse_args():
parser = ArgumentParser()
parser.add_argument(
'--base-url',
dest='base_url',
default=os.getenv('AWXKIT_BASE_URL', 'http://127.0.0.1:8013'),
help='URL for AWX. Defaults to env var AWXKIT_BASE_URL or http://127.0.0.1:8013',
)
parser.add_argument(
'-c',
'--credential-file',
dest='credential_file',
default=os.getenv('AWXKIT_CREDENTIAL_FILE', utils.not_provided),
help='Path for yml credential file. If not provided or set by AWXKIT_CREDENTIAL_FILE, set '
'AWXKIT_USER and AWXKIT_USER_PASSWORD env vars for awx user credentials.',
)
parser.add_argument(
'-p',
'--project-file',
dest='project_file',
default=os.getenv('AWXKIT_PROJECT_FILE'),
help='Path for yml project config file.If not provided or set by AWXKIT_PROJECT_FILE, projects will not have default SCM_URL',
)
parser.add_argument('-f', '--file', dest='akit_script', default=False, help='akit script file to run in interactive session.')
parser.add_argument('-x', '--non-interactive', action='store_true', dest='non_interactive', help='Do not run in interactive mode.')
return parser.parse_known_args()[0]
def main():
exc = None
try:
global akit_args
akit_args = parse_args()
config.base_url = akit_args.base_url
if akit_args.credential_file != utils.not_provided:
config.credentials = utils.load_credentials(akit_args.credential_file)
else:
config.credentials = utils.PseudoNamespace(
{'default': {'username': os.getenv('AWXKIT_USER', 'admin'), 'password': os.getenv('AWXKIT_USER_PASSWORD', 'password')}}
)
if akit_args.project_file != utils.not_provided:
config.project_urls = utils.load_projects(akit_args.project_file)
global root
root = api.Api()
if uses_sessions(root.connection):
config.use_sessions = True
root.load_session().get()
else:
root.load_authtoken().get()
if 'v2' in root.available_versions:
global v2
v2 = root.available_versions.v2.get()
rc = 0
if akit_args.akit_script:
try:
exec(open(akit_args.akit_script).read(), globals())
except Exception as e:
exc = e
raise
except Exception as e:
exc = e # noqa
rc = 1 # noqa
raise
def as_user(username, password=None):
return _as_user(root, username, password)
def load_interactive():
if '--help' in sys.argv or '-h' in sys.argv:
return parse_args()
try:
from IPython import start_ipython
basic_session_path = os.path.abspath(__file__)
if basic_session_path[-1] == 'c': # start_ipython doesn't work w/ .pyc
basic_session_path = basic_session_path[:-1]
sargs = ['-i', basic_session_path]
if sys.argv[1:]:
sargs.extend(['--'] + sys.argv[1:])
return start_ipython(argv=sargs)
except ImportError:
from code import interact
main()
interact('', local=dict(globals(), **locals()))
if __name__ == '__main__':
main()
07070100000063000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001800000000awx-24.6.1/awxkit/utils07070100000064000081A400000000000000000000000166846B92000032A3000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/utils/__init__.pyfrom datetime import datetime, timedelta, tzinfo
import inspect
import logging
import random
import shlex
import types
import time
import sys
import re
import os
import yaml
from awxkit.words import words
from awxkit.exceptions import WaitUntilTimeout
log = logging.getLogger(__name__)
cloud_types = (
'aws',
'azure',
'azure_ad',
'azure_classic',
'azure_rm',
'cloudforms',
'ec2',
'gce',
'openstack',
'openstack_v2',
'openstack_v3',
'rhv',
'satellite6',
'tower',
'vmware',
)
credential_type_kinds = ('cloud', 'net')
not_provided = 'xx__NOT_PROVIDED__xx'
def super_dir_set(cls):
attrs = set()
for _class in inspect.getmro(cls):
attrs.update(dir(_class))
return attrs
class NoReloadError(Exception):
pass
class PseudoNamespace(dict):
def __init__(self, _d=None, **loaded):
if not isinstance(_d, dict):
_d = {}
_d.update(loaded)
super(PseudoNamespace, self).__init__(_d)
# Convert nested structures into PseudoNamespaces
for k, v in _d.items():
tuple_converted = False
if isinstance(v, tuple):
self[k] = v = list(v)
tuple_converted = True
if isinstance(v, list):
for i, item in enumerate(v):
if isinstance(item, dict):
self[k][i] = PseudoNamespace(item)
if tuple_converted:
self[k] = tuple(self[k])
elif isinstance(v, dict):
self[k] = PseudoNamespace(v)
def __getattr__(self, attr):
try:
return self.__getitem__(attr)
except KeyError:
raise AttributeError("{!r} has no attribute {!r}".format(self.__class__.__name__, attr))
def __setattr__(self, attr, value):
self.__setitem__(attr, value)
def __setitem__(self, key, value):
if not isinstance(value, PseudoNamespace):
tuple_converted = False
if isinstance(value, dict):
value = PseudoNamespace(value)
elif isinstance(value, tuple):
value = list(value)
tuple_converted = True
if isinstance(value, list):
for i, item in enumerate(value):
if isinstance(item, dict) and not isinstance(item, PseudoNamespace):
value[i] = PseudoNamespace(item)
if tuple_converted:
value = tuple(value)
super(PseudoNamespace, self).__setitem__(key, value)
def __delattr__(self, attr):
self.__delitem__(attr)
def __dir__(self):
attrs = super_dir_set(self.__class__)
attrs.update(self.keys())
return sorted(attrs)
# override builtin in order to have updated content become
# PseudoNamespaces if applicable
def update(self, iterable=None, **kw):
if iterable:
if hasattr(iterable, 'keys') and isinstance(iterable.keys, (types.FunctionType, types.BuiltinFunctionType, types.MethodType)):
for key in iterable:
self[key] = iterable[key]
else:
for k, v in iterable:
self[k] = v
for k in kw:
self[k] = kw[k]
def is_relative_endpoint(candidate):
return isinstance(candidate, (str,)) and candidate.startswith('/api/')
def is_class_or_instance(obj, cls):
"""returns True is obj is an instance of cls or is cls itself"""
return isinstance(obj, cls) or obj is cls
def filter_by_class(*item_class_tuples):
"""takes an arbitrary number of (item, class) tuples and returns a list consisting
of each item if it's an instance of the class, the item if it's a (class, dict()) tuple,
the class itself if item is truthy but not an instance of the
class or (class, dict()) tuple, or None if item is falsy in the same order as the arguments
```
_cred = Credential()
inv, org, cred = filter_base_subclasses((True, Inventory), (None, Organization), (_cred, Credential))
inv == Inventory
org == None
cred == _cred
```
"""
results = []
for item, cls in item_class_tuples:
if item:
was_tuple = False
if isinstance(item, tuple):
was_tuple = True
examined_item = item[0]
else:
examined_item = item
if is_class_or_instance(examined_item, cls) or is_proper_subclass(examined_item, cls):
results.append(item)
else:
updated = (cls, item[1]) if was_tuple else cls
results.append(updated)
else:
results.append(None)
return results
def load_credentials(filename=None):
if filename is None:
path = os.path.join(os.getcwd(), 'credentials.yaml')
else:
path = os.path.abspath(filename)
if os.path.isfile(path):
with open(path) as credentials_fh:
credentials_dict = yaml.safe_load(credentials_fh)
return credentials_dict
else:
msg = 'Unable to load credentials file at %s' % path
raise Exception(msg)
def load_projects(filename=None):
if filename is None:
return {}
else:
path = os.path.abspath(filename)
if os.path.isfile(path):
with open(path) as projects_fh:
projects_dict = yaml.safe_load(projects_fh)
return projects_dict
else:
msg = 'Unable to load projects file at %s' % path
raise Exception(msg)
def logged_sleep(duration, level='DEBUG', stack_depth=1):
level = getattr(logging, level.upper())
# based on
# http://stackoverflow.com/questions/1095543/get-name-of-calling-functions-module-in-python
try:
frm = inspect.stack()[stack_depth]
logger = logging.getLogger(inspect.getmodule(frm[0]).__name__)
except AttributeError: # module is None (interactive shell)
logger = log # fall back to utils logger
logger.log(level, 'Sleeping for {0} seconds.'.format(duration))
time.sleep(duration)
def poll_until(function, interval=5, timeout=0):
"""Polls `function` every `interval` seconds until it returns a non-falsey
value. If this does not occur within the provided `timeout`,
a WaitUntilTimeout is raised.
Each attempt will log the time that has elapsed since the original
request.
"""
start_time = time.time()
while True:
elapsed = time.time() - start_time
log.debug('elapsed: {0:4.1f}'.format(elapsed))
value = function()
if value:
return value
if elapsed > timeout:
break
logged_sleep(interval, stack_depth=3)
msg = 'Timeout after {0} seconds.'.format(elapsed)
raise WaitUntilTimeout(None, msg)
def gen_utf_char():
is_char = False
b = 'b'
while not is_char:
b = random.randint(32, 0x10FFFF)
is_char = chr(b).isprintable()
return chr(b)
def random_int(maxint=sys.maxsize):
max = int(maxint)
return random.randint(0, max)
def random_ipv4():
"""Generates a random ipv4 address;; useful for testing."""
return ".".join(str(random.randint(1, 255)) for i in range(4))
def random_ipv6():
"""Generates a random ipv6 address;; useful for testing."""
return ':'.join('{0:x}'.format(random.randint(0, 2**16 - 1)) for i in range(8))
def random_loopback_ip():
"""Generates a random loopback ipv4 address;; useful for testing."""
return "127.{}.{}.{}".format(random_int(255), random_int(255), random_int(255))
def random_utf8(*args, **kwargs):
"""This function exists due to a bug in ChromeDriver that throws an
exception when a character outside of the BMP is sent to `send_keys`.
Code pulled from http://stackoverflow.com/a/3220210.
"""
pattern = re.compile('[^\u0000-\uD7FF\uE000-\uFFFF]', re.UNICODE)
length = args[0] if len(args) else kwargs.get('length', 10)
scrubbed = pattern.sub('\uFFFD', ''.join([gen_utf_char() for _ in range(length)]))
return scrubbed
def random_title(num_words=2, non_ascii=True):
base = ''.join([random.choice(words) for word in range(num_words)])
if os.getenv('AWXKIT_FORCE_ONLY_ASCII', False):
title = ''.join([base, ''.join(str(random_int(99)))])
else:
if non_ascii:
title = ''.join([base, random_utf8(1)])
else:
title = ''.join([base, ''.join([str(random_int()) for _ in range(3)])])
return title
def update_payload(payload, fields, kwargs):
"""Takes a list of fields and adds their kwargs value to payload if defined.
If the payload has an existing value and not_provided is the kwarg value for that key,
the existing key/value are stripped from the payload.
"""
not_provided_as_kwarg = 'xx_UPDATE_PAYLOAD_FIELD_NOT_PROVIDED_AS_KWARG_xx'
for field in fields:
field_val = kwargs.get(field, not_provided_as_kwarg)
if field_val not in (not_provided, not_provided_as_kwarg):
payload[field] = field_val
elif field in payload and field_val == not_provided:
payload.pop(field)
return payload
def set_payload_foreign_key_args(payload, fk_fields, kwargs):
if isinstance(fk_fields, str):
fk_fields = (fk_fields,)
for fk_field in fk_fields:
rel_obj = kwargs.get(fk_field)
if rel_obj is None:
continue
elif isinstance(rel_obj, int):
payload.update(**{fk_field: int(rel_obj)})
elif hasattr(rel_obj, 'id'):
payload.update(**{fk_field: rel_obj.id})
else:
raise AttributeError(f'Related field {fk_field} must be either integer of pkid or object')
return payload
def to_str(obj):
if isinstance(obj, bytes):
return obj.decode('utf-8')
return obj
def to_bool(obj):
if isinstance(obj, (str,)):
return obj.lower() not in ('false', 'off', 'no', 'n', '0', '')
return bool(obj)
def load_json_or_yaml(obj):
try:
return yaml.safe_load(obj)
except AttributeError:
raise TypeError("Provide valid YAML/JSON.")
def get_class_if_instance(obj):
if not inspect.isclass(obj):
return obj.__class__
return obj
def class_name_to_kw_arg(class_name):
"""'ClassName' -> 'class_name'"""
first_pass = re.sub(r'([a-z])([A-Z0-9])', r'\1_\2', class_name)
second_pass = re.sub(r'([0-9])([a-zA-Z])', r'\1_\2', first_pass).lower()
return second_pass.replace('v2_', '')
def is_proper_subclass(obj, cls):
return inspect.isclass(obj) and obj is not cls and issubclass(obj, cls)
def are_same_endpoint(first, second):
"""Equivalence check of two urls, stripped of query parameters"""
def strip(url):
return url.replace('www.', '').split('?')[0]
return strip(first) == strip(second)
def utcnow():
"""Provide a wrapped copy of the built-in utcnow that can be easily mocked."""
return datetime.utcnow()
class UTC(tzinfo):
"""Concrete implementation of tzinfo for UTC. For more information, see:
https://docs.python.org/2/library/datetime.html
"""
def tzname(self, dt):
return 'UTC'
def dst(self, dt):
return timedelta(0)
def utcoffset(self, dt):
return timedelta(0)
def seconds_since_date_string(date_str, fmt='%Y-%m-%dT%H:%M:%S.%fZ', default_tz=UTC()):
"""Return the number of seconds since the date and time indicated by a date
string and its corresponding format string.
:param date_str: string representing a date and time.
:param fmt: Formatting string - by default, this value is set to parse
date strings originating from awx API response data.
:param default_tz: Assumed tzinfo if the parsed date_str does not include tzinfo
For more information on python date string formatting directives, see
https://docs.python.org/2/library/datetime.httpsml#strftime-strptime-behavior
"""
parsed_datetime = datetime.strptime(date_str, fmt)
if not parsed_datetime.tzinfo:
parsed_datetime = parsed_datetime.replace(tzinfo=default_tz)
elapsed = utcnow().replace(tzinfo=UTC()) - parsed_datetime
return elapsed.total_seconds()
def to_ical(dt):
return re.sub('[:-]', '', dt.strftime("%Y%m%dT%H%M%SZ"))
def version_from_endpoint(endpoint):
return endpoint.split('/api/')[1].split('/')[0] or 'common'
def args_string_to_list(args):
"""Converts cmdline arg string to list of args. The reverse of subprocess.list2cmdline()
heavily inspired by robot.utils.argumentparser.cmdline2list()
"""
lexer = shlex.shlex(args, posix=True)
lexer.escapedquotes = '"\''
lexer.commenters = ''
lexer.whitespace_split = True
return [token.decode('utf-8') for token in lexer]
def is_list_or_tuple(item):
return isinstance(item, list) or isinstance(item, tuple)
07070100000065000081A400000000000000000000000166846B9200000C32000000000000000000000000000000000000002400000000awx-24.6.1/awxkit/utils/toposort.py#######################################################################
# Implements a topological sort algorithm.
#
# Copyright 2014 True Blade Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Notes:
# Based on http://code.activestate.com/recipes/578272-topological-sort
# with these major changes:
# Added unittests.
# Deleted doctests (maybe not the best idea in the world, but it cleans
# up the docstring).
# Moved functools import to the top of the file.
# Changed assert to a ValueError.
# Changed iter[items|keys] to [items|keys], for python 3
# compatibility. I don't think it matters for python 2 these are
# now lists instead of iterables.
# Copy the input so as to leave it unmodified.
# Renamed function from toposort2 to toposort.
# Handle empty input.
# Switch tests to use set literals.
#
########################################################################
from functools import reduce as _reduce
__all__ = ['toposort', 'CircularDependencyError']
class CircularDependencyError(ValueError):
def __init__(self, data):
# Sort the data just to make the output consistent, for use in
# error messages. That's convenient for doctests.
s = 'Circular dependencies exist among these items: {{{}}}'.format(
', '.join('{!r}:{!r}'.format(key, value) for key, value in sorted(data.items()))
) # noqa
super(CircularDependencyError, self).__init__(s)
self.data = data
def toposort(data):
"""Dependencies are expressed as a dictionary whose keys are items
and whose values are a set of dependent items. Output is a list of
sets in topological order. The first set consists of items with no
dependences, each subsequent set consists of items that depend upon
items in the preceeding sets."""
# Special case empty input.
if len(data) == 0:
return
# Copy the input so as to leave it unmodified.
data = data.copy()
# Ignore self dependencies.
for k, v in data.items():
v.discard(k)
# Find all items that don't depend on anything.
extra_items_in_deps = _reduce(set.union, data.values()) - set(data.keys())
# Add empty dependences where needed.
data.update({item: set() for item in extra_items_in_deps})
while True:
ordered = set(item for item, dep in data.items() if len(dep) == 0)
if not ordered:
break
yield ordered
data = {item: (dep - ordered) for item, dep in data.items() if item not in ordered}
if len(data) != 0:
raise CircularDependencyError(data)
07070100000066000081A400000000000000000000000166846B92000053EE000000000000000000000000000000000000001B00000000awx-24.6.1/awxkit/words.py# list of random English nouns used for resource name utilities
words = [
'People',
'History',
'Way',
'Art',
'World',
'Information',
'Map',
'Two',
'Family',
'Government',
'Health',
'System',
'Computer',
'Meat',
'Year',
'Thanks',
'Music',
'Person',
'Reading',
'Method',
'Data',
'Food',
'Understanding',
'Theory',
'Law',
'Bird',
'Literature',
'Problem',
'Software',
'Control',
'Knowledge',
'Power',
'Ability',
'Economics',
'Love',
'Internet',
'Television',
'Science',
'Library',
'Nature',
'Fact',
'Product',
'Idea',
'Temperature',
'Investment',
'Area',
'Society',
'Activity',
'Story',
'Industry',
'Media',
'Thing',
'Oven',
'Community',
'Definition',
'Safety',
'Quality',
'Development',
'Language',
'Management',
'Player',
'Variety',
'Video',
'Week',
'Security',
'Country',
'Exam',
'Movie',
'Organization',
'Equipment',
'Physics',
'Analysis',
'Policy',
'Series',
'Thought',
'Basis',
'Boyfriend',
'Direction',
'Strategy',
'Technology',
'Army',
'Camera',
'Freedom',
'Paper',
'Environment',
'Child',
'Instance',
'Month',
'Truth',
'Marketing',
'University',
'Writing',
'Article',
'Department',
'Difference',
'Goal',
'News',
'Audience',
'Fishing',
'Growth',
'Income',
'Marriage',
'User',
'Combination',
'Failure',
'Meaning',
'Medicine',
'Philosophy',
'Teacher',
'Communication',
'Night',
'Chemistry',
'Disease',
'Disk',
'Energy',
'Nation',
'Road',
'Role',
'Soup',
'Advertising',
'Location',
'Success',
'Addition',
'Apartment',
'Education',
'Math',
'Moment',
'Painting',
'Politics',
'Attention',
'Decision',
'Event',
'Property',
'Shopping',
'Student',
'Wood',
'Competition',
'Distribution',
'Entertainment',
'Office',
'Population',
'President',
'Unit',
'Category',
'Cigarette',
'Context',
'Introduction',
'Opportunity',
'Performance',
'Driver',
'Flight',
'Length',
'Magazine',
'Newspaper',
'Relationship',
'Teaching',
'Cell',
'Dealer',
'Debate',
'Finding',
'Lake',
'Member',
'Message',
'Phone',
'Scene',
'Appearance',
'Association',
'Concept',
'Customer',
'Death',
'Discussion',
'Housing',
'Inflation',
'Insurance',
'Mood',
'Woman',
'Advice',
'Blood',
'Effort',
'Expression',
'Importance',
'Opinion',
'Payment',
'Reality',
'Responsibility',
'Situation',
'Skill',
'Statement',
'Wealth',
'Application',
'City',
'County',
'Depth',
'Estate',
'Foundation',
'Grandmother',
'Heart',
'Perspective',
'Photo',
'Recipe',
'Studio',
'Topic',
'Collection',
'Depression',
'Imagination',
'Passion',
'Percentage',
'Resource',
'Setting',
'Ad',
'Agency',
'College',
'Connection',
'Criticism',
'Debt',
'Description',
'Memory',
'Patience',
'Secretary',
'Solution',
'Administration',
'Aspect',
'Attitude',
'Director',
'Personality',
'Psychology',
'Recommendation',
'Response',
'Selection',
'Storage',
'Version',
'Alcohol',
'Argument',
'Complaint',
'Contract',
'Emphasis',
'Highway',
'Loss',
'Membership',
'Possession',
'Preparation',
'Steak',
'Union',
'Agreement',
'Cancer',
'Currency',
'Employment',
'Engineering',
'Entry',
'Interaction',
'Limit',
'Mixture',
'Preference',
'Region',
'Republic',
'Seat',
'Tradition',
'Virus',
'Actor',
'Classroom',
'Delivery',
'Device',
'Difficulty',
'Drama',
'Election',
'Engine',
'Football',
'Guidance',
'Hotel',
'Match',
'Owner',
'Priority',
'Protection',
'Suggestion',
'Tension',
'Variation',
'Anxiety',
'Atmosphere',
'Awareness',
'Bread',
'Climate',
'Comparison',
'Confusion',
'Construction',
'Elevator',
'Emotion',
'Employee',
'Employer',
'Guest',
'Height',
'Leadership',
'Mall',
'Manager',
'Operation',
'Recording',
'Respect',
'Sample',
'Transportation',
'Boring',
'Charity',
'Cousin',
'Disaster',
'Editor',
'Efficiency',
'Excitement',
'Extent',
'Feedback',
'Guitar',
'Homework',
'Leader',
'Mom',
'Outcome',
'Permission',
'Presentation',
'Promotion',
'Reflection',
'Refrigerator',
'Resolution',
'Revenue',
'Session',
'Singer',
'Tennis',
'Basket',
'Bonus',
'Cabinet',
'Childhood',
'Church',
'Clothes',
'Coffee',
'Dinner',
'Drawing',
'Hair',
'Hearing',
'Initiative',
'Judgment',
'Lab',
'Measurement',
'Mode',
'Mud',
'Orange',
'Poetry',
'Police',
'Possibility',
'Procedure',
'Queen',
'Ratio',
'Relation',
'Restaurant',
'Satisfaction',
'Sector',
'Signature',
'Significance',
'Song',
'Tooth',
'Town',
'Vehicle',
'Volume',
'Wife',
'Accident',
'Airport',
'Appointment',
'Arrival',
'Assumption',
'Baseball',
'Chapter',
'Committee',
'Conversation',
'Database',
'Enthusiasm',
'Error',
'Explanation',
'Farmer',
'Gate',
'Girl',
'Hall',
'Historian',
'Hospital',
'Injury',
'Instruction',
'Maintenance',
'Manufacturer',
'Meal',
'Perception',
'Pie',
'Poem',
'Presence',
'Proposal',
'Reception',
'Replacement',
'Revolution',
'River',
'Son',
'Speech',
'Tea',
'Village',
'Warning',
'Winner',
'Worker',
'Writer',
'Assistance',
'Breath',
'Buyer',
'Chest',
'Chocolate',
'Conclusion',
'Contribution',
'Cookie',
'Courage',
'Dad',
'Desk',
'Drawer',
'Establishment',
'Examination',
'Garbage',
'Grocery',
'Honey',
'Impression',
'Improvement',
'Independence',
'Insect',
'Inspection',
'Inspector',
'King',
'Ladder',
'Menu',
'Penalty',
'Piano',
'Potato',
'Profession',
'Professor',
'Quantity',
'Reaction',
'Requirement',
'Salad',
'Sister',
'Supermarket',
'Tongue',
'Weakness',
'Wedding',
'Affair',
'Ambition',
'Analyst',
'Apple',
'Assignment',
'Assistant',
'Bathroom',
'Bedroom',
'Beer',
'Birthday',
'Celebration',
'Championship',
'Cheek',
'Client',
'Consequence',
'Departure',
'Diamond',
'Dirt',
'Ear',
'Fortune',
'Friendship',
'Snapewife',
'Funeral',
'Gene',
'Girlfriend',
'Hat',
'Indication',
'Intention',
'Lady',
'Midnight',
'Negotiation',
'Obligation',
'Passenger',
'Pizza',
'Platform',
'Poet',
'Pollution',
'Recognition',
'Reputation',
'Shirt',
'Sir',
'Speaker',
'Stranger',
'Surgery',
'Sympathy',
'Tale',
'Throat',
'Trainer',
'Uncle',
'Youth',
'Time',
'Work',
'Film',
'Water',
'Money',
'Example',
'While',
'Business',
'Study',
'Game',
'Life',
'Form',
'Air',
'Day',
'Place',
'Number',
'Part',
'Field',
'Fish',
'Back',
'Process',
'Heat',
'Hand',
'Experience',
'Job',
'Book',
'End',
'Point',
'Type',
'Home',
'Economy',
'Value',
'Body',
'Market',
'Guide',
'Interest',
'State',
'Radio',
'Course',
'Company',
'Price',
'Size',
'Card',
'List',
'Mind',
'Trade',
'Line',
'Care',
'Group',
'Risk',
'Word',
'Fat',
'Force',
'Key',
'Light',
'Training',
'Name',
'School',
'Top',
'Amount',
'Level',
'Order',
'Practice',
'Research',
'Sense',
'Service',
'Piece',
'Web',
'Boss',
'Sport',
'Fun',
'House',
'Page',
'Term',
'Test',
'Answer',
'Sound',
'Focus',
'Matter',
'Kind',
'Soil',
'Board',
'Oil',
'Picture',
'Access',
'Garden',
'Range',
'Rate',
'Reason',
'Future',
'Site',
'Demand',
'Exercise',
'Image',
'Case',
'Cause',
'Coast',
'Action',
'Age',
'Bad',
'Boat',
'Record',
'Result',
'Section',
'Building',
'Mouse',
'Cash',
'Class',
'Nothing',
'Period',
'Plan',
'Store',
'Tax',
'Side',
'Subject',
'Space',
'Rule',
'Stock',
'Weather',
'Chance',
'Figure',
'Man',
'Model',
'Source',
'Beginning',
'Earth',
'Program',
'Chicken',
'Design',
'Feature',
'Head',
'Material',
'Purpose',
'Question',
'Rock',
'Salt',
'Act',
'Birth',
'Car',
'Dog',
'Object',
'Scale',
'Sun',
'Note',
'Profit',
'Rent',
'Speed',
'Style',
'War',
'Bank',
'Craft',
'Half',
'Inside',
'Outside',
'Standard',
'Bus',
'Exchange',
'Eye',
'Fire',
'Position',
'Pressure',
'Stress',
'Advantage',
'Benefit',
'Box',
'Frame',
'Issue',
'Step',
'Cycle',
'Face',
'Item',
'Metal',
'Paint',
'Review',
'Room',
'Screen',
'Structure',
'View',
'Account',
'Ball',
'Discipline',
'Medium',
'Share',
'Balance',
'Bit',
'Black',
'Bottom',
'Choice',
'Gift',
'Impact',
'Machine',
'Shape',
'Tool',
'Wind',
'Address',
'Average',
'Career',
'Culture',
'Morning',
'Pot',
'Sign',
'Table',
'Task',
'Condition',
'Contact',
'Credit',
'Egg',
'Hope',
'Ice',
'Network',
'North',
'Square',
'Attempt',
'Date',
'Effect',
'Link',
'Post',
'Star',
'Voice',
'Capital',
'Challenge',
'Friend',
'Self',
'Shot',
'Brush',
'Couple',
'Exit',
'Front',
'Function',
'Lack',
'Living',
'Plant',
'Plastic',
'Spot',
'Summer',
'Taste',
'Theme',
'Track',
'Wing',
'Brain',
'Button',
'Click',
'Desire',
'Foot',
'Gas',
'Influence',
'Notice',
'Rain',
'Wall',
'Base',
'Damage',
'Distance',
'Feeling',
'Pair',
'Savings',
'Staff',
'Sugar',
'Target',
'Text',
'Animal',
'Author',
'Budget',
'Discount',
'File',
'Ground',
'Lesson',
'Minute',
'Officer',
'Phase',
'Reference',
'Register',
'Sky',
'Stage',
'Stick',
'Title',
'Trouble',
'Bowl',
'Bridge',
'Campaign',
'Character',
'Club',
'Edge',
'Evidence',
'Fan',
'Letter',
'Lock',
'Maximum',
'Novel',
'Option',
'Pack',
'Park',
'Plenty',
'Quarter',
'Skin',
'Sort',
'Weight',
'Baby',
'Background',
'Carry',
'Dish',
'Factor',
'Fruit',
'Glass',
'Joint',
'Master',
'Muscle',
'Red',
'Strength',
'Traffic',
'Trip',
'Vegetable',
'Appeal',
'Chart',
'Gear',
'Ideal',
'Kitchen',
'Land',
'Log',
'Mother',
'Net',
'Party',
'Principle',
'Relative',
'Sale',
'Season',
'Signal',
'Spirit',
'Street',
'Tree',
'Wave',
'Belt',
'Bench',
'Commission',
'Copy',
'Drop',
'Minimum',
'Path',
'Progress',
'Project',
'Sea',
'South',
'Status',
'Stuff',
'Ticket',
'Tour',
'Angle',
'Blue',
'Breakfast',
'Confidence',
'Daughter',
'Degree',
'Doctor',
'Dot',
'Dream',
'Duty',
'Essay',
'Father',
'Fee',
'Finance',
'Hour',
'Juice',
'Luck',
'Milk',
'Mouth',
'Peace',
'Pipe',
'Stable',
'Storm',
'Substance',
'Team',
'Trick',
'Afternoon',
'Bat',
'Beach',
'Blank',
'Catch',
'Chain',
'Consideration',
'Cream',
'Crew',
'Detail',
'Gold',
'Interview',
'Kid',
'Mark',
'Mission',
'Pain',
'Pleasure',
'Score',
'Screw',
'Gratitude',
'Shop',
'Shower',
'Suit',
'Tone',
'Window',
'Agent',
'Band',
'Bath',
'Block',
'Bone',
'Calendar',
'Candidate',
'Cap',
'Coat',
'Contest',
'Corner',
'Court',
'Cup',
'District',
'Door',
'East',
'Finger',
'Garage',
'Guarantee',
'Hole',
'Hook',
'Implement',
'Layer',
'Lecture',
'Lie',
'Manner',
'Meeting',
'Nose',
'Parking',
'Partner',
'Profile',
'Rice',
'Routine',
'Schedule',
'Swimming',
'Telephone',
'Tip',
'Winter',
'Airline',
'Bag',
'Battle',
'Bed',
'Bill',
'Bother',
'Cake',
'Code',
'Curve',
'Designer',
'Dimension',
'Dress',
'Ease',
'Emergency',
'Evening',
'Extension',
'Farm',
'Fight',
'Gap',
'Grade',
'Holiday',
'Horror',
'Horse',
'Host',
'Husband',
'Loan',
'Mistake',
'Mountain',
'Nail',
'Noise',
'Occasion',
'Package',
'Patient',
'Pause',
'Phrase',
'Proof',
'Race',
'Relief',
'Sand',
'Sentence',
'Shoulder',
'Smoke',
'Stomach',
'String',
'Tourist',
'Towel',
'Vacation',
'West',
'Wheel',
'Wine',
'Arm',
'Aside',
'Associate',
'Bet',
'Blow',
'Border',
'Branch',
'Breast',
'Brother',
'Buddy',
'Bunch',
'Chip',
'Coach',
'Cross',
'Document',
'Draft',
'Dust',
'Expert',
'Floor',
'God',
'Golf',
'Habit',
'Iron',
'Judge',
'Knife',
'Landscape',
'League',
'Mail',
'Mess',
'Native',
'Opening',
'Parent',
'Pattern',
'Pin',
'Pool',
'Pound',
'Request',
'Salary',
'Shame',
'Shelter',
'Shoe',
'Silver',
'Tackle',
'Tank',
'Trust',
'Assist',
'Bake',
'Bar',
'Bell',
'Bike',
'Blame',
'Boy',
'Brick',
'Chair',
'Closet',
'Clue',
'Collar',
'Comment',
'Conference',
'Devil',
'Diet',
'Fear',
'Fuel',
'Glove',
'Jacket',
'Lunch',
'Monitor',
'Mortgage',
'Nurse',
'Pace',
'Panic',
'Peak',
'Plane',
'Reward',
'Row',
'Sandwich',
'Shock',
'Spite',
'Spray',
'Surprise',
'Till',
'Transition',
'Weekend',
'Welcome',
'Yard',
'Alarm',
'Bend',
'Bicycle',
'Bite',
'Blind',
'Bottle',
'Cable',
'Candle',
'Clerk',
'Cloud',
'Concert',
'Counter',
'Flower',
'Grandfather',
'Harm',
'Knee',
'Lawyer',
'Leather',
'Load',
'Mirror',
'Neck',
'Pension',
'Plate',
'Purple',
'Ruin',
'Ship',
'Skirt',
'Slice',
'Snow',
'Specialist',
'Stroke',
'Switch',
'Trash',
'Tune',
'Zone',
'Anger',
'Award',
'Bid',
'Bitter',
'Boot',
'Bug',
'Camp',
'Candy',
'Carpet',
'Cat',
'Champion',
'Channel',
'Clock',
'Comfort',
'Cow',
'Crack',
'Engineer',
'Entrance',
'Fault',
'Grass',
'Guy',
'Hell',
'Highlight',
'Incident',
'Island',
'Joke',
'Jury',
'Leg',
'Lip',
'Mate',
'Motor',
'Nerve',
'Passage',
'Pen',
'Pride',
'Priest',
'Prize',
'Promise',
'Resident',
'Resort',
'Ring',
'Roof',
'Rope',
'Sail',
'Scheme',
'Script',
'Sock',
'Station',
'Toe',
'Tower',
'Truck',
'Witness',
'Asparagus',
'You',
'It',
'Can',
'Will',
'If',
'One',
'Many',
'Most',
'Other',
'Use',
'Make',
'Good',
'Look',
'Help',
'Go',
'Great',
'Being',
'Few',
'Might',
'Still',
'Public',
'Read',
'Keep',
'Start',
'Give',
'Human',
'Local',
'General',
'She',
'Specific',
'Long',
'Play',
'Feel',
'High',
'Tonight',
'Put',
'Common',
'Set',
'Change',
'Simple',
'Past',
'Big',
'Possible',
'Particular',
'Today',
'Major',
'Personal',
'Current',
'National',
'Cut',
'Natural',
'Physical',
'Show',
'Try',
'Check',
'Second',
'Call',
'Move',
'Pay',
'Let',
'Increase',
'Single',
'Individual',
'Turn',
'Ask',
'Buy',
'Guard',
'Hold',
'Main',
'Offer',
'Potential',
'Professional',
'International',
'Travel',
'Cook',
'Alternative',
'Following',
'Special',
'Working',
'Whole',
'Dance',
'Excuse',
'Cold',
'Commercial',
'Low',
'Purchase',
'Deal',
'Primary',
'Worth',
'Fall',
'Necessary',
'Positive',
'Produce',
'Search',
'Present',
'Spend',
'Talk',
'Creative',
'Tell',
'Cost',
'Drive',
'Green',
'Support',
'Glad',
'Remove',
'Return',
'Run',
'Complex',
'Due',
'Effective',
'Middle',
'Regular',
'Reserve',
'Independent',
'Leave',
'Original',
'Reach',
'Rest',
'Serve',
'Watch',
'Beautiful',
'Charge',
'Active',
'Break',
'Negative',
'Safe',
'Stay',
'Visit',
'Visual',
'Affect',
'Cover',
'Report',
'Rise',
'Walk',
'White',
'Beyond',
'Junior',
'Pick',
'Unique',
'Anything',
'Classic',
'Final',
'Lift',
'Mix',
'Private',
'Stop',
'Teach',
'Western',
'Concern',
'Familiar',
'Fly',
'Official',
'Broad',
'Comfortable',
'Gain',
'Maybe',
'Rich',
'Save',
'Stand',
'Young',
'Heavy',
'Hello',
'Lead',
'Listen',
'Valuable',
'Worry',
'Handle',
'Leading',
'Meet',
'Release',
'Sell',
'Finish',
'Normal',
'Press',
'Ride',
'Secret',
'Spread',
'Spring',
'Tough',
'Wait',
'Brown',
'Deep',
'Display',
'Flow',
'Hit',
'Objective',
'Shoot',
'Touch',
'Cancel',
'Chemical',
'Cry',
'Dump',
'Extreme',
'Push',
'Conflict',
'Eat',
'Fill',
'Formal',
'Jump',
'Kick',
'Opposite',
'Pass',
'Pitch',
'Remote',
'Total',
'Treat',
'Vast',
'Abuse',
'Beat',
'Burn',
'Deposit',
'Print',
'Raise',
'Sleep',
'Somewhere',
'Advance',
'Anywhere',
'Consist',
'Dark',
'Double',
'Draw',
'Equal',
'Fix',
'Hire',
'Internal',
'Join',
'Kill',
'Sensitive',
'Tap',
'Win',
'Attack',
'Claim',
'Constant',
'Drag',
'Drink',
'Guess',
'Minor',
'Pull',
'Raw',
'Soft',
'Solid',
'Wear',
'Weird',
'Wonder',
'Annual',
'Count',
'Dead',
'Doubt',
'Feed',
'Forever',
'Impress',
'Nobody',
'Repeat',
'Round',
'Sing',
'Slide',
'Strip',
'Whereas',
'Wish',
'Combine',
'Command',
'Dig',
'Divide',
'Equivalent',
'Hang',
'Hunt',
'Initial',
'March',
'Mention',
'Spiritual',
'Survey',
'Tie',
'Adult',
'Brief',
'Crazy',
'Escape',
'Gather',
'Hate',
'Prior',
'Repair',
'Rough',
'Sad',
'Scratch',
'Sick',
'Strike',
'Employ',
'External',
'Hurt',
'Illegal',
'Laugh',
'Lay',
'Mobile',
'Nasty',
'Ordinary',
'Respond',
'Royal',
'Senior',
'Split',
'Strain',
'Struggle',
'Swim',
'Train',
'Upper',
'Wash',
'Yellow',
'Convert',
'Crash',
'Dependent',
'Fold',
'Funny',
'Grab',
'Hide',
'Miss',
'Permit',
'Quote',
'Recover',
'Resolve',
'Roll',
'Sink',
'Slip',
'Spare',
'Suspect',
'Sweet',
'Swing',
'Twist',
'Upstairs',
'Usual',
'Abroad',
'Brave',
'Calm',
'Concentrate',
'Estimate',
'Grand',
'Male',
'Mine',
'Prompt',
'Quiet',
'Refuse',
'Regret',
'Reveal',
'Rush',
'Shake',
'Shift',
'Shine',
'Steal',
'Suck',
'Surround',
'Anybody',
'Bear',
'Brilliant',
'Dare',
'Dear',
'Delay',
'Drunk',
'Female',
'Hurry',
'Inevitable',
'Invite',
'Kiss',
'Neat',
'Pop',
'Punch',
'Quit',
'Reply',
'Representative',
'Resist',
'Rip',
'Rub',
'Silly',
'Smile',
'Spell',
'Stretch',
'Stupid',
'Tear',
'Temporary',
'Tomorrow',
'Wake',
'Wrap',
'Yesterday',
]
07070100000067000081A400000000000000000000000166846B9200002393000000000000000000000000000000000000001800000000awx-24.6.1/awxkit/ws.pyimport threading
import logging
import atexit
import json
import ssl
import datetime
from queue import Queue, Empty
from urllib.parse import urlparse
from awxkit.config import config
log = logging.getLogger(__name__)
class WSClientException(Exception):
pass
changed = 'changed'
limit_reached = 'limit_reached'
status_changed = 'status_changed'
summary = 'summary'
class WSClient(object):
"""Provides a basic means of testing pub/sub notifications with payloads similar to
'groups': {'jobs': ['status_changed', 'summary'],
'schedules': ['changed'],
'ad_hoc_command_events': [ids...],
'job_events': [ids...],
'workflow_events': [ids...],
'project_update_events': [ids...],
'inventory_update_events': [ids...],
'system_job_events': [ids...],
'control': ['limit_reached']}
e.x:
```
ws = WSClient(token, port=8013, secure=False).connect()
ws.job_details()
... # launch job
job_messages = [msg for msg in ws]
ws.ad_hoc_stdout()
... # launch ad hoc command
ad_hoc_messages = [msg for msg in ws]
ws.close()
```
"""
# Subscription group types
def __init__(
self,
token=None,
hostname='',
port=443,
secure=True,
ws_suffix='websocket/',
session_id=None,
csrftoken=None,
add_received_time=False,
session_cookie_name='awx_sessionid',
):
# delay this import, because this is an optional dependency
import websocket
if not hostname:
result = urlparse(config.base_url)
secure = result.scheme == 'https'
port = result.port
if port is None:
port = 80
if secure:
port = 443
# should we be adding result.path here?
hostname = result.hostname
self.port = port
self.suffix = ws_suffix
self._use_ssl = secure
self.hostname = hostname
self.token = token
self.session_id = session_id
self.csrftoken = csrftoken
self._recv_queue = Queue()
self._ws_closed = False
self._ws_connected_flag = threading.Event()
if self.token is not None:
auth_cookie = 'token="{0.token}";'.format(self)
elif self.session_id is not None:
auth_cookie = '{1}="{0.session_id}"'.format(self, session_cookie_name)
if self.csrftoken:
auth_cookie += ';csrftoken={0.csrftoken}'.format(self)
else:
auth_cookie = ''
pref = 'wss://' if self._use_ssl else 'ws://'
url = '{0}{1.hostname}:{1.port}/{1.suffix}'.format(pref, self)
self.ws = websocket.WebSocketApp(
url, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close, cookie=auth_cookie
)
self._message_cache = []
self._should_subscribe_to_pending_job = False
self._pending_unsubscribe = threading.Event()
self._add_received_time = add_received_time
def connect(self):
wst = threading.Thread(target=self._ws_run_forever, args=(self.ws, {"cert_reqs": ssl.CERT_NONE}))
wst.daemon = True
wst.start()
atexit.register(self.close)
if not self._ws_connected_flag.wait(20):
raise WSClientException('Failed to establish channel connection w/ AWX.')
return self
def close(self):
log.info('close method was called, but ignoring')
if not self._ws_closed:
log.info('Closing websocket connection.')
self.ws.close()
def job_details(self, *job_ids):
"""subscribes to job status, summary, and, for the specified ids, job events"""
self.subscribe(jobs=[status_changed, summary], job_events=list(job_ids))
def pending_job_details(self):
"""subscribes to job status and summary, with responsive
job event subscription for an id provided by AWX
"""
self.subscribe_to_pending_events('job_events', [status_changed, summary])
def status_changes(self):
self.subscribe(jobs=[status_changed])
def job_stdout(self, *job_ids):
self.subscribe(jobs=[status_changed], job_events=list(job_ids))
def pending_job_stdout(self):
self.subscribe_to_pending_events('job_events')
# mirror page behavior
def ad_hoc_stdout(self, *ahc_ids):
self.subscribe(jobs=[status_changed], ad_hoc_command_events=list(ahc_ids))
def pending_ad_hoc_stdout(self):
self.subscribe_to_pending_events('ad_hoc_command_events')
def project_update_stdout(self, *project_update_ids):
self.subscribe(jobs=[status_changed], project_update_events=list(project_update_ids))
def pending_project_update_stdout(self):
self.subscribe_to_pending_events('project_update_events')
def inventory_update_stdout(self, *inventory_update_ids):
self.subscribe(jobs=[status_changed], inventory_update_events=list(inventory_update_ids))
def pending_inventory_update_stdout(self):
self.subscribe_to_pending_events('inventory_update_events')
def workflow_events(self, *wfjt_ids):
self.subscribe(jobs=[status_changed], workflow_events=list(wfjt_ids))
def pending_workflow_events(self):
self.subscribe_to_pending_events('workflow_events')
def system_job_events(self, *system_job_ids):
self.subscribe(jobs=[status_changed], system_job_events=list(system_job_ids))
def pending_system_job_events(self):
self.subscribe_to_pending_events('system_job_events')
def subscribe_to_pending_events(self, events, jobs=[status_changed]):
self._should_subscribe_to_pending_job = dict(jobs=jobs, events=events)
self.subscribe(jobs=jobs)
# mirror page behavior
def jobs_list(self):
self.subscribe(jobs=[status_changed, summary], schedules=[changed])
# mirror page behavior
def dashboard(self):
self.subscribe(jobs=[status_changed])
def subscribe(self, **groups):
"""Sends a subscription request for the specified channel groups.
```
ws.subscribe(jobs=[ws.status_changed, ws.summary],
job_events=[1,2,3])
```
"""
self._subscribe(groups=groups)
def _subscribe(self, **payload):
payload['xrftoken'] = self.csrftoken
self._send(json.dumps(payload))
def unsubscribe(self, wait=True, timeout=10):
if wait:
# Other unnsubscribe events could have caused the edge to trigger.
# This way the _next_ event will trigger our waiting.
self._pending_unsubscribe.clear()
self._send(json.dumps(dict(groups={}, xrftoken=self.csrftoken)))
if not self._pending_unsubscribe.wait(timeout):
raise RuntimeError("Failed while waiting on unsubscribe reply because timeout of {} seconds was reached.".format(timeout))
else:
self._send(json.dumps(dict(groups={}, xrftoken=self.csrftoken)))
def _on_message(self, message):
message = json.loads(message)
log.debug('received message: {}'.format(message))
if self._add_received_time:
message['received_time'] = datetime.datetime.utcnow()
if all([message.get('group_name') == 'jobs', message.get('status') == 'pending', message.get('unified_job_id'), self._should_subscribe_to_pending_job]):
if bool(message.get('project_id')) == (self._should_subscribe_to_pending_job['events'] == 'project_update_events'):
self._update_subscription(message['unified_job_id'])
ret = self._recv_queue.put(message)
# unsubscribe acknowledgement
if 'groups_current' in message:
self._pending_unsubscribe.set()
return ret
def _update_subscription(self, job_id):
subscription = dict(jobs=self._should_subscribe_to_pending_job['jobs'])
events = self._should_subscribe_to_pending_job['events']
subscription[events] = [job_id]
self.subscribe(**subscription)
self._should_subscribe_to_pending_job = False
def _on_open(self):
self._ws_connected_flag.set()
def _on_error(self, error):
log.info('Error received: {}'.format(error))
def _on_close(self):
log.info('Successfully closed ws.')
self._ws_closed = True
def _ws_run_forever(self, sockopt=None, sslopt=None):
self.ws.run_forever(sslopt=sslopt)
log.debug('ws.run_forever finished')
def _recv(self, wait=False, timeout=10):
try:
msg = self._recv_queue.get(wait, timeout)
except Empty:
return None
return msg
def _send(self, data):
self.ws.send(data)
log.debug('successfully sent {}'.format(data))
def __iter__(self):
while True:
val = self._recv()
if not val:
return
yield val
07070100000068000081A400000000000000000000000166846B9200000C38000000000000000000000000000000000000001F00000000awx-24.6.1/awxkit/yaml_file.pyimport os
import yaml
import glob
import logging
log = logging.getLogger(__name__)
file_pattern_cache = {}
file_path_cache = {}
class Loader(yaml.SafeLoader):
def __init__(self, stream):
self._root = os.path.split(stream.name)[0]
super(Loader, self).__init__(stream)
Loader.add_constructor('!include', Loader.include)
Loader.add_constructor('!import', Loader.include)
def include(self, node):
if isinstance(node, yaml.ScalarNode):
return self.extractFile(self.construct_scalar(node))
elif isinstance(node, yaml.SequenceNode):
result = []
for filename in self.construct_sequence(node):
result += self.extractFile(filename)
return result
elif isinstance(node, yaml.MappingNode):
result = {}
for k, v in self.construct_mapping(node).items():
result[k] = self.extractFile(v)[k]
return result
else:
log.error("unrecognised node type in !include statement")
raise yaml.constructor.ConstructorError
def extractFile(self, filename):
file_pattern = os.path.join(self._root, filename)
log.debug('Will attempt to extract schema from: {0}'.format(file_pattern))
if file_pattern in file_pattern_cache:
log.debug('File pattern cache hit: {0}'.format(file_pattern))
return file_pattern_cache[file_pattern]
data = dict()
for file_path in glob.glob(file_pattern):
file_path = os.path.abspath(file_path)
if file_path in file_path_cache:
log.debug('Schema cache hit: {0}'.format(file_path))
path_data = file_path_cache[file_path]
else:
log.debug('Loading schema from {0}'.format(file_path))
with open(file_path, 'r') as f:
path_data = yaml.load(f, Loader)
file_path_cache[file_path] = path_data
data.update(path_data)
file_pattern_cache[file_pattern] = data
return data
def load_file(filename):
"""Loads a YAML file from the given filename.
If the filename is omitted or None, attempts will be made to load it from
its normal location in the parent of the utils directory.
The awx_data dict loaded with this method supports value randomization,
thanks to the RandomizeValues class. See that class for possible options
Example usage in data.yaml (quotes are important!):
top_level:
list:
- "{random_str}"
- "{random_int}"
- "{random_uuid}"
random_thing: "{random_string:24}"
"""
from py.path import local
if filename is None:
this_file = os.path.abspath(__file__)
path = local(this_file).new(basename='../data.yaml')
else:
path = local(filename)
if path.check():
with open(path, 'r') as fp:
# FIXME - support load_all()
return yaml.load(fp, Loader=Loader)
else:
msg = 'Unable to load data file at %s' % path
raise Exception(msg)
07070100000069000081A400000000000000000000000166846B9200000002000000000000000000000000000000000000001C00000000awx-24.6.1/requirements.txt.
0707010000006A000081A400000000000000000000000166846B9200000D2A000000000000000000000000000000000000001400000000awx-24.6.1/setup.pyimport os
import glob
import shutil
from setuptools import setup, find_packages, Command
def use_scm_version():
return False if version_file() else True
def get_version_from_file():
vf = version_file()
if vf:
with open(vf, 'r') as file:
return file.read().strip()
def version_file():
current_dir = os.path.dirname(os.path.abspath(__file__))
version_file = os.path.join(current_dir, 'VERSION')
if os.path.exists(version_file):
return version_file
def setup_requires():
if version_file():
return []
else:
return ['setuptools_scm']
extra_setup_args = {}
if not version_file():
extra_setup_args.update(dict(use_scm_version=dict(root="..", relative_to=__file__), setup_requires=setup_requires()))
class CleanCommand(Command):
description = "Custom clean command that forcefully removes dist/build directories"
user_options = []
def initialize_options(self):
self.cwd = None
def finalize_options(self):
self.cwd = os.getcwd()
def run(self):
assert os.getcwd() == self.cwd, 'Must be in package root: %s' % self.cwd
# List of things to remove
rm_list = list()
# Find any .pyc files or __pycache__ dirs
for root, dirs, files in os.walk(self.cwd, topdown=False):
for fname in files:
if fname.endswith('.pyc') and os.path.isfile(os.path.join(root, fname)):
rm_list.append(os.path.join(root, fname))
if root.endswith('__pycache__'):
rm_list.append(root)
# Find egg's
for egg_dir in glob.glob('*.egg') + glob.glob('*egg-info'):
rm_list.append(egg_dir)
# Zap!
for rm in rm_list:
if self.verbose:
print("Removing '%s'" % rm)
if os.path.isdir(rm):
if not self.dry_run:
shutil.rmtree(rm)
else:
if not self.dry_run:
os.remove(rm)
setup(
name='awxkit',
version=get_version_from_file(),
description='The official command line interface for Ansible AWX',
author='Red Hat, Inc.',
author_email='info@ansible.com',
url='https://github.com/ansible/awx',
packages=find_packages(exclude=['test']),
cmdclass={
'clean': CleanCommand,
},
include_package_data=True,
install_requires=[
'PyYAML',
'requests',
'setuptools',
],
python_requires=">=3.8",
extras_require={'formatting': ['jq'], 'websockets': ['websocket-client==0.57.0'], 'crypto': ['cryptography']},
license='Apache 2.0',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: Apache Software License',
'Operating System :: MacOS :: MacOS X',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Topic :: System :: Software Distribution',
'Topic :: System :: Systems Administration',
],
entry_points={'console_scripts': ['akit=awxkit.scripts.basic_session:load_interactive', 'awx=awxkit.cli:run']},
**extra_setup_args,
)
0707010000006B000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001000000000awx-24.6.1/test0707010000006C000081A400000000000000000000000166846B9200000000000000000000000000000000000000000000001C00000000awx-24.6.1/test/__init__.py0707010000006D000041ED00000000000000000000000266846B9200000000000000000000000000000000000000000000001400000000awx-24.6.1/test/cli0707010000006E000081A400000000000000000000000166846B9200000571000000000000000000000000000000000000002300000000awx-24.6.1/test/cli/test_client.pyimport pytest
from requests.exceptions import ConnectionError
from awxkit.cli import run, CLI
class MockedCLI(CLI):
def fetch_version_root(self):
pass
@property
def v2(self):
return MockedCLI()
@property
def json(self):
return {'users': None}
@pytest.mark.parametrize('help_param', ['-h', '--help'])
def test_help(capfd, help_param):
with pytest.raises(SystemExit):
run(['awx {}'.format(help_param)])
out, err = capfd.readouterr()
assert "usage:" in out
for snippet in ('--conf.host https://example.awx.org]', '-v, --verbose'):
assert snippet in out
def test_connection_error(capfd):
cli = CLI()
cli.parse_args(['awx'])
with pytest.raises(ConnectionError):
cli.connect()
@pytest.mark.parametrize('resource', ['', 'invalid'])
def test_list_resources(capfd, resource):
# if a valid resource isn't specified, print --help
cli = MockedCLI()
cli.parse_args(['awx {}'.format(resource)])
cli.connect()
try:
cli.parse_resource()
out, err = capfd.readouterr()
except SystemExit:
# python2 argparse raises SystemExit for invalid/missing required args,
# py3 doesn't
_, out = capfd.readouterr()
assert "usage:" in out
for snippet in ('--conf.host https://example.awx.org]', '-v, --verbose'):
assert snippet in out
0707010000006F000081A400000000000000000000000166846B9200000DE2000000000000000000000000000000000000002300000000awx-24.6.1/test/cli/test_config.pyimport os
import json
import pytest
from requests.exceptions import ConnectionError
from awxkit.cli import CLI
from awxkit import config
def test_host_from_environment():
cli = CLI()
cli.parse_args(['awx'], env={'CONTROLLER_HOST': 'https://xyz.local'})
with pytest.raises(ConnectionError):
cli.connect()
assert config.base_url == 'https://xyz.local'
def test_host_from_argv():
cli = CLI()
cli.parse_args(['awx', '--conf.host', 'https://xyz.local'])
with pytest.raises(ConnectionError):
cli.connect()
assert config.base_url == 'https://xyz.local'
def test_username_and_password_from_environment():
cli = CLI()
cli.parse_args(['awx'], env={'CONTROLLER_USERNAME': 'mary', 'CONTROLLER_PASSWORD': 'secret'})
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
def test_username_and_password_argv():
cli = CLI()
cli.parse_args(['awx', '--conf.username', 'mary', '--conf.password', 'secret'])
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
def test_config_precedence():
cli = CLI()
cli.parse_args(['awx', '--conf.username', 'mary', '--conf.password', 'secret'], env={'CONTROLLER_USERNAME': 'IGNORE', 'CONTROLLER_PASSWORD': 'IGNORE'})
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
def test_config_file_precedence():
"""Ignores AWXKIT_CREDENTIAL_FILE if cli args are set"""
os.makedirs('/tmp/awx-test/', exist_ok=True)
with open('/tmp/awx-test/config.json', 'w') as f:
json.dump({'default': {'username': 'IGNORE', 'password': 'IGNORE'}}, f)
cli = CLI()
cli.parse_args(
['awx', '--conf.username', 'mary', '--conf.password', 'secret'],
env={
'AWXKIT_CREDENTIAL_FILE': '/tmp/awx-test/config.json',
},
)
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
def test_config_file_precedence_2():
"""Ignores AWXKIT_CREDENTIAL_FILE if TOWER_* vars are set."""
os.makedirs('/tmp/awx-test/', exist_ok=True)
with open('/tmp/awx-test/config.json', 'w') as f:
json.dump({'default': {'username': 'IGNORE', 'password': 'IGNORE'}}, f)
cli = CLI()
cli.parse_args(['awx'], env={'AWXKIT_CREDENTIAL_FILE': '/tmp/awx-test/config.json', 'TOWER_USERNAME': 'mary', 'TOWER_PASSWORD': 'secret'})
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
def test_config_file():
"""Reads username and password from AWXKIT_CREDENTIAL_FILE."""
os.makedirs('/tmp/awx-test/', exist_ok=True)
with open('/tmp/awx-test/config.json', 'w') as f:
json.dump({'default': {'username': 'mary', 'password': 'secret'}}, f)
cli = CLI()
cli.parse_args(
['awx'],
env={
'AWXKIT_CREDENTIAL_FILE': '/tmp/awx-test/config.json',
},
)
with pytest.raises(ConnectionError):
cli.connect()
assert config.credentials.default.username == 'mary'
assert config.credentials.default.password == 'secret'
07070100000070000081A400000000000000000000000166846B92000006B9000000000000000000000000000000000000002300000000awx-24.6.1/test/cli/test_format.pyimport io
import json
import yaml
from awxkit.api.pages import Page
from awxkit.api.pages.users import Users
from awxkit.cli import CLI
from awxkit.cli.format import format_response
from awxkit.cli.resource import Import
def test_json_empty_list():
page = Page.from_json({'results': []})
formatted = format_response(page)
assert json.loads(formatted) == {'results': []}
def test_yaml_empty_list():
page = Page.from_json({'results': []})
formatted = format_response(page, fmt='yaml')
assert yaml.safe_load(formatted) == {'results': []}
def test_json_list():
users = {
'results': [
{'username': 'betty'},
{'username': 'tom'},
{'username': 'anne'},
]
}
page = Users.from_json(users)
formatted = format_response(page)
assert json.loads(formatted) == users
def test_yaml_list():
users = {
'results': [
{'username': 'betty'},
{'username': 'tom'},
{'username': 'anne'},
]
}
page = Users.from_json(users)
formatted = format_response(page, fmt='yaml')
assert yaml.safe_load(formatted) == users
def test_yaml_import():
class MockedV2:
def import_assets(self, data):
self._parsed_data = data
def _dummy_authenticate():
pass
yaml_fd = io.StringIO(
"""
workflow_job_templates:
- name: Workflow1
"""
)
yaml_fd.name = 'file.yaml'
cli = CLI(stdin=yaml_fd)
cli.parse_args(['--conf.format', 'yaml'])
cli.v2 = MockedV2()
cli.authenticate = _dummy_authenticate
Import().handle(cli, None)
assert cli.v2._parsed_data['workflow_job_templates'][0]['name']
07070100000071000081A400000000000000000000000166846B9200001AD8000000000000000000000000000000000000002400000000awx-24.6.1/test/cli/test_options.pyimport argparse
import unittest
from io import StringIO
from awxkit.api.pages import Page
from awxkit.cli.options import ResourceOptionsParser
class ResourceOptionsParser(ResourceOptionsParser):
def get_allowed_options(self):
self.allowed_options = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
class OptionsPage(Page):
def options(self):
return self
def endswith(self, v):
return self.endpoint.endswith(v)
def __getitem__(self, k):
return {
'GET': {},
'POST': {},
'PUT': {},
}
class TestOptions(unittest.TestCase):
def setUp(self):
_parser = argparse.ArgumentParser()
self.parser = _parser.add_subparsers(help='action')
def test_list(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {},
}
}
)
ResourceOptionsParser(None, page, 'users', self.parser)
assert 'list' in self.parser.choices
def test_list_filtering(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {'first_name': {'type': 'string'}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('list', 'POST')
assert 'list' in self.parser.choices
out = StringIO()
self.parser.choices['list'].print_help(out)
assert '--first_name TEXT' in out.getvalue()
def test_list_not_filterable(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {'middle_name': {'type': 'string', 'filterable': False}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('list', 'POST')
assert 'list' in self.parser.choices
out = StringIO()
self.parser.choices['list'].print_help(out)
assert '--middle_name' not in out.getvalue()
def test_creation_optional_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {
'first_name': {
'type': 'string',
'help_text': 'Please specify your first name',
}
},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--first_name TEXT Please specify your first name' in out.getvalue()
def test_creation_required_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'username': {'type': 'string', 'help_text': 'Please specify a username', 'required': True}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--username TEXT Please specify a username'
def test_integer_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'max_hosts': {'type': 'integer'}},
}
}
)
options = ResourceOptionsParser(None, page, 'organizations', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--max_hosts INTEGER' in out.getvalue()
def test_boolean_argument(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {'diff_mode': {'type': 'boolean'}},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--diff_mode BOOLEAN' in out.getvalue()
def test_choices(self):
page = OptionsPage.from_json(
{
'actions': {
'POST': {
'verbosity': {
'type': 'integer',
'choices': [
(0, '0 (Normal)'),
(1, '1 (Verbose)'),
(2, '2 (More Verbose)'),
(3, '3 (Debug)'),
(4, '4 (Connection Debug)'),
(5, '5 (WinRM Debug)'),
],
}
},
}
}
)
options = ResourceOptionsParser(None, page, 'users', self.parser)
options.build_query_arguments('create', 'POST')
assert 'create' in self.parser.choices
out = StringIO()
self.parser.choices['create'].print_help(out)
assert '--verbosity {0,1,2,3,4,5}' in out.getvalue()
def test_actions_with_primary_key(self):
page = OptionsPage.from_json({'actions': {'GET': {}, 'POST': {}}})
ResourceOptionsParser(None, page, 'jobs', self.parser)
for method in ('get', 'modify', 'delete'):
assert method in self.parser.choices
out = StringIO()
self.parser.choices[method].print_help(out)
assert 'positional arguments:\n id' in out.getvalue()
class TestSettingsOptions(unittest.TestCase):
def setUp(self):
_parser = argparse.ArgumentParser()
self.parser = _parser.add_subparsers(help='action')
def test_list(self):
page = OptionsPage.from_json(
{
'actions': {
'GET': {},
'POST': {},
'PUT': {},
}
}
)
page.endpoint = '/settings/all/'
ResourceOptionsParser(None, page, 'settings', self.parser)
assert 'list' in self.parser.choices
assert 'modify' in self.parser.choices
out = StringIO()
self.parser.choices['modify'].print_help(out)
assert 'modify [-h] key value' in out.getvalue()
07070100000072000081A400000000000000000000000166846B9200000000000000000000000000000000000000000000001B00000000awx-24.6.1/test/pytest.ini07070100000073000081A400000000000000000000000166846B92000007E6000000000000000000000000000000000000002400000000awx-24.6.1/test/test_credentials.pyimport pytest
from awxkit.api.pages import credentials
from awxkit.utils import PseudoNamespace
def set_config_cred_to_desired(config, location):
split = location.split('.')
config_ref = config.credentials
for _location in split[:-1]:
setattr(config_ref, _location, PseudoNamespace())
config_ref = config_ref[_location]
setattr(config_ref, split[-1], 'desired')
class MockCredentialType(object):
def __init__(self, name, kind, managed=True):
self.name = name
self.kind = kind
self.managed = managed
@pytest.mark.parametrize(
'field, kind, config_cred, desired_field, desired_value',
[
('field', 'ssh', PseudoNamespace(field=123), 'field', 123),
('subscription', 'azure', PseudoNamespace(subscription_id=123), 'subscription', 123),
('project_id', 'gce', PseudoNamespace(project=123), 'project', 123),
('authorize_password', 'net', PseudoNamespace(authorize=123), 'authorize_password', 123),
],
)
def test_get_payload_field_and_value_from_config_cred(field, kind, config_cred, desired_field, desired_value):
ret_field, ret_val = credentials.get_payload_field_and_value_from_kwargs_or_config_cred(field, kind, {}, config_cred)
assert ret_field == desired_field
assert ret_val == desired_value
@pytest.mark.parametrize(
'field, kind, kwargs, desired_field, desired_value',
[
('field', 'ssh', dict(field=123), 'field', 123),
('subscription', 'azure', dict(subscription=123), 'subscription', 123),
('project_id', 'gce', dict(project_id=123), 'project', 123),
('authorize_password', 'net', dict(authorize_password=123), 'authorize_password', 123),
],
)
def test_get_payload_field_and_value_from_kwarg(field, kind, kwargs, desired_field, desired_value):
ret_field, ret_val = credentials.get_payload_field_and_value_from_kwargs_or_config_cred(field, kind, kwargs, PseudoNamespace())
assert ret_field == desired_field
assert ret_val == desired_value
07070100000074000081A400000000000000000000000166846B92000055B7000000000000000000000000000000000000002C00000000awx-24.6.1/test/test_dependency_resolver.pyimport pytest
from awxkit.utils import filter_by_class
from awxkit.utils.toposort import CircularDependencyError
from awxkit.api.mixins import has_create
class MockHasCreate(has_create.HasCreate):
connection = None
def __str__(self):
return "instance of {0.__class__.__name__} ({1})".format(self, hex(id(self)))
def __init__(self, *a, **kw):
self.cleaned = False
super(MockHasCreate, self).__init__()
def silent_cleanup(self):
self.cleaned = True
class A(MockHasCreate):
def create(self, **kw):
return self
class B(MockHasCreate):
optional_dependencies = [A]
def create(self, a=None, **kw):
self.create_and_update_dependencies(*filter_by_class((a, A)))
return self
class C(MockHasCreate):
dependencies = [A, B]
def create(self, a=A, b=B, **kw):
self.create_and_update_dependencies(b, a)
return self
class D(MockHasCreate):
dependencies = [A]
optional_dependencies = [B]
def create(self, a=A, b=None, **kw):
self.create_and_update_dependencies(*filter_by_class((a, A), (b, B)))
return self
class E(MockHasCreate):
dependencies = [D, C]
def create(self, c=C, d=D, **kw):
self.create_and_update_dependencies(d, c)
return self
class F(MockHasCreate):
dependencies = [B]
optional_dependencies = [E]
def create(self, b=B, e=None, **kw):
self.create_and_update_dependencies(*filter_by_class((b, B), (e, E)))
return self
class G(MockHasCreate):
dependencies = [D]
optional_dependencies = [F, E]
def create(self, d=D, f=None, e=None, **kw):
self.create_and_update_dependencies(*filter_by_class((d, D), (f, F), (e, E)))
return self
class H(MockHasCreate):
optional_dependencies = [E, A]
def create(self, a=None, e=None, **kw):
self.create_and_update_dependencies(*filter_by_class((a, A), (e, E)))
return self
class MultipleWordClassName(MockHasCreate):
def create(self, **kw):
return self
class AnotherMultipleWordClassName(MockHasCreate):
optional_dependencies = [MultipleWordClassName]
def create(self, multiple_word_class_name=None, **kw):
self.create_and_update_dependencies(*filter_by_class((multiple_word_class_name, MultipleWordClassName)))
return self
def test_dependency_graph_single_page():
"""confirms that `dependency_graph(Base)` will return a dependency graph
consisting of only dependencies and dependencies of dependencies (if any)
"""
desired = {}
desired[G] = set([D])
desired[D] = set([A])
desired[A] = set()
assert has_create.dependency_graph(G) == desired
def test_dependency_graph_page_with_optional():
"""confirms that `dependency_graph(Base, OptionalBase)` will return a dependency
graph consisting of only dependencies and dependencies of dependencies (if any)
with the exception that the OptionalBase and its dependencies are included as well.
"""
desired = {}
desired[G] = set([D])
desired[E] = set([D, C])
desired[C] = set([A, B])
desired[D] = set([A])
desired[B] = set()
desired[A] = set()
assert has_create.dependency_graph(G, E) == desired
def test_dependency_graph_page_with_additionals():
"""confirms that `dependency_graph(Base, AdditionalBaseOne, AdditionalBaseTwo)`
will return a dependency graph consisting of only dependencies and dependencies
of dependencies (if any) with the exception that the AdditionalBases
are treated as a dependencies of Base (when they aren't) and their dependencies
are included as well.
"""
desired = {}
desired[E] = set([D, C])
desired[D] = set([A])
desired[C] = set([A, B])
desired[F] = set([B])
desired[G] = set([D])
desired[A] = set()
desired[B] = set()
assert has_create.dependency_graph(E, F, G) == desired
def test_optional_dependency_graph_single_page():
"""confirms that has_create._optional_dependency_graph(Base) returns a complete dependency tree
including all optional_dependencies
"""
desired = {}
desired[H] = set([E, A])
desired[E] = set([D, C])
desired[D] = set([A, B])
desired[C] = set([A, B])
desired[B] = set([A])
desired[A] = set()
assert has_create.optional_dependency_graph(H) == desired
def test_optional_dependency_graph_with_additional():
"""confirms that has_create._optional_dependency_graph(Base) returns a complete dependency tree
including all optional_dependencies with the AdditionalBases treated as a dependencies
of Base (when they aren't) and their dependencies and optional_dependencies included as well.
"""
desired = {}
desired[F] = set([B, E])
desired[H] = set([E, A])
desired[E] = set([D, C])
desired[D] = set([A, B])
desired[C] = set([A, B])
desired[B] = set([A])
desired[A] = set()
assert has_create.optional_dependency_graph(F, H, A) == desired
def test_creation_order():
"""confirms that `has_create.creation_order()` returns a valid creation order in the desired list of sets format"""
dependency_graph = dict(
eight=set(['seven', 'six']),
seven=set(['five']),
six=set(),
five=set(['two', 'one']),
four=set(['one']),
three=set(['two']),
two=set(['one']),
one=set(),
)
desired = [set(['one', 'six']), set(['two', 'four']), set(['three', 'five']), set(['seven']), set(['eight'])]
assert has_create.creation_order(dependency_graph) == desired
def test_creation_order_with_loop():
"""confirms that `has_create.creation_order()` raises toposort.CircularDependencyError when evaluating
a cyclic dependency graph
"""
dependency_graph = dict(
eight=set(['seven', 'six']),
seven=set(['five']),
six=set(),
five=set(['two', 'one']),
four=set(['one']),
three=set(['two']),
two=set(['one']),
one=set(['eight']),
)
with pytest.raises(CircularDependencyError):
assert has_create.creation_order(dependency_graph)
class One(MockHasCreate):
pass
class Two(MockHasCreate):
dependencies = [One]
class Three(MockHasCreate):
dependencies = [Two, One]
class Four(MockHasCreate):
optional_dependencies = [Two]
class Five(MockHasCreate):
dependencies = [Two]
optional_dependencies = [One]
class IsntAHasCreate(object):
pass
class Six(MockHasCreate, IsntAHasCreate):
dependencies = [Two]
class Seven(MockHasCreate):
dependencies = [IsntAHasCreate]
def test_separate_async_optionals_none_exist():
"""confirms that when creation group classes have no async optional dependencies the order is unchanged"""
order = has_create.creation_order(has_create.optional_dependency_graph(Three, Two, One))
assert has_create.separate_async_optionals(order) == order
def test_separate_async_optionals_two_exist():
"""confirms that when two creation group classes have async dependencies
the class that has shared item as a dependency occurs first in a separate creation group
"""
order = has_create.creation_order(has_create.optional_dependency_graph(Four, Three, Two))
assert has_create.separate_async_optionals(order) == [set([One]), set([Two]), set([Three]), set([Four])]
def test_separate_async_optionals_three_exist():
"""confirms that when three creation group classes have async dependencies
the class that has shared item as a dependency occurs first in a separate creation group
"""
order = has_create.creation_order(has_create.optional_dependency_graph(Five, Four, Three))
assert has_create.separate_async_optionals(order) == [set([One]), set([Two]), set([Three]), set([Five]), set([Four])]
def test_separate_async_optionals_not_has_create():
"""confirms that when a dependency isn't a HasCreate has_create.separate_aysnc_optionals doesn't
unnecessarily move it from the initial creation group
"""
order = has_create.creation_order(has_create.optional_dependency_graph(Seven, Six))
assert has_create.separate_async_optionals(order) == [set([One, IsntAHasCreate]), set([Two, Seven]), set([Six])]
def test_page_creation_order_single_page():
"""confirms that `has_create.page_creation_order()` returns a valid creation order"""
desired = [set([A]), set([D]), set([G])]
assert has_create.page_creation_order(G) == desired
def test_page_creation_order_optionals_provided():
"""confirms that `has_create.page_creation_order()` returns a valid creation order
when optional_dependencies are included
"""
desired = [set([A]), set([B]), set([C]), set([D]), set([E]), set([H])]
assert has_create.page_creation_order(H, A, E) == desired
def test_page_creation_order_additionals_provided():
"""confirms that `has_create.page_creation_order()` returns a valid creation order
when additional pages are included
"""
desired = [set([A]), set([B]), set([D]), set([F, H]), set([G])]
assert has_create.page_creation_order(F, H, G) == desired
def test_all_instantiated_dependencies_single_page():
f = F().create()
b = f._dependency_store[B]
desired = set([b, f])
assert set(has_create.all_instantiated_dependencies(f, A, B, C, D, E, F, G, H)) == desired
def test_all_instantiated_dependencies_single_page_are_ordered():
f = F().create()
b = f._dependency_store[B]
desired = [b, f]
assert has_create.all_instantiated_dependencies(f, A, B, C, D, E, F, G, H) == desired
def test_all_instantiated_dependencies_optionals():
a = A().create()
b = B().create(a=a)
c = C().create(a=a, b=b)
d = D().create(a=a, b=b)
e = E().create(c=c, d=d)
h = H().create(a=a, e=e)
desired = set([a, b, c, d, e, h])
assert set(has_create.all_instantiated_dependencies(h, A, B, C, D, E, F, G, H)) == desired
def test_all_instantiated_dependencies_optionals_are_ordered():
a = A().create()
b = B().create(a=a)
c = C().create(a=a, b=b)
d = D().create(a=a, b=b)
e = E().create(c=c, d=d)
h = H().create(a=a, e=e)
desired = [a, b, c, d, e, h]
assert has_create.all_instantiated_dependencies(h, A, B, C, D, E, F, G, H) == desired
def test_dependency_resolution_complete():
h = H().create(a=True, e=True)
a = h._dependency_store[A]
e = h._dependency_store[E]
c = e._dependency_store[C]
d = e._dependency_store[D]
b = c._dependency_store[B]
for item in (h, a, e, d, c, b):
if item._dependency_store:
assert all(item._dependency_store.values()), "{0} missing dependency: {0._dependency_store}".format(item)
assert a == b._dependency_store[A], "Duplicate dependency detected"
assert a == c._dependency_store[A], "Duplicate dependency detected"
assert a == d._dependency_store[A], "Duplicate dependency detected"
assert b == c._dependency_store[B], "Duplicate dependency detected"
assert b == d._dependency_store[B], "Duplicate dependency detected"
def test_ds_mapping():
h = H().create(a=True, e=True)
a = h._dependency_store[A]
e = h._dependency_store[E]
c = e._dependency_store[C]
d = e._dependency_store[D]
b = c._dependency_store[B]
assert a == h.ds.a
assert e == h.ds.e
assert c == e.ds.c
assert d == e.ds.d
assert b == c.ds.b
def test_ds_multiple_word_class_and_attribute_name():
amwcn = AnotherMultipleWordClassName().create(multiple_word_class_name=True)
mwcn = amwcn._dependency_store[MultipleWordClassName]
assert amwcn.ds.multiple_word_class_name == mwcn
def test_ds_missing_dependency():
a = A().create()
with pytest.raises(AttributeError):
a.ds.b
def test_teardown_calls_silent_cleanup():
g = G().create(f=True, e=True)
f = g._dependency_store[F]
e = g._dependency_store[E]
b = f._dependency_store[B]
d = e._dependency_store[D]
c = e._dependency_store[C]
a = c._dependency_store[A]
instances = [g, f, e, b, d, c, a]
for instance in instances:
assert not instance.cleaned
g.teardown()
for instance in instances:
assert instance.cleaned
def test_teardown_dependency_store_cleared():
g = G().create(f=True, e=True)
f = g._dependency_store[F]
e = g._dependency_store[E]
c = e._dependency_store[C]
g.teardown()
assert not g._dependency_store[F]
assert not g._dependency_store[E]
assert not f._dependency_store[B]
assert not e._dependency_store[D]
assert not e._dependency_store[C]
assert not c._dependency_store[A]
def test_idempotent_teardown_dependency_store_cleared():
g = G().create(f=True, e=True)
f = g._dependency_store[F]
e = g._dependency_store[E]
b = f._dependency_store[B]
d = e._dependency_store[D]
c = e._dependency_store[C]
a = c._dependency_store[A]
for item in (g, f, e, b, d, c, a):
item.teardown()
item.teardown()
assert not g._dependency_store[F]
assert not g._dependency_store[E]
assert not f._dependency_store[B]
assert not e._dependency_store[D]
assert not e._dependency_store[C]
assert not c._dependency_store[A]
def test_teardown_ds_cleared():
g = G().create(f=True, e=True)
f = g._dependency_store[F]
e = g._dependency_store[E]
c = e._dependency_store[C]
g.teardown()
for former_dep in ('f', 'e'):
with pytest.raises(AttributeError):
getattr(g.ds, former_dep)
with pytest.raises(AttributeError):
getattr(f.ds, 'b')
for former_dep in ('d', 'c'):
with pytest.raises(AttributeError):
getattr(e.ds, former_dep)
with pytest.raises(AttributeError):
getattr(c.ds, 'a')
class OneWithArgs(MockHasCreate):
def create(self, **kw):
self.kw = kw
return self
class TwoWithArgs(MockHasCreate):
dependencies = [OneWithArgs]
def create(self, one_with_args=OneWithArgs, **kw):
if not one_with_args and kw.pop('make_one_with_args', False):
one_with_args = (OneWithArgs, dict(a='a', b='b', c='c'))
self.create_and_update_dependencies(one_with_args)
self.kw = kw
return self
class ThreeWithArgs(MockHasCreate):
dependencies = [OneWithArgs]
optional_dependencies = [TwoWithArgs]
def create(self, one_with_args=OneWithArgs, two_with_args=None, **kw):
self.create_and_update_dependencies(*filter_by_class((one_with_args, OneWithArgs), (two_with_args, TwoWithArgs)))
self.kw = kw
return self
class FourWithArgs(MockHasCreate):
dependencies = [TwoWithArgs, ThreeWithArgs]
def create(self, two_with_args=TwoWithArgs, three_with_args=ThreeWithArgs, **kw):
self.create_and_update_dependencies(*filter_by_class((two_with_args, TwoWithArgs), (three_with_args, ThreeWithArgs)))
self.kw = kw
return self
def test_single_kwargs_class_in_create_and_update_dependencies():
two_wa = TwoWithArgs().create(one_with_args=False, make_one_with_args=True, two_with_args_kw_arg=123)
assert isinstance(two_wa.ds.one_with_args, OneWithArgs)
assert two_wa.ds.one_with_args.kw == dict(a='a', b='b', c='c')
assert two_wa.kw == dict(two_with_args_kw_arg=123)
def test_no_tuple_for_class_arg_causes_shared_dependencies_staggered():
three_wo = ThreeWithArgs().create(two_with_args=True)
assert isinstance(three_wo.ds.one_with_args, OneWithArgs)
assert isinstance(three_wo.ds.two_with_args, TwoWithArgs)
assert isinstance(three_wo.ds.two_with_args.ds.one_with_args, OneWithArgs)
assert three_wo.ds.one_with_args == three_wo.ds.two_with_args.ds.one_with_args
def test_no_tuple_for_class_arg_causes_shared_dependencies_nested_staggering():
four_wo = FourWithArgs().create()
assert isinstance(four_wo.ds.two_with_args, TwoWithArgs)
assert isinstance(four_wo.ds.three_with_args, ThreeWithArgs)
assert isinstance(four_wo.ds.two_with_args.ds.one_with_args, OneWithArgs)
assert isinstance(four_wo.ds.three_with_args.ds.one_with_args, OneWithArgs)
assert isinstance(four_wo.ds.three_with_args.ds.two_with_args, TwoWithArgs)
assert four_wo.ds.two_with_args.ds.one_with_args == four_wo.ds.three_with_args.ds.one_with_args
assert four_wo.ds.two_with_args == four_wo.ds.three_with_args.ds.two_with_args
def test_tuple_for_class_arg_causes_unshared_dependencies_when_downstream():
"""Confirms that provided arg-tuple for dependency type is applied instead of chained dependency"""
three_wa = ThreeWithArgs().create(
two_with_args=(TwoWithArgs, dict(one_with_args=False, make_one_with_args=True, two_with_args_kw_arg=234)), three_with_args_kw_arg=345
)
assert isinstance(three_wa.ds.one_with_args, OneWithArgs)
assert isinstance(three_wa.ds.two_with_args, TwoWithArgs)
assert isinstance(three_wa.ds.two_with_args.ds.one_with_args, OneWithArgs)
assert three_wa.ds.one_with_args != three_wa.ds.two_with_args.ds.one_with_args
assert three_wa.ds.one_with_args.kw == dict()
assert three_wa.ds.two_with_args.kw == dict(two_with_args_kw_arg=234)
assert three_wa.ds.two_with_args.ds.one_with_args.kw == dict(a='a', b='b', c='c')
assert three_wa.kw == dict(three_with_args_kw_arg=345)
def test_tuples_for_class_arg_cause_unshared_dependencies_when_downstream():
"""Confirms that provided arg-tuple for dependency type is applied instead of chained dependency"""
four_wa = FourWithArgs().create(
two_with_args=(TwoWithArgs, dict(one_with_args=False, make_one_with_args=True, two_with_args_kw_arg=456)),
# No shared dependencies with four_wa.ds.two_with_args
three_with_args=(ThreeWithArgs, dict(one_with_args=(OneWithArgs, {}), two_with_args=False)),
four_with_args_kw=567,
)
assert isinstance(four_wa.ds.two_with_args, TwoWithArgs)
assert isinstance(four_wa.ds.three_with_args, ThreeWithArgs)
assert isinstance(four_wa.ds.two_with_args.ds.one_with_args, OneWithArgs)
assert isinstance(four_wa.ds.three_with_args.ds.one_with_args, OneWithArgs)
assert four_wa.ds.three_with_args.ds.one_with_args != four_wa.ds.two_with_args.ds.one_with_args
with pytest.raises(AttributeError):
four_wa.ds.three_with_args.ds.two_with_args
assert four_wa.kw == dict(four_with_args_kw=567)
class NotHasCreate(object):
pass
class MixinUserA(MockHasCreate, NotHasCreate):
def create(self, **kw):
return self
class MixinUserB(MockHasCreate, NotHasCreate):
def create(self, **kw):
return self
class MixinUserC(MixinUserB):
def create(self, **kw):
return self
class MixinUserD(MixinUserC):
def create(self, **kw):
return self
class NotHasCreateDependencyHolder(MockHasCreate):
dependencies = [NotHasCreate]
def create(self, not_has_create=MixinUserA):
self.create_and_update_dependencies(not_has_create)
return self
def test_not_has_create_default_dependency():
"""Confirms that HasCreates that claim non-HasCreates as dependencies claim them by correct kwarg
class name in _dependency_store
"""
dep_holder = NotHasCreateDependencyHolder().create()
assert isinstance(dep_holder.ds.not_has_create, MixinUserA)
def test_not_has_create_passed_dependency():
"""Confirms that passed non-HasCreate subclasses are sourced as dependency"""
dep = MixinUserB().create()
assert isinstance(dep, MixinUserB)
dep_holder = NotHasCreateDependencyHolder().create(not_has_create=dep)
assert dep_holder.ds.not_has_create == dep
class HasCreateParentDependencyHolder(MockHasCreate):
dependencies = [MixinUserB]
def create(self, mixin_user_b=MixinUserC):
self.create_and_update_dependencies(mixin_user_b)
return self
def test_has_create_stored_as_parent_dependency():
"""Confirms that HasCreate subclasses are sourced as their parent"""
dep = MixinUserC().create()
assert isinstance(dep, MixinUserC)
assert isinstance(dep, MixinUserB)
dep_holder = HasCreateParentDependencyHolder().create(mixin_user_b=dep)
assert dep_holder.ds.mixin_user_b == dep
class DynamicallyDeclaresNotHasCreateDependency(MockHasCreate):
dependencies = [NotHasCreate]
def create(self, not_has_create=MixinUserA):
dynamic_dependency = dict(mixinusera=MixinUserA, mixinuserb=MixinUserB, mixinuserc=MixinUserC)
self.create_and_update_dependencies(dynamic_dependency[not_has_create])
return self
@pytest.mark.parametrize('dependency,dependency_class', [('mixinusera', MixinUserA), ('mixinuserb', MixinUserB), ('mixinuserc', MixinUserC)])
def test_subclass_or_parent_dynamic_not_has_create_dependency_declaration(dependency, dependency_class):
"""Confirms that dependencies that dynamically declare dependencies subclassed from not HasCreate
are properly linked
"""
dep_holder = DynamicallyDeclaresNotHasCreateDependency().create(dependency)
assert dep_holder.ds.not_has_create.__class__ == dependency_class
class DynamicallyDeclaresHasCreateDependency(MockHasCreate):
dependencies = [MixinUserB]
def create(self, mixin_user_b=MixinUserB):
dynamic_dependency = dict(mixinuserb=MixinUserB, mixinuserc=MixinUserC, mixinuserd=MixinUserD)
self.create_and_update_dependencies(dynamic_dependency[mixin_user_b])
return self
@pytest.mark.parametrize('dependency,dependency_class', [('mixinuserb', MixinUserB), ('mixinuserc', MixinUserC), ('mixinuserd', MixinUserD)])
def test_subclass_or_parent_dynamic_has_create_dependency_declaration(dependency, dependency_class):
"""Confirms that dependencies that dynamically declare dependencies subclassed from not HasCreate
are properly linked
"""
dep_holder = DynamicallyDeclaresHasCreateDependency().create(dependency)
assert dep_holder.ds.mixin_user_b.__class__ == dependency_class
07070100000075000081A400000000000000000000000166846B92000023D1000000000000000000000000000000000000002100000000awx-24.6.1/test/test_registry.pyimport pytest
from awxkit.api.registry import URLRegistry
class One(object):
pass
class Two(object):
pass
@pytest.fixture
def reg():
return URLRegistry()
def test_url_pattern(reg):
desired = r'^/some/resources/\d+/(\?.*)*$'
assert reg.url_pattern(r'/some/resources/\d+/').pattern == desired
def test_methodless_get_from_empty_registry(reg):
assert reg.get('nonexistent') is None
def test_method_get_from_empty_registry(reg):
assert reg.get('nonexistent', 'method') is None
def test_methodless_setdefault_methodless_get(reg):
reg.setdefault(One)
assert reg.get('some_path') is One
def test_methodless_setdefault_method_get(reg):
reg.setdefault(One)
assert reg.get('some_path', 'method') is One
def test_method_setdefault_methodless_get(reg):
reg.setdefault('method', One)
assert reg.get('some_path') is None
def test_method_setdefault_matching_method_get(reg):
reg.setdefault('method', One)
assert reg.get('some_path', 'method') is One
def test_method_setdefault_nonmatching_method_get(reg):
reg.setdefault('method', One)
assert reg.get('some_path', 'nonexistent') is None
def test_multimethod_setdefault_matching_method_get(reg):
reg.setdefault(('method_one', 'method_two'), One)
assert reg.get('some_path', 'method_one') is One
assert reg.get('some_path', 'method_two') is One
def test_multimethod_setdefault_nonmatching_method_get(reg):
reg.setdefault(('method_one', 'method_two'), One)
assert reg.get('some_path') is None
assert reg.get('some_path', 'nonexistent') is None
def test_wildcard_setdefault_methodless_get(reg):
reg.setdefault('.*', One)
assert reg.get('some_path') is One
def test_wildcard_setdefault_method_get(reg):
reg.setdefault('.*', One)
assert reg.get('some_path', 'method') is One
def test_regex_method_setdefaults_over_wildcard_method_get(reg):
reg.setdefault('.*', One)
reg.setdefault('reg.*ex', Two)
for _ in range(1000):
assert reg.get('some_path', 'regex') is Two
def test_methodless_registration_with_matching_path_methodless_get(reg):
reg.register('some_path', One)
assert reg.get('some_path') is One
def test_methodless_registraion_with_nonmatching_path_methodless_get(reg):
reg.register('some_path', One)
assert reg.get('nonexistent') is None
def test_methodless_registration_with_matching_path_nonmatching_method_get(reg):
reg.register('some_path', One)
assert reg.get('some_path', 'method') is None
def test_method_registration_with_matching_path_matching_method_get(reg):
reg.register('some_path', 'method', One)
assert reg.get('some_path', 'method') is One
def test_method_registration_with_matching_path_nonmatching_method_get(reg):
reg.register('some_path', 'method_one', One)
assert reg.get('some_path', 'method_two') is None
def test_multimethod_registration_with_matching_path_matching_method_get(reg):
reg.register('some_path', ('method_one', 'method_two'), One)
assert reg.get('some_path', 'method_one') is One
assert reg.get('some_path', 'method_two') is One
def test_multimethod_registration_with_path_matching_method_get(reg):
reg.register('some_path', ('method_one', 'method_two'), One)
assert reg.get('some_path', 'method_three') is None
def test_multipath_methodless_registration_with_matching_path_methodless_get(reg):
reg.register(('some_path_one', 'some_path_two'), One)
assert reg.get('some_path_one') is One
assert reg.get('some_path_two') is One
def test_multipath_methodless_registration_with_matching_path_nonmatching_method_get(reg):
reg.register(('some_path_one', 'some_path_two'), One)
assert reg.get('some_path_one', 'method') is None
assert reg.get('some_path_two', 'method') is None
def test_multipath_method_registration_with_matching_path_matching_method_get(reg):
reg.register((('some_path_one', 'method_one'), ('some_path_two', 'method_two')), One)
assert reg.get('some_path_one', 'method_one') is One
assert reg.get('some_path_two', 'method_two') is One
def test_multipath_partial_method_registration_with_matching_path_matching_method_get(reg):
reg.register(('some_path_one', ('some_path_two', 'method')), One)
assert reg.get('some_path_one') is One
assert reg.get('some_path_two', 'method') is One
def test_wildcard_method_registration_with_methodless_get(reg):
reg.register('some_path', '.*', One)
assert reg.get('some_path') is One
def test_wildcard_method_registration_with_method_get(reg):
reg.register('some_path', '.*', One)
assert reg.get('some_path', 'method') is One
def test_wildcard_and_specific_method_registration_acts_as_default(reg):
reg.register('some_path', 'method_one', Two)
reg.register('some_path', '.*', One)
reg.register('some_path', 'method_two', Two)
for _ in range(1000): # eliminate overt randomness
assert reg.get('some_path', 'nonexistent') is One
assert reg.get('some_path', 'method_one') is Two
assert reg.get('some_path', 'method_two') is Two
@pytest.mark.parametrize('method', ('method', '.*'))
def test_multiple_method_registrations_disallowed_for_single_path_single_registration(reg, method):
with pytest.raises(TypeError) as e:
reg.register((('some_path', method), ('some_path', method)), One)
assert str(e.value) == ('"{0.pattern}" already has registered method "{1}"'.format(reg.url_pattern('some_path'), method))
@pytest.mark.parametrize('method', ('method', '.*'))
def test_multiple_method_registrations_disallowed_for_single_path_multiple_registrations(reg, method):
reg.register('some_path', method, One)
with pytest.raises(TypeError) as e:
reg.register('some_path', method, One)
assert str(e.value) == ('"{0.pattern}" already has registered method "{1}"'.format(reg.url_pattern('some_path'), method))
def test_paths_can_be_patterns(reg):
reg.register('.*pattern.*', One)
assert reg.get('XYZpattern123') is One
def test_mixed_form_single_registration(reg):
reg.register(
[('some_path_one', 'method_one'), 'some_path_two', ('some_path_three', ('method_two', 'method_three')), 'some_path_four', 'some_path_five'], One
)
assert reg.get('some_path_one', 'method_one') is One
assert reg.get('some_path_one') is None
assert reg.get('some_path_one', 'nonexistent') is None
assert reg.get('some_path_two') is One
assert reg.get('some_path_two', 'nonexistent') is None
assert reg.get('some_path_three', 'method_two') is One
assert reg.get('some_path_three', 'method_three') is One
assert reg.get('some_path_three') is None
assert reg.get('some_path_three', 'nonexistent') is None
assert reg.get('some_path_four') is One
assert reg.get('some_path_four', 'nonexistent') is None
assert reg.get('some_path_five') is One
assert reg.get('some_path_five', 'nonexistent') is None
def test_mixed_form_single_registration_with_methodless_default(reg):
reg.setdefault(One)
reg.register(
[('some_path_one', 'method_one'), 'some_path_two', ('some_path_three', ('method_two', 'method_three')), 'some_path_four', 'some_path_five'], Two
)
assert reg.get('some_path_one', 'method_one') is Two
assert reg.get('some_path_one') is One
assert reg.get('some_path_one', 'nonexistent') is One
assert reg.get('some_path_two') is Two
assert reg.get('some_path_two', 'nonexistent') is One
assert reg.get('some_path_three', 'method_two') is Two
assert reg.get('some_path_three', 'method_three') is Two
assert reg.get('some_path_three') is One
assert reg.get('some_path_three', 'nonexistent') is One
assert reg.get('some_path_four') is Two
assert reg.get('some_path_four', 'nonexistent') is One
assert reg.get('some_path_five') is Two
assert reg.get('some_path_five', 'nonexistent') is One
def test_mixed_form_single_registration_with_method_default(reg):
reg.setdefault('existent', One)
reg.register(
[('some_path_one', 'method_one'), 'some_path_two', ('some_path_three', ('method_two', 'method_three')), 'some_path_four', 'some_path_five'], Two
)
assert reg.get('some_path_one', 'method_one') is Two
assert reg.get('some_path_one') is None
assert reg.get('some_path_one', 'existent') is One
assert reg.get('some_path_one', 'nonexistent') is None
assert reg.get('some_path_two') is Two
assert reg.get('some_path_two', 'existent') is One
assert reg.get('some_path_two', 'nonexistent') is None
assert reg.get('some_path_three', 'method_two') is Two
assert reg.get('some_path_three', 'method_three') is Two
assert reg.get('some_path_three') is None
assert reg.get('some_path_three', 'existent') is One
assert reg.get('some_path_three', 'nonexistent') is None
assert reg.get('some_path_four') is Two
assert reg.get('some_path_four', 'existent') is One
assert reg.get('some_path_four', 'nonexistent') is None
assert reg.get('some_path_five') is Two
assert reg.get('some_path_five', 'existent') is One
assert reg.get('some_path_five', 'nonexistent') is None
07070100000076000081A400000000000000000000000166846B9200003461000000000000000000000000000000000000001E00000000awx-24.6.1/test/test_utils.py# -*- coding: utf-8 -*-
from datetime import datetime
import sys
from unittest import mock
import pytest
from awxkit import utils
from awxkit import exceptions as exc
@pytest.mark.parametrize(
'inp, out',
[
[True, True],
[False, False],
[1, True],
[0, False],
[1.0, True],
[0.0, False],
['TrUe', True],
['FalSe', False],
['yEs', True],
['No', False],
['oN', True],
['oFf', False],
['asdf', True],
['0', False],
['', False],
[{1: 1}, True],
[{}, False],
[(0,), True],
[(), False],
[[1], True],
[[], False],
],
)
def test_to_bool(inp, out):
assert utils.to_bool(inp) == out
@pytest.mark.parametrize(
'inp, out',
[
["{}", {}],
["{'null': null}", {"null": None}],
["{'bool': true}", {"bool": True}],
["{'bool': false}", {"bool": False}],
["{'int': 0}", {"int": 0}],
["{'float': 1.0}", {"float": 1.0}],
["{'str': 'abc'}", {"str": "abc"}],
["{'obj': {}}", {"obj": {}}],
["{'list': []}", {"list": []}],
["---", None],
["---\n'null': null", {'null': None}],
["---\n'bool': true", {'bool': True}],
["---\n'bool': false", {'bool': False}],
["---\n'int': 0", {'int': 0}],
["---\n'float': 1.0", {'float': 1.0}],
["---\n'string': 'abc'", {'string': 'abc'}],
["---\n'obj': {}", {'obj': {}}],
["---\n'list': []", {'list': []}],
["", None],
["'null': null", {'null': None}],
["'bool': true", {'bool': True}],
["'bool': false", {'bool': False}],
["'int': 0", {'int': 0}],
["'float': 1.0", {'float': 1.0}],
["'string': 'abc'", {'string': 'abc'}],
["'obj': {}", {'obj': {}}],
["'list': []", {'list': []}],
],
)
def test_load_valid_json_or_yaml(inp, out):
assert utils.load_json_or_yaml(inp) == out
@pytest.mark.parametrize('inp', [True, False, 0, 1.0, {}, [], None])
def test_load_invalid_json_or_yaml(inp):
with pytest.raises(TypeError):
utils.load_json_or_yaml(inp)
@pytest.mark.parametrize('non_ascii', [True, False])
@pytest.mark.skipif(sys.version_info < (3, 6), reason='this is only intended to be used in py3, not the CLI')
def test_random_titles_are_unicode(non_ascii):
assert isinstance(utils.random_title(non_ascii=non_ascii), str)
@pytest.mark.parametrize('non_ascii', [True, False])
@pytest.mark.skipif(sys.version_info < (3, 6), reason='this is only intended to be used in py3, not the CLI')
def test_random_titles_generates_correct_characters(non_ascii):
title = utils.random_title(non_ascii=non_ascii)
if non_ascii:
with pytest.raises(UnicodeEncodeError):
title.encode('ascii')
title.encode('utf-8')
else:
title.encode('ascii')
title.encode('utf-8')
@pytest.mark.parametrize(
'inp, out',
[
['ClassNameShouldChange', 'class_name_should_change'],
['classnameshouldntchange', 'classnameshouldntchange'],
['Classspacingshouldntchange', 'classspacingshouldntchange'],
['Class1Name2Should3Change', 'class_1_name_2_should_3_change'],
['Class123name234should345change456', 'class_123_name_234_should_345_change_456'],
],
)
def test_class_name_to_kw_arg(inp, out):
assert utils.class_name_to_kw_arg(inp) == out
@pytest.mark.parametrize(
'first, second, expected',
[
['/api/v2/resources/', '/api/v2/resources/', True],
['/api/v2/resources/', '/api/v2/resources/?test=ignored', True],
['/api/v2/resources/?one=ignored', '/api/v2/resources/?two=ignored', True],
['http://one.com', 'http://one.com', True],
['http://one.com', 'http://www.one.com', True],
['http://one.com', 'http://one.com?test=ignored', True],
['http://one.com', 'http://www.one.com?test=ignored', True],
['http://one.com', 'https://one.com', False],
['http://one.com', 'https://one.com?test=ignored', False],
],
)
def test_are_same_endpoint(first, second, expected):
assert utils.are_same_endpoint(first, second) == expected
@pytest.mark.parametrize('endpoint, expected', [['/api/v2/resources/', 'v2'], ['/api/v2000/resources/', 'v2000'], ['/api/', 'common']])
def test_version_from_endpoint(endpoint, expected):
assert utils.version_from_endpoint(endpoint) == expected
class OneClass:
pass
class TwoClass:
pass
class ThreeClass:
pass
class FourClass(ThreeClass):
pass
def test_filter_by_class_with_subclass_class():
filtered = utils.filter_by_class((OneClass, OneClass), (FourClass, ThreeClass))
assert filtered == [OneClass, FourClass]
def test_filter_by_class_with_subclass_instance():
one = OneClass()
four = FourClass()
filtered = utils.filter_by_class((one, OneClass), (four, ThreeClass))
assert filtered == [one, four]
def test_filter_by_class_no_arg_tuples():
three = ThreeClass()
filtered = utils.filter_by_class((True, OneClass), (False, TwoClass), (three, ThreeClass))
assert filtered == [OneClass, None, three]
def test_filter_by_class_with_arg_tuples_containing_class():
one = OneClass()
three = (ThreeClass, dict(one=1, two=2))
filtered = utils.filter_by_class((one, OneClass), (False, TwoClass), (three, ThreeClass))
assert filtered == [one, None, three]
def test_filter_by_class_with_arg_tuples_containing_subclass():
one = OneClass()
three = (FourClass, dict(one=1, two=2))
filtered = utils.filter_by_class((one, OneClass), (False, TwoClass), (three, ThreeClass))
assert filtered == [one, None, three]
@pytest.mark.parametrize('truthy', (True, 123, 'yes'))
def test_filter_by_class_with_arg_tuples_containing_truthy(truthy):
one = OneClass()
three = (truthy, dict(one=1, two=2))
filtered = utils.filter_by_class((one, OneClass), (False, TwoClass), (three, ThreeClass))
assert filtered == [one, None, (ThreeClass, dict(one=1, two=2))]
@pytest.mark.parametrize(
'date_string,now,expected',
[
('2017-12-20T00:00:01.5Z', datetime(2017, 12, 20, 0, 0, 2, 750000), 1.25),
('2017-12-20T00:00:01.5Z', datetime(2017, 12, 20, 0, 0, 1, 500000), 0.00),
('2017-12-20T00:00:01.5Z', datetime(2017, 12, 20, 0, 0, 0, 500000), -1.00),
],
)
def test_seconds_since_date_string(date_string, now, expected):
with mock.patch('awxkit.utils.utcnow', return_value=now):
assert utils.seconds_since_date_string(date_string) == expected
class RecordingCallback(object):
def __init__(self, value=True):
self.call_count = 0
self.value = value
def __call__(self):
self.call_count += 1
return self.value
class TestPollUntil(object):
@pytest.mark.parametrize('timeout', [0, 0.0, -0.5, -1, -9999999])
def test_callback_called_once_for_non_positive_timeout(self, timeout):
with mock.patch('awxkit.utils.logged_sleep') as sleep:
callback = RecordingCallback()
utils.poll_until(callback, timeout=timeout)
assert not sleep.called
assert callback.call_count == 1
def test_exc_raised_on_timeout(self):
with mock.patch('awxkit.utils.logged_sleep'):
with pytest.raises(exc.WaitUntilTimeout):
utils.poll_until(lambda: False, timeout=0)
@pytest.mark.parametrize('callback_value', [{'hello': 1}, 'foo', True])
def test_non_falsey_callback_value_is_returned(self, callback_value):
with mock.patch('awxkit.utils.logged_sleep'):
assert utils.poll_until(lambda: callback_value) == callback_value
class TestPseudoNamespace(object):
def test_set_item_check_item(self):
pn = utils.PseudoNamespace()
pn['key'] = 'value'
assert pn['key'] == 'value'
def test_set_item_check_attr(self):
pn = utils.PseudoNamespace()
pn['key'] = 'value'
assert pn.key == 'value'
def test_set_attr_check_item(self):
pn = utils.PseudoNamespace()
pn.key = 'value'
assert pn['key'] == 'value'
def test_set_attr_check_attr(self):
pn = utils.PseudoNamespace()
pn.key = 'value'
assert pn.key == 'value'
def test_auto_dicts_cast(self):
pn = utils.PseudoNamespace()
pn.one = dict()
pn.one.two = dict(three=3)
assert pn.one.two.three == 3
assert pn == dict(one=dict(two=dict(three=3)))
def test_auto_list_of_dicts_cast(self):
pn = utils.PseudoNamespace()
pn.one = [dict(two=2), dict(three=3)]
assert pn.one[0].two == 2
assert pn == dict(one=[dict(two=2), dict(three=3)])
def test_auto_tuple_of_dicts_cast(self):
pn = utils.PseudoNamespace()
pn.one = (dict(two=2), dict(three=3))
assert pn.one[0].two == 2
assert pn == dict(one=(dict(two=2), dict(three=3)))
def test_instantiation_via_dict(self):
pn = utils.PseudoNamespace(dict(one=1, two=2, three=3))
assert pn.one == 1
assert pn == dict(one=1, two=2, three=3)
assert len(pn.keys()) == 3
def test_instantiation_via_kwargs(self):
pn = utils.PseudoNamespace(one=1, two=2, three=3)
assert pn.one == 1
assert pn == dict(one=1, two=2, three=3)
assert len(pn.keys()) == 3
def test_instantiation_via_dict_and_kwargs(self):
pn = utils.PseudoNamespace(dict(one=1, two=2, three=3), four=4, five=5)
assert pn.one == 1
assert pn.four == 4
assert pn == dict(one=1, two=2, three=3, four=4, five=5)
assert len(pn.keys()) == 5
def test_instantiation_via_nested_dict(self):
pn = utils.PseudoNamespace(dict(one=1, two=2), three=dict(four=4, five=dict(six=6)))
assert pn.one == 1
assert pn.three.four == 4
assert pn.three.five.six == 6
assert pn == dict(one=1, two=2, three=dict(four=4, five=dict(six=6)))
def test_instantiation_via_nested_dict_with_list(self):
pn = utils.PseudoNamespace(dict(one=[dict(two=2), dict(three=3)]))
assert pn.one[0].two == 2
assert pn.one[1].three == 3
assert pn == dict(one=[dict(two=2), dict(three=3)])
def test_instantiation_via_nested_dict_with_lists(self):
pn = utils.PseudoNamespace(dict(one=[dict(two=2), dict(three=dict(four=4, five=[dict(six=6), dict(seven=7)]))]))
assert pn.one[1].three.five[1].seven == 7
def test_instantiation_via_nested_dict_with_tuple(self):
pn = utils.PseudoNamespace(dict(one=(dict(two=2), dict(three=3))))
assert pn.one[0].two == 2
assert pn.one[1].three == 3
assert pn == dict(one=(dict(two=2), dict(three=3)))
def test_instantiation_via_nested_dict_with_tuples(self):
pn = utils.PseudoNamespace(dict(one=(dict(two=2), dict(three=dict(four=4, five=(dict(six=6), dict(seven=7)))))))
assert pn.one[1].three.five[1].seven == 7
def test_update_with_nested_dict(self):
pn = utils.PseudoNamespace()
pn.update(dict(one=1, two=2, three=3), four=4, five=5)
assert pn.one == 1
assert pn.four == 4
assert pn == dict(one=1, two=2, three=3, four=4, five=5)
assert len(pn.keys()) == 5
def test_update_with_nested_dict_with_lists(self):
pn = utils.PseudoNamespace()
pn.update(dict(one=[dict(two=2), dict(three=dict(four=4, five=[dict(six=6), dict(seven=7)]))]))
assert pn.one[1].three.five[1].seven == 7
def test_update_with_nested_dict_with_tuples(self):
pn = utils.PseudoNamespace()
pn.update(dict(one=(dict(two=2), dict(three=dict(four=4, five=(dict(six=6), dict(seven=7)))))))
assert pn.one[1].three.five[1].seven == 7
class TestUpdatePayload(object):
def test_empty_payload(self):
fields = ('one', 'two', 'three', 'four')
kwargs = dict(two=2, four=4)
payload = {}
utils.update_payload(payload, fields, kwargs)
assert payload == kwargs
def test_untouched_payload(self):
fields = ('not', 'in', 'kwargs')
kwargs = dict(one=1, two=2)
payload = dict(three=3, four=4)
utils.update_payload(payload, fields, kwargs)
assert payload == dict(three=3, four=4)
def test_overwritten_payload(self):
fields = ('one', 'two')
kwargs = dict(one=1, two=2)
payload = dict(one='one', two='two')
utils.update_payload(payload, fields, kwargs)
assert payload == kwargs
def test_falsy_kwargs(self):
fields = ('one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight')
kwargs = dict(one=False, two=(), three='', four=None, five=0, six={}, seven=set(), eight=[])
payload = {}
utils.update_payload(payload, fields, kwargs)
assert payload == kwargs
def test_not_provided_strips_payload(self):
fields = ('one', 'two')
kwargs = dict(one=utils.not_provided)
payload = dict(one=1, two=2)
utils.update_payload(payload, fields, kwargs)
assert payload == dict(two=2)
def test_to_ical():
now = datetime.utcnow()
ical_datetime = utils.to_ical(now)
date = str(now.date()).replace('-', '')
time = str(now.time()).split('.')[0].replace(':', '')
assert ical_datetime == '{}T{}Z'.format(date, time)
07070100000077000081A400000000000000000000000166846B92000004D3000000000000000000000000000000000000001B00000000awx-24.6.1/test/test_ws.py# -*- coding: utf-8 -*-
from collections import namedtuple
from unittest.mock import patch
import pytest
from awxkit.ws import WSClient
ParseResult = namedtuple("ParseResult", ["port", "hostname", "secure"])
def test_explicit_hostname():
client = WSClient("token", "some-hostname", 556, False)
assert client.port == 556
assert client.hostname == "some-hostname"
assert client._use_ssl == False
assert client.token == "token"
def test_websocket_suffix():
client = WSClient("token", "hostname", 566, ws_suffix='my-websocket/')
assert client.suffix == 'my-websocket/'
@pytest.mark.parametrize(
'url, result',
[
['https://somename:123', ParseResult(123, "somename", True)],
['http://othername:456', ParseResult(456, "othername", False)],
['http://othername', ParseResult(80, "othername", False)],
['https://othername', ParseResult(443, "othername", True)],
],
)
def test_urlparsing(url, result):
with patch("awxkit.ws.config") as mock_config:
mock_config.base_url = url
client = WSClient("token")
assert client.port == result.port
assert client.hostname == result.hostname
assert client._use_ssl == result.secure
07070100000078000081A400000000000000000000000166846B920000032F000000000000000000000000000000000000001300000000awx-24.6.1/tox.ini[tox]
distshare = {homedir}/.tox/distshare
envlist =
lint,
test
skip_missing_interpreters = true
# recreate = true
# skipsdist = true
[testenv]
basepython = python3.11
setenv =
PYTHONPATH = {toxinidir}:{env:PYTHONPATH:}:.
deps =
websocket-client
coverage
mock
pytest
pytest-mock
commands = coverage run --parallel --source awxkit -m pytest --doctest-glob='*.md' --junit-xml=report.xml {posargs}
[testenv:lint]
deps =
{[testenv]deps}
flake8
commands =
flake8 awxkit
# pylama --report report.pylama awxkit
# py.test awxkit --pylama --junitxml=report.pylama {posargs}
- coverage erase
[testenv:coveralls]
commands=
- coverage combine
- coverage report -m
- coveralls
[flake8]
max-line-length = 120
[pytest]
addopts = -v --tb=native
junit_family=xunit2
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!876 blocks