mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
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:
55
awxkit/awxkit/cli/__init__.py
Normal file
55
awxkit/awxkit/cli/__init__.py
Normal 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
289
awxkit/awxkit/cli/client.py
Executable 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
216
awxkit/awxkit/cli/custom.py
Normal 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]})
|
||||
20
awxkit/awxkit/cli/docs/Makefile
Normal file
20
awxkit/awxkit/cli/docs/Makefile
Normal 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)
|
||||
5
awxkit/awxkit/cli/docs/README
Normal file
5
awxkit/awxkit/cli/docs/README
Normal 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/) ..
|
||||
35
awxkit/awxkit/cli/docs/make.bat
Normal file
35
awxkit/awxkit/cli/docs/make.bat
Normal 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
|
||||
69
awxkit/awxkit/cli/docs/source/authentication.rst
Normal file
69
awxkit/awxkit/cli/docs/source/authentication.rst
Normal 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
|
||||
59
awxkit/awxkit/cli/docs/source/conf.py
Normal file
59
awxkit/awxkit/cli/docs/source/conf.py
Normal 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
|
||||
'''
|
||||
60
awxkit/awxkit/cli/docs/source/examples.rst
Normal file
60
awxkit/awxkit/cli/docs/source/examples.rst
Normal 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
|
||||
36
awxkit/awxkit/cli/docs/source/index.rst
Normal file
36
awxkit/awxkit/cli/docs/source/index.rst
Normal 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`
|
||||
59
awxkit/awxkit/cli/docs/source/output.rst
Normal file
59
awxkit/awxkit/cli/docs/source/output.rst
Normal 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``.
|
||||
3
awxkit/awxkit/cli/docs/source/reference.rst
Normal file
3
awxkit/awxkit/cli/docs/source/reference.rst
Normal file
@@ -0,0 +1,3 @@
|
||||
.. autoprogram:: awxkit.cli.sphinx:parser
|
||||
:prog: awx
|
||||
:maxdepth: 3
|
||||
99
awxkit/awxkit/cli/docs/source/usage.rst
Normal file
99
awxkit/awxkit/cli/docs/source/usage.rst
Normal 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
166
awxkit/awxkit/cli/format.py
Normal 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
|
||||
}
|
||||
112
awxkit/awxkit/cli/options.py
Normal file
112
awxkit/awxkit/cli/options.py
Normal 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)
|
||||
158
awxkit/awxkit/cli/resource.py
Normal file
158
awxkit/awxkit/cli/resource.py
Normal 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
|
||||
84
awxkit/awxkit/cli/sphinx.py
Normal file
84
awxkit/awxkit/cli/sphinx.py
Normal 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
116
awxkit/awxkit/cli/stdout.py
Normal 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
|
||||
76
awxkit/awxkit/cli/utils.py
Normal file
76
awxkit/awxkit/cli/utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user