import awxkit

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

View File

@@ -0,0 +1,55 @@
import json
import sys
import traceback
import yaml
from requests.exceptions import ConnectionError, SSLError
from .client import CLI
from awxkit.exceptions import Unauthorized, Common
from awxkit.cli.utils import cprint
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 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)
elif cli.get_config('format') == 'yaml':
sys.stdout.write(yaml.dump(e.msg))
sys.exit(1)
except Exception as e:
if cli.verbose:
e = traceback.format_exc()
cprint(e, 'red', file=stderr)
sys.exit(1)

289
awxkit/awxkit/cli/client.py Executable file
View File

@@ -0,0 +1,289 @@
import logging
import os
import pkg_resources
import sys
from .custom import handle_custom_actions
from .format import (add_authentication_arguments,
add_output_formatting_arguments,
FORMATTERS, format_response)
from .options import ResourceOptionsParser
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
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):
self.stdout = stdout
self.stderr = stderr
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, auth_type='Bearer'
)
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()
self.fetch_version_root()
def fetch_version_root(self):
self.v2 = self.root.get().available_versions.v2.get()
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...
self.method = 'get'
response = getattr(resource, self.method)()
else:
response = self.parse_action(resource)
formatted = format_response(
response,
fmt=self.get_config('format'),
filter=self.get_config('filter'),
changed=self.original_action in ('modify', 'create')
)
if formatted:
print(formatted, file=self.stdout)
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(page, self.resource, subparsers)
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'):
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))

216
awxkit/awxkit/cli/custom.py Normal file
View File

@@ -0,0 +1,216 @@
from .stdout import monitor, monitor_workflow
from .utils import CustomRegistryMeta, color_enabled
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(object, 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):
pass
class Launchable(object):
def add_arguments(self, parser, with_pk=True):
if with_pk:
parser.choices[self.action].add_argument('id', type=int, 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(
'--timeout', type=int,
help='If set with --monitor or --wait, time out waiting on job completion.' # noqa
)
parser.choices[self.action].add_argument(
'--wait', action='store_true',
help='If set, waits until the launched job finishes.'
)
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'),
timeout=kwargs.get('timeout'),
)
if status:
response.json['status'] = status
return response
def perform(self, **kwargs):
response = self.page.get().related.get(self.action).post()
self.monitor(response, **kwargs)
return response
class JobTemplateLaunch(Launchable, CustomAction):
action = 'launch'
resource = 'job_templates'
class ProjectUpdate(Launchable, CustomAction):
action = 'update'
resource = 'projects'
class ProjectCreate(CustomAction):
action = 'create'
resource = 'projects'
def add_arguments(self, 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):
Launchable.add_arguments(self, 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):
parser.choices['stdout'].add_argument('id', type=int, 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 SettingsList(CustomAction):
action = 'list'
resource = 'settings'
def add_arguments(self, 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 SettingsModify(CustomAction):
action = 'modify'
resource = 'settings'
def add_arguments(self, 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/'
resp = self.page.patch(**{key: value})
return resp.from_json({'key': key, 'value': resp[key]})

View File

@@ -0,0 +1,20 @@
# 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)

View File

@@ -0,0 +1,5 @@
To build the docs, spin up a real AWX/Tower server, `pip install sphinx sphinxcontrib-autoprogram`, and run:
~ TOWER_HOST=https://awx.example.org TOWER_USERNAME=example TOWER_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/) ..

View File

@@ -0,0 +1,35 @@
@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

View File

@@ -0,0 +1,69 @@
.. _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
TOWER_HOST=https://awx.example.org \
TOWER_USERNAME=alice \
TOWER_PASSWORD=secret \
awx login
As a convenience, the ``awx login`` command prints a shell-formatted token
value:
.. code:: bash
export TOWER_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 TOWER_HOST=https://awx.example.org
$(TOWER_USERNAME=alice TOWER_PASSWORD=secret awx login)
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
TOWER_USERNAME=alice TOWER_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
TOWER_USERNAME=alice TOWER_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
TOWER_USERNAME=alice TOWER_PASSWORD=secret awx jobs list
awx --conf.username alice --conf.password secret jobs list

View File

@@ -0,0 +1,59 @@
# 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 = '2019, 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:: Ansible Tower
.. |RHAT| replace:: Red Hat Ansible Tower
'''

View File

@@ -0,0 +1,60 @@
Usage 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 configurating 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
export TOWER_COLOR=f
INVENTORY_ID=$(awx inventory list --name 'Demo Inventory' -f jq --filter '.results[0].id')
PROJECT_ID=$(awx projects create --wait \
--organization 1 --name='Example Project' \
--scm_type git --scm_url 'https://github.com/ansible/ansible-tower-samples' \
-f jq --filter '.id')
TEMPLATE_ID=$(awx job_templates create \
--name='Example Job Template' --project $PROJECT_ID \
--playbook hello_world.yml --inventory $INVENTORY_ID \
-f jq --filter '.id')
awx job_templates launch $TEMPLATE_ID --monitor
Importing an SSH Key
--------------------
DOCUMENT ME
Creating a Job Template with Extra Vars
---------------------------------------
DOCUMENT ME
Granting Membership to a Team or Organization
---------------------------------------------
DOCUMENT ME

View File

@@ -0,0 +1,36 @@
.. 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`

View File

@@ -0,0 +1,59 @@
.. _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| provides *optional* support for printing results in a human-readable
tabular format, but it requires an additional Python software dependency,
``tabulate``.
To use ``-f human``, you must install the optional dependency via ``pip install tabulate``.
.. code:: bash
awx jobs list -f human
awx jobs list -f human --filter name,created,status
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
``TOWER_COLOR=f``.

View File

@@ -0,0 +1,3 @@
.. autoprogram:: awxkit.cli.sphinx:parser
:prog: awx
:maxdepth: 3

View File

@@ -0,0 +1,99 @@
Basic Usage
===========
Installation
------------
The awx CLI is available as part of the ``awxkit`` package on PyPI.
The preferred way to install is through pip:
.. code:: bash
pip install awxkit
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, TOWER_HOST``
the full URL of the AWX/|RHAT| host (i.e., https://my.awx.example.org)
``-k, --conf.insecure, TOWER_VERIFY_SSL``
allows insecure server connections when using SSL
``--conf.username, TOWER_USERNAME``
the AWX username to use for authentication
``--conf.password, TOWER_PASSWORD``
the AWX password to use for authentication
``--conf.token, TOWER_TOKEN``
an OAuth2.0 token to use for authentication

166
awxkit/awxkit/cli/format.py Normal file
View File

@@ -0,0 +1,166 @@
import json
from distutils.util import strtobool
import yaml
from awxkit.cli.utils import colored
def add_authentication_arguments(parser, env):
auth = parser.add_argument_group('authentication')
auth.add_argument(
'--conf.host',
default=env.get('TOWER_HOST', 'https://127.0.0.1:443'),
metavar='https://example.awx.org',
)
auth.add_argument(
'--conf.token',
default=env.get('TOWER_TOKEN', ''),
help='an OAuth2.0 token (get one by using `awx login`)',
metavar='TEXT',
)
auth.add_argument(
'--conf.username',
default=env.get('TOWER_USERNAME', 'admin'),
metavar='TEXT',
)
auth.add_argument(
'--conf.password',
default=env.get('TOWER_PASSWORD', 'password'),
metavar='TEXT',
)
auth.add_argument(
'-k',
'--conf.insecure',
help='Allow insecure server connections when using SSL',
default=not strtobool(env.get('TOWER_VERIFY_SSL', 'True')),
action='store_true',
)
def add_output_formatting_arguments(parser, env):
formatting = parser.add_argument_group('output formatting')
formatting.add_argument(
'-f',
'--conf.format',
dest='conf.format',
choices=FORMATTERS.keys(),
default=env.get('TOWER_FORMAT', 'json'),
help=(
'specify an output format'
),
)
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('TOWER_COLOR', 't'), type=strtobool,
)
formatting.add_argument(
'-v',
'--verbose',
dest='conf.verbose',
help='print debug-level logs, including requests made',
default=strtobool(env.get('TOWER_VERBOSE', 'f')),
action="store_true"
)
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.dump(
output,
default_flow_style=False
)
def format_human(output, fmt):
if fmt == '.':
fmt = 'id,name'
column_names = fmt.split(',')
try:
from tabulate import tabulate
except ImportError:
raise ImportError(
'To use `-f human`, you must install the optional tabulate '
'dependency.\n`pip install tabulate`',
)
if 'count' in output:
output = output['results']
else:
output = [output]
return tabulate([
dict(
(col, record.get(col, ''))
for col in column_names
)
for record in output
], headers='keys', tablefmt='rst'
)
FORMATTERS = {
'json': format_json,
'yaml': format_yaml,
'jq': format_jq,
'human': format_human
}

View File

@@ -0,0 +1,112 @@
from distutils.util import strtobool
from .custom import CustomAction
from .format import add_output_formatting_arguments
class ResourceOptionsParser(object):
def __init__(self, page, resource, parser):
"""Used to submit an OPTIONS request to the appropriate endpoint
and apply the appropriate argparse arguments
: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.page = page
self.resource = resource
self.parser = parser
self.options = getattr(
self.page.options().json, 'actions', {'GET': {}}
)
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 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)'
)
)
add_output_formatting_arguments(parser, {})
def build_detail_actions(self):
for method in ('get', 'modify', 'delete'):
parser = self.parser.add_parser(method, help='')
self.parser.choices[method].add_argument('id', type=int, help='')
if method == 'get':
add_output_formatting_arguments(parser, {})
def build_query_arguments(self, method, http_method):
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', '')
if required:
help_text = '[REQUIRED] {}'.format(help_text)
if method == 'list':
help_text = 'only list {} with the specified {}'.format(
self.resource,
k
)
if method == 'list' and param.get('filterable') is False:
continue
kwargs = {
'help': help_text,
'required': required,
'type': {
'string': str,
'field': int,
'integer': int,
'boolean': strtobool,
}.get(param['type'], str),
}
meta_map = {
'string': 'TEXT',
'integer': 'INTEGER',
'boolean': 'BOOLEAN',
}
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)
if isinstance(kwargs['choices'][0], int):
kwargs['type'] = int
kwargs['choices'] = [str(choice) for choice in kwargs['choices']]
elif param['type'] in meta_map:
kwargs['metavar'] = meta_map[param['type']]
self.parser.choices[method].add_argument(
'--{}'.format(k),
**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)

View File

@@ -0,0 +1,158 @@
import os
from awxkit import api, config
from awxkit.api.pages import Page
from awxkit.cli.format import format_response, add_authentication_arguments
from awxkit.cli.utils import CustomRegistryMeta, cprint
CONTROL_RESOURCES = ['ping', 'config', 'me', 'metrics']
DEPRECATED_RESOURCES = {
'applications': 'application',
'credentials': 'credential',
'credential_types': 'credential_type',
'groups': 'group',
'hosts': 'hosts',
'instances': 'instance',
'instance_groups': 'instance_group',
'inventory_scripts': 'inventory_script',
'inventory_sources': 'inventory_source',
'inventory_updates': 'inventory_update',
'jobs': 'job',
'job_templates': 'job_template',
'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(object, 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 handle(self, client, parser):
auth = parser.add_argument_group('OAuth2.0 Options')
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'
)
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),
}
try:
token = api.Api().get_oauth2_token(**kwargs)
except Exception as e:
add_authentication_arguments(parser, os.environ)
parser.print_help()
cprint(
'Error retrieving an OAuth2.0 token ({}).'.format(e.__class__),
'red'
)
else:
print('export TOWER_TOKEN={}'.format(token))
class Config(CustomCommand):
name = 'config'
help_text = 'print current configuration values'
def handle(self, client, parser):
return {
'base_url': config.base_url,
'token': client.get_config('token'),
'use_sessions': config.use_sessions,
'credentials': config.credentials,
}
def parse_resource(client, skip_deprecated=False):
subparsers = client.parser.add_subparsers(
dest='resource',
metavar='resource',
)
# 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',):
# the Dashboard API is deprecated and not supported
continue
aliases = []
if not skip_deprecated:
if k in DEPRECATED_RESOURCES:
aliases = [DEPRECATED_RESOURCES[k]]
client.subparsers[k] = subparsers.add_parser(
k, help='', aliases=aliases
)
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 response:
formatted = format_response(
Page.from_json(response),
fmt=client.get_config('format'),
filter=client.get_config('filter'),
)
print(formatted)
raise SystemExit()
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

View File

@@ -0,0 +1,84 @@
import 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 ('TOWER_HOST', 'TOWER_USERNAME', 'TOWER_PASSWORD'):
if not os.environ.get(e):
raise SystemExit(
'Please specify a valid {} for a real (running) Tower install.'.format(e) # 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()

116
awxkit/awxkit/cli/stdout.py Normal file
View File

@@ -0,0 +1,116 @@
import sys
import time
from .utils import cprint, color_enabled, STATUS_COLORS
def monitor_workflow(response, session, print_stdout=True, timeout=None,
interval=.25):
get = response.url.get
payload = {
'order_by': 'finished',
'unified_job_node__workflow_job': response.id,
}
def fetch(seen):
results = response.connection.get(
'/api/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:
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(get().json.name))
started = time.time()
seen = set()
while True:
if timeout and time.time() - started > timeout:
if print_stdout:
cprint('Monitoring aborted due to 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(.25)
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, timeout=None, interval=.25):
get = response.url.get
payload = {'order_by': 'start_line'}
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 = 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 timeout and time.time() - started > timeout:
if print_stdout:
cprint('Monitoring aborted due to timeout.', 'red')
break
next_line = fetch(next_line)
if next_line:
payload['start_line__gte'] = next_line
time.sleep(.25)
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

View File

@@ -0,0 +1,76 @@
from argparse import ArgumentParser
import threading
import sys
import termcolor
_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',
'cancelled': '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
def colored(value, color):
if _color.enabled:
return termcolor.colored(value, color)
else:
return value
def cprint(value, color, **kwargs):
if _color.enabled:
termcolor.cprint(value, color, **kwargs)
else:
print(value, **kwargs)