summaryrefslogtreecommitdiff
path: root/lib/ansible/cli
diff options
context:
space:
mode:
Diffstat (limited to 'lib/ansible/cli')
-rw-r--r--lib/ansible/cli/__init__.py32
-rwxr-xr-xlib/ansible/cli/adhoc.py2
-rw-r--r--lib/ansible/cli/arguments/option_helpers.py20
-rwxr-xr-xlib/ansible/cli/config.py42
-rwxr-xr-xlib/ansible/cli/console.py38
-rwxr-xr-xlib/ansible/cli/doc.py85
-rwxr-xr-xlib/ansible/cli/galaxy.py273
-rwxr-xr-xlib/ansible/cli/inventory.py100
-rwxr-xr-xlib/ansible/cli/playbook.py13
-rwxr-xr-xlib/ansible/cli/pull.py11
-rwxr-xr-xlib/ansible/cli/scripts/ansible_connection_cli_stub.py6
-rwxr-xr-xlib/ansible/cli/vault.py22
12 files changed, 408 insertions, 236 deletions
diff --git a/lib/ansible/cli/__init__.py b/lib/ansible/cli/__init__.py
index 15ab5fe1..91d6a969 100644
--- a/lib/ansible/cli/__init__.py
+++ b/lib/ansible/cli/__init__.py
@@ -13,9 +13,9 @@ import sys
# Used for determining if the system is running a new enough python version
# and should only restrict on our documented minimum versions
-if sys.version_info < (3, 9):
+if sys.version_info < (3, 10):
raise SystemExit(
- 'ERROR: Ansible requires Python 3.9 or newer on the controller. '
+ 'ERROR: Ansible requires Python 3.10 or newer on the controller. '
'Current version: %s' % ''.join(sys.version.splitlines())
)
@@ -97,11 +97,12 @@ from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleError, AnsibleOptionsError, AnsibleParserError
from ansible.inventory.manager import InventoryManager
from ansible.module_utils.six import string_types
-from ansible.module_utils._text import to_bytes, to_text
+from ansible.module_utils.common.text.converters import to_bytes, to_text
+from ansible.module_utils.common.collections import is_sequence
from ansible.module_utils.common.file import is_executable
from ansible.parsing.dataloader import DataLoader
from ansible.parsing.vault import PromptVaultSecret, get_file_vault_secret
-from ansible.plugins.loader import add_all_plugin_dirs
+from ansible.plugins.loader import add_all_plugin_dirs, init_plugin_loader
from ansible.release import __version__
from ansible.utils.collection_loader import AnsibleCollectionConfig
from ansible.utils.collection_loader._collection_finder import _get_collection_name_from_path
@@ -119,7 +120,7 @@ except ImportError:
class CLI(ABC):
''' code behind bin/ansible* programs '''
- PAGER = 'less'
+ PAGER = C.config.get_config_value('PAGER')
# -F (quit-if-one-screen) -R (allow raw ansi control chars)
# -S (chop long lines) -X (disable termcap init and de-init)
@@ -154,6 +155,13 @@ class CLI(ABC):
"""
self.parse()
+ # Initialize plugin loader after parse, so that the init code can utilize parsed arguments
+ cli_collections_path = context.CLIARGS.get('collections_path') or []
+ if not is_sequence(cli_collections_path):
+ # In some contexts ``collections_path`` is singular
+ cli_collections_path = [cli_collections_path]
+ init_plugin_loader(cli_collections_path)
+
display.vv(to_text(opt_help.version(self.parser.prog)))
if C.CONFIG_FILE:
@@ -494,11 +502,11 @@ class CLI(ABC):
# this is a much simpler form of what is in pydoc.py
if not sys.stdout.isatty():
display.display(text, screen_only=True)
- elif 'PAGER' in os.environ:
+ elif CLI.PAGER:
if sys.platform == 'win32':
display.display(text, screen_only=True)
else:
- CLI.pager_pipe(text, os.environ['PAGER'])
+ CLI.pager_pipe(text)
else:
p = subprocess.Popen('less --version', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.communicate()
@@ -508,12 +516,12 @@ class CLI(ABC):
display.display(text, screen_only=True)
@staticmethod
- def pager_pipe(text, cmd):
+ def pager_pipe(text):
''' pipe text through a pager '''
- if 'LESS' not in os.environ:
+ if 'less' in CLI.PAGER:
os.environ['LESS'] = CLI.LESS_OPTS
try:
- cmd = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=sys.stdout)
+ cmd = subprocess.Popen(CLI.PAGER, shell=True, stdin=subprocess.PIPE, stdout=sys.stdout)
cmd.communicate(input=to_bytes(text))
except IOError:
pass
@@ -522,6 +530,10 @@ class CLI(ABC):
@staticmethod
def _play_prereqs():
+ # TODO: evaluate moving all of the code that touches ``AnsibleCollectionConfig``
+ # into ``init_plugin_loader`` so that we can specifically remove
+ # ``AnsibleCollectionConfig.playbook_paths`` to make it immutable after instantiation
+
options = context.CLIARGS
# all needs loader
diff --git a/lib/ansible/cli/adhoc.py b/lib/ansible/cli/adhoc.py
index e90b44ce..a54dacb7 100755
--- a/lib/ansible/cli/adhoc.py
+++ b/lib/ansible/cli/adhoc.py
@@ -14,7 +14,7 @@ from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleError, AnsibleOptionsError, AnsibleParserError
from ansible.executor.task_queue_manager import TaskQueueManager
-from ansible.module_utils._text import to_text
+from ansible.module_utils.common.text.converters import to_text
from ansible.parsing.splitter import parse_kv
from ansible.parsing.utils.yaml import from_yaml
from ansible.playbook import Playbook
diff --git a/lib/ansible/cli/arguments/option_helpers.py b/lib/ansible/cli/arguments/option_helpers.py
index a3efb1e2..3baaf255 100644
--- a/lib/ansible/cli/arguments/option_helpers.py
+++ b/lib/ansible/cli/arguments/option_helpers.py
@@ -16,7 +16,7 @@ from jinja2 import __version__ as j2_version
import ansible
from ansible import constants as C
-from ansible.module_utils._text import to_native
+from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.common.yaml import HAS_LIBYAML, yaml_load
from ansible.release import __version__
from ansible.utils.path import unfrackpath
@@ -31,6 +31,16 @@ class SortingHelpFormatter(argparse.HelpFormatter):
super(SortingHelpFormatter, self).add_arguments(actions)
+class ArgumentParser(argparse.ArgumentParser):
+ def add_argument(self, *args, **kwargs):
+ action = kwargs.get('action')
+ help = kwargs.get('help')
+ if help and action in {'append', 'append_const', 'count', 'extend', PrependListAction}:
+ help = f'{help.rstrip(".")}. This argument may be specified multiple times.'
+ kwargs['help'] = help
+ return super().add_argument(*args, **kwargs)
+
+
class AnsibleVersion(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
ansible_version = to_native(version(getattr(parser, 'prog')))
@@ -192,7 +202,7 @@ def create_base_parser(prog, usage="", desc=None, epilog=None):
Create an options parser for all ansible scripts
"""
# base opts
- parser = argparse.ArgumentParser(
+ parser = ArgumentParser(
prog=prog,
formatter_class=SortingHelpFormatter,
epilog=epilog,
@@ -250,8 +260,8 @@ def add_connect_options(parser):
help='connect as this user (default=%s)' % C.DEFAULT_REMOTE_USER)
connect_group.add_argument('-c', '--connection', dest='connection', default=C.DEFAULT_TRANSPORT,
help="connection type to use (default=%s)" % C.DEFAULT_TRANSPORT)
- connect_group.add_argument('-T', '--timeout', default=C.DEFAULT_TIMEOUT, type=int, dest='timeout',
- help="override the connection timeout in seconds (default=%s)" % C.DEFAULT_TIMEOUT)
+ connect_group.add_argument('-T', '--timeout', default=None, type=int, dest='timeout',
+ help="override the connection timeout in seconds (default depends on connection)")
# ssh only
connect_group.add_argument('--ssh-common-args', default=None, dest='ssh_common_args',
@@ -383,7 +393,7 @@ def add_vault_options(parser):
parser.add_argument('--vault-id', default=[], dest='vault_ids', action='append', type=str,
help='the vault identity to use')
base_group = parser.add_mutually_exclusive_group()
- base_group.add_argument('--ask-vault-password', '--ask-vault-pass', default=C.DEFAULT_ASK_VAULT_PASS, dest='ask_vault_pass', action='store_true',
+ base_group.add_argument('-J', '--ask-vault-password', '--ask-vault-pass', default=C.DEFAULT_ASK_VAULT_PASS, dest='ask_vault_pass', action='store_true',
help='ask for vault password')
base_group.add_argument('--vault-password-file', '--vault-pass-file', default=[], dest='vault_password_files',
help="vault password file", type=unfrack_path(follow=False), action='append')
diff --git a/lib/ansible/cli/config.py b/lib/ansible/cli/config.py
index c8d99ea0..f394ef7c 100755
--- a/lib/ansible/cli/config.py
+++ b/lib/ansible/cli/config.py
@@ -23,7 +23,7 @@ from ansible import constants as C
from ansible.cli.arguments import option_helpers as opt_help
from ansible.config.manager import ConfigManager, Setting
from ansible.errors import AnsibleError, AnsibleOptionsError
-from ansible.module_utils._text import to_native, to_text, to_bytes
+from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
from ansible.module_utils.common.json import json_dump
from ansible.module_utils.six import string_types
from ansible.parsing.quoting import is_quoted
@@ -67,7 +67,7 @@ class ConfigCLI(CLI):
desc="View ansible configuration.",
)
- common = opt_help.argparse.ArgumentParser(add_help=False)
+ common = opt_help.ArgumentParser(add_help=False)
opt_help.add_verbosity_options(common)
common.add_argument('-c', '--config', dest='config_file',
help="path to configuration file, defaults to first file found in precedence.")
@@ -187,7 +187,7 @@ class ConfigCLI(CLI):
# pylint: disable=unreachable
try:
- editor = shlex.split(os.environ.get('EDITOR', 'vi'))
+ editor = shlex.split(C.config.get_config_value('EDITOR'))
editor.append(self.config_file)
subprocess.call(editor)
except Exception as e:
@@ -314,7 +314,7 @@ class ConfigCLI(CLI):
return data
- def _get_settings_ini(self, settings):
+ def _get_settings_ini(self, settings, seen):
sections = {}
for o in sorted(settings.keys()):
@@ -327,7 +327,7 @@ class ConfigCLI(CLI):
if not opt.get('description'):
# its a plugin
- new_sections = self._get_settings_ini(opt)
+ new_sections = self._get_settings_ini(opt, seen)
for s in new_sections:
if s in sections:
sections[s].extend(new_sections[s])
@@ -343,37 +343,45 @@ class ConfigCLI(CLI):
if 'ini' in opt and opt['ini']:
entry = opt['ini'][-1]
+ if entry['section'] not in seen:
+ seen[entry['section']] = []
if entry['section'] not in sections:
sections[entry['section']] = []
- default = opt.get('default', '')
- if opt.get('type', '') == 'list' and not isinstance(default, string_types):
- # python lists are not valid ini ones
- default = ', '.join(default)
- elif default is None:
- default = ''
+ # avoid dupes
+ if entry['key'] not in seen[entry['section']]:
+ seen[entry['section']].append(entry['key'])
+
+ default = opt.get('default', '')
+ if opt.get('type', '') == 'list' and not isinstance(default, string_types):
+ # python lists are not valid ini ones
+ default = ', '.join(default)
+ elif default is None:
+ default = ''
+
+ if context.CLIARGS['commented']:
+ entry['key'] = ';%s' % entry['key']
- if context.CLIARGS['commented']:
- entry['key'] = ';%s' % entry['key']
+ key = desc + '\n%s=%s' % (entry['key'], default)
- key = desc + '\n%s=%s' % (entry['key'], default)
- sections[entry['section']].append(key)
+ sections[entry['section']].append(key)
return sections
def execute_init(self):
"""Create initial configuration"""
+ seen = {}
data = []
config_entries = self._list_entries_from_args()
plugin_types = config_entries.pop('PLUGINS', None)
if context.CLIARGS['format'] == 'ini':
- sections = self._get_settings_ini(config_entries)
+ sections = self._get_settings_ini(config_entries, seen)
if plugin_types:
for ptype in plugin_types:
- plugin_sections = self._get_settings_ini(plugin_types[ptype])
+ plugin_sections = self._get_settings_ini(plugin_types[ptype], seen)
for s in plugin_sections:
if s in sections:
sections[s].extend(plugin_sections[s])
diff --git a/lib/ansible/cli/console.py b/lib/ansible/cli/console.py
index 3125cc47..2325bf05 100755
--- a/lib/ansible/cli/console.py
+++ b/lib/ansible/cli/console.py
@@ -22,7 +22,7 @@ from ansible import constants as C
from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.executor.task_queue_manager import TaskQueueManager
-from ansible.module_utils._text import to_native, to_text
+from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.parsing.splitter import parse_kv
from ansible.playbook.play import Play
@@ -39,26 +39,30 @@ class ConsoleCLI(CLI, cmd.Cmd):
'''
A REPL that allows for running ad-hoc tasks against a chosen inventory
from a nice shell with built-in tab completion (based on dominis'
- ansible-shell).
+ ``ansible-shell``).
It supports several commands, and you can modify its configuration at
runtime:
- - `cd [pattern]`: change host/group (you can use host patterns eg.: app*.dc*:!app01*)
- - `list`: list available hosts in the current path
- - `list groups`: list groups included in the current path
- - `become`: toggle the become flag
- - `!`: forces shell module instead of the ansible module (!yum update -y)
- - `verbosity [num]`: set the verbosity level
- - `forks [num]`: set the number of forks
- - `become_user [user]`: set the become_user
- - `remote_user [user]`: set the remote_user
- - `become_method [method]`: set the privilege escalation method
- - `check [bool]`: toggle check mode
- - `diff [bool]`: toggle diff mode
- - `timeout [integer]`: set the timeout of tasks in seconds (0 to disable)
- - `help [command/module]`: display documentation for the command or module
- - `exit`: exit ansible-console
+ - ``cd [pattern]``: change host/group
+ (you can use host patterns eg.: ``app*.dc*:!app01*``)
+ - ``list``: list available hosts in the current path
+ - ``list groups``: list groups included in the current path
+ - ``become``: toggle the become flag
+ - ``!``: forces shell module instead of the ansible module
+ (``!yum update -y``)
+ - ``verbosity [num]``: set the verbosity level
+ - ``forks [num]``: set the number of forks
+ - ``become_user [user]``: set the become_user
+ - ``remote_user [user]``: set the remote_user
+ - ``become_method [method]``: set the privilege escalation method
+ - ``check [bool]``: toggle check mode
+ - ``diff [bool]``: toggle diff mode
+ - ``timeout [integer]``: set the timeout of tasks in seconds
+ (0 to disable)
+ - ``help [command/module]``: display documentation for
+ the command or module
+ - ``exit``: exit ``ansible-console``
'''
name = 'ansible-console'
diff --git a/lib/ansible/cli/doc.py b/lib/ansible/cli/doc.py
index 9f560bcb..4a5c8928 100755
--- a/lib/ansible/cli/doc.py
+++ b/lib/ansible/cli/doc.py
@@ -26,7 +26,7 @@ from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.collections.list import list_collection_dirs
from ansible.errors import AnsibleError, AnsibleOptionsError, AnsibleParserError, AnsiblePluginNotFound
-from ansible.module_utils._text import to_native, to_text
+from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.module_utils.common.collections import is_sequence
from ansible.module_utils.common.json import json_dump
from ansible.module_utils.common.yaml import yaml_dump
@@ -163,8 +163,8 @@ class RoleMixin(object):
might be fully qualified with the collection name (e.g., community.general.roleA)
or not (e.g., roleA).
- :param collection_filter: A string containing the FQCN of a collection which will be
- used to limit results. This filter will take precedence over the name_filters.
+ :param collection_filter: A list of strings containing the FQCN of a collection which will
+ be used to limit results. This filter will take precedence over the name_filters.
:returns: A set of tuples consisting of: role name, collection name, collection path
"""
@@ -362,12 +362,23 @@ class DocCLI(CLI, RoleMixin):
_ITALIC = re.compile(r"\bI\(([^)]+)\)")
_BOLD = re.compile(r"\bB\(([^)]+)\)")
_MODULE = re.compile(r"\bM\(([^)]+)\)")
+ _PLUGIN = re.compile(r"\bP\(([^#)]+)#([a-z]+)\)")
_LINK = re.compile(r"\bL\(([^)]+), *([^)]+)\)")
_URL = re.compile(r"\bU\(([^)]+)\)")
_REF = re.compile(r"\bR\(([^)]+), *([^)]+)\)")
_CONST = re.compile(r"\bC\(([^)]+)\)")
+ _SEM_PARAMETER_STRING = r"\(((?:[^\\)]+|\\.)+)\)"
+ _SEM_OPTION_NAME = re.compile(r"\bO" + _SEM_PARAMETER_STRING)
+ _SEM_OPTION_VALUE = re.compile(r"\bV" + _SEM_PARAMETER_STRING)
+ _SEM_ENV_VARIABLE = re.compile(r"\bE" + _SEM_PARAMETER_STRING)
+ _SEM_RET_VALUE = re.compile(r"\bRV" + _SEM_PARAMETER_STRING)
_RULER = re.compile(r"\bHORIZONTALLINE\b")
+ # helper for unescaping
+ _UNESCAPE = re.compile(r"\\(.)")
+ _FQCN_TYPE_PREFIX_RE = re.compile(r'^([^.]+\.[^.]+\.[^#]+)#([a-z]+):(.*)$')
+ _IGNORE_MARKER = 'ignore:'
+
# rst specific
_RST_NOTE = re.compile(r".. note::")
_RST_SEEALSO = re.compile(r".. seealso::")
@@ -379,6 +390,40 @@ class DocCLI(CLI, RoleMixin):
super(DocCLI, self).__init__(args)
self.plugin_list = set()
+ @staticmethod
+ def _tty_ify_sem_simle(matcher):
+ text = DocCLI._UNESCAPE.sub(r'\1', matcher.group(1))
+ return f"`{text}'"
+
+ @staticmethod
+ def _tty_ify_sem_complex(matcher):
+ text = DocCLI._UNESCAPE.sub(r'\1', matcher.group(1))
+ value = None
+ if '=' in text:
+ text, value = text.split('=', 1)
+ m = DocCLI._FQCN_TYPE_PREFIX_RE.match(text)
+ if m:
+ plugin_fqcn = m.group(1)
+ plugin_type = m.group(2)
+ text = m.group(3)
+ elif text.startswith(DocCLI._IGNORE_MARKER):
+ text = text[len(DocCLI._IGNORE_MARKER):]
+ plugin_fqcn = plugin_type = ''
+ else:
+ plugin_fqcn = plugin_type = ''
+ entrypoint = None
+ if ':' in text:
+ entrypoint, text = text.split(':', 1)
+ if value is not None:
+ text = f"{text}={value}"
+ if plugin_fqcn and plugin_type:
+ plugin_suffix = '' if plugin_type in ('role', 'module', 'playbook') else ' plugin'
+ plugin = f"{plugin_type}{plugin_suffix} {plugin_fqcn}"
+ if plugin_type == 'role' and entrypoint is not None:
+ plugin = f"{plugin}, {entrypoint} entrypoint"
+ return f"`{text}' (of {plugin})"
+ return f"`{text}'"
+
@classmethod
def find_plugins(cls, path, internal, plugin_type, coll_filter=None):
display.deprecated("find_plugins method as it is incomplete/incorrect. use ansible.plugins.list functions instead.", version='2.17')
@@ -393,8 +438,13 @@ class DocCLI(CLI, RoleMixin):
t = cls._MODULE.sub("[" + r"\1" + "]", t) # M(word) => [word]
t = cls._URL.sub(r"\1", t) # U(word) => word
t = cls._LINK.sub(r"\1 <\2>", t) # L(word, url) => word <url>
+ t = cls._PLUGIN.sub("[" + r"\1" + "]", t) # P(word#type) => [word]
t = cls._REF.sub(r"\1", t) # R(word, sphinx-ref) => word
t = cls._CONST.sub(r"`\1'", t) # C(word) => `word'
+ t = cls._SEM_OPTION_NAME.sub(cls._tty_ify_sem_complex, t) # O(expr)
+ t = cls._SEM_OPTION_VALUE.sub(cls._tty_ify_sem_simle, t) # V(expr)
+ t = cls._SEM_ENV_VARIABLE.sub(cls._tty_ify_sem_simle, t) # E(expr)
+ t = cls._SEM_RET_VALUE.sub(cls._tty_ify_sem_complex, t) # RV(expr)
t = cls._RULER.sub("\n{0}\n".format("-" * 13), t) # HORIZONTALLINE => -------
# remove rst
@@ -495,7 +545,9 @@ class DocCLI(CLI, RoleMixin):
desc = desc[:linelimit] + '...'
pbreak = plugin.split('.')
- if pbreak[-1].startswith('_'): # Handle deprecated # TODO: add mark for deprecated collection plugins
+ # TODO: add mark for deprecated collection plugins
+ if pbreak[-1].startswith('_') and plugin.startswith(('ansible.builtin.', 'ansible.legacy.')):
+ # Handle deprecated ansible.builtin plugins
pbreak[-1] = pbreak[-1][1:]
plugin = '.'.join(pbreak)
deprecated.append("%-*s %-*.*s" % (displace, plugin, linelimit, len(desc), desc))
@@ -626,12 +678,11 @@ class DocCLI(CLI, RoleMixin):
def _get_collection_filter(self):
coll_filter = None
- if len(context.CLIARGS['args']) == 1:
- coll_filter = context.CLIARGS['args'][0]
- if not AnsibleCollectionRef.is_valid_collection_name(coll_filter):
- raise AnsibleError('Invalid collection name (must be of the form namespace.collection): {0}'.format(coll_filter))
- elif len(context.CLIARGS['args']) > 1:
- raise AnsibleOptionsError("Only a single collection filter is supported.")
+ if len(context.CLIARGS['args']) >= 1:
+ coll_filter = context.CLIARGS['args']
+ for coll_name in coll_filter:
+ if not AnsibleCollectionRef.is_valid_collection_name(coll_name):
+ raise AnsibleError('Invalid collection name (must be of the form namespace.collection): {0}'.format(coll_name))
return coll_filter
@@ -1251,6 +1302,20 @@ class DocCLI(CLI, RoleMixin):
relative_url = 'collections/%s_module.html' % item['module'].replace('.', '/', 2)
text.append(textwrap.fill(DocCLI.tty_ify(get_versioned_doclink(relative_url)),
limit - 6, initial_indent=opt_indent + ' ', subsequent_indent=opt_indent))
+ elif 'plugin' in item and 'plugin_type' in item:
+ plugin_suffix = ' plugin' if item['plugin_type'] not in ('module', 'role') else ''
+ text.append(textwrap.fill(DocCLI.tty_ify('%s%s %s' % (item['plugin_type'].title(), plugin_suffix, item['plugin'])),
+ limit - 6, initial_indent=opt_indent[:-2] + "* ", subsequent_indent=opt_indent))
+ description = item.get('description')
+ if description is None and item['plugin'].startswith('ansible.builtin.'):
+ description = 'The official documentation on the %s %s%s.' % (item['plugin'], item['plugin_type'], plugin_suffix)
+ if description is not None:
+ text.append(textwrap.fill(DocCLI.tty_ify(description),
+ limit - 6, initial_indent=opt_indent + ' ', subsequent_indent=opt_indent + ' '))
+ if item['plugin'].startswith('ansible.builtin.'):
+ relative_url = 'collections/%s_%s.html' % (item['plugin'].replace('.', '/', 2), item['plugin_type'])
+ text.append(textwrap.fill(DocCLI.tty_ify(get_versioned_doclink(relative_url)),
+ limit - 6, initial_indent=opt_indent + ' ', subsequent_indent=opt_indent))
elif 'name' in item and 'link' in item and 'description' in item:
text.append(textwrap.fill(DocCLI.tty_ify(item['name']),
limit - 6, initial_indent=opt_indent[:-2] + "* ", subsequent_indent=opt_indent))
diff --git a/lib/ansible/cli/galaxy.py b/lib/ansible/cli/galaxy.py
index 536964e2..334e4bf4 100755
--- a/lib/ansible/cli/galaxy.py
+++ b/lib/ansible/cli/galaxy.py
@@ -10,9 +10,11 @@ __metaclass__ = type
# ansible.cli needs to be imported first, to ensure the source bin/* scripts run that code first
from ansible.cli import CLI
+import argparse
import functools
import json
import os.path
+import pathlib
import re
import shutil
import sys
@@ -51,7 +53,7 @@ from ansible.galaxy.token import BasicAuthToken, GalaxyToken, KeycloakToken, NoT
from ansible.module_utils.ansible_release import __version__ as ansible_version
from ansible.module_utils.common.collections import is_iterable
from ansible.module_utils.common.yaml import yaml_dump, yaml_load
-from ansible.module_utils._text import to_bytes, to_native, to_text
+from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
from ansible.module_utils import six
from ansible.parsing.dataloader import DataLoader
from ansible.parsing.yaml.loader import AnsibleLoader
@@ -71,7 +73,7 @@ SERVER_DEF = [
('password', False, 'str'),
('token', False, 'str'),
('auth_url', False, 'str'),
- ('v3', False, 'bool'),
+ ('api_version', False, 'int'),
('validate_certs', False, 'bool'),
('client_id', False, 'str'),
('timeout', False, 'int'),
@@ -79,9 +81,9 @@ SERVER_DEF = [
# config definition fields
SERVER_ADDITIONAL = {
- 'v3': {'default': 'False'},
+ 'api_version': {'default': None, 'choices': [2, 3]},
'validate_certs': {'cli': [{'name': 'validate_certs'}]},
- 'timeout': {'default': '60', 'cli': [{'name': 'timeout'}]},
+ 'timeout': {'default': C.GALAXY_SERVER_TIMEOUT, 'cli': [{'name': 'timeout'}]},
'token': {'default': None},
}
@@ -99,7 +101,8 @@ def with_collection_artifacts_manager(wrapped_method):
return wrapped_method(*args, **kwargs)
# FIXME: use validate_certs context from Galaxy servers when downloading collections
- artifacts_manager_kwargs = {'validate_certs': context.CLIARGS['resolved_validate_certs']}
+ # .get used here for when this is used in a non-CLI context
+ artifacts_manager_kwargs = {'validate_certs': context.CLIARGS.get('resolved_validate_certs', True)}
keyring = context.CLIARGS.get('keyring', None)
if keyring is not None:
@@ -156,8 +159,8 @@ def _get_collection_widths(collections):
fqcn_set = {to_text(c.fqcn) for c in collections}
version_set = {to_text(c.ver) for c in collections}
- fqcn_length = len(max(fqcn_set, key=len))
- version_length = len(max(version_set, key=len))
+ fqcn_length = len(max(fqcn_set or [''], key=len))
+ version_length = len(max(version_set or [''], key=len))
return fqcn_length, version_length
@@ -238,45 +241,49 @@ class GalaxyCLI(CLI):
)
# Common arguments that apply to more than 1 action
- common = opt_help.argparse.ArgumentParser(add_help=False)
+ common = opt_help.ArgumentParser(add_help=False)
common.add_argument('-s', '--server', dest='api_server', help='The Galaxy API server URL')
+ common.add_argument('--api-version', type=int, choices=[2, 3], help=argparse.SUPPRESS) # Hidden argument that should only be used in our tests
common.add_argument('--token', '--api-key', dest='api_key',
help='The Ansible Galaxy API key which can be found at '
'https://galaxy.ansible.com/me/preferences.')
common.add_argument('-c', '--ignore-certs', action='store_true', dest='ignore_certs', help='Ignore SSL certificate validation errors.', default=None)
- common.add_argument('--timeout', dest='timeout', type=int, default=60,
+
+ # --timeout uses the default None to handle two different scenarios.
+ # * --timeout > C.GALAXY_SERVER_TIMEOUT for non-configured servers
+ # * --timeout > server-specific timeout > C.GALAXY_SERVER_TIMEOUT for configured servers.
+ common.add_argument('--timeout', dest='timeout', type=int,
help="The time to wait for operations against the galaxy server, defaults to 60s.")
opt_help.add_verbosity_options(common)
- force = opt_help.argparse.ArgumentParser(add_help=False)
+ force = opt_help.ArgumentParser(add_help=False)
force.add_argument('-f', '--force', dest='force', action='store_true', default=False,
help='Force overwriting an existing role or collection')
- github = opt_help.argparse.ArgumentParser(add_help=False)
+ github = opt_help.ArgumentParser(add_help=False)
github.add_argument('github_user', help='GitHub username')
github.add_argument('github_repo', help='GitHub repository')
- offline = opt_help.argparse.ArgumentParser(add_help=False)
+ offline = opt_help.ArgumentParser(add_help=False)
offline.add_argument('--offline', dest='offline', default=False, action='store_true',
help="Don't query the galaxy API when creating roles")
default_roles_path = C.config.get_configuration_definition('DEFAULT_ROLES_PATH').get('default', '')
- roles_path = opt_help.argparse.ArgumentParser(add_help=False)
+ roles_path = opt_help.ArgumentParser(add_help=False)
roles_path.add_argument('-p', '--roles-path', dest='roles_path', type=opt_help.unfrack_path(pathsep=True),
default=C.DEFAULT_ROLES_PATH, action=opt_help.PrependListAction,
help='The path to the directory containing your roles. The default is the first '
'writable one configured via DEFAULT_ROLES_PATH: %s ' % default_roles_path)
- collections_path = opt_help.argparse.ArgumentParser(add_help=False)
+ collections_path = opt_help.ArgumentParser(add_help=False)
collections_path.add_argument('-p', '--collections-path', dest='collections_path', type=opt_help.unfrack_path(pathsep=True),
- default=AnsibleCollectionConfig.collection_paths,
action=opt_help.PrependListAction,
help="One or more directories to search for collections in addition "
"to the default COLLECTIONS_PATHS. Separate multiple paths "
"with '{0}'.".format(os.path.pathsep))
- cache_options = opt_help.argparse.ArgumentParser(add_help=False)
+ cache_options = opt_help.ArgumentParser(add_help=False)
cache_options.add_argument('--clear-response-cache', dest='clear_response_cache', action='store_true',
default=False, help='Clear the existing server response cache.')
cache_options.add_argument('--no-cache', dest='no_cache', action='store_true', default=False,
@@ -460,12 +467,15 @@ class GalaxyCLI(CLI):
valid_signature_count_help = 'The number of signatures that must successfully verify the collection. This should be a positive integer ' \
'or all to signify that all signatures must be used to verify the collection. ' \
'Prepend the value with + to fail if no valid signatures are found for the collection (e.g. +all).'
- ignore_gpg_status_help = 'A status code to ignore during signature verification (for example, NO_PUBKEY). ' \
- 'Provide this option multiple times to ignore a list of status codes. ' \
- 'Descriptions for the choices can be seen at L(https://github.com/gpg/gnupg/blob/master/doc/DETAILS#general-status-codes).'
+ ignore_gpg_status_help = 'A space separated list of status codes to ignore during signature verification (for example, NO_PUBKEY FAILURE). ' \
+ 'Descriptions for the choices can be seen at L(https://github.com/gpg/gnupg/blob/master/doc/DETAILS#general-status-codes).' \
+ 'Note: specify these after positional arguments or use -- to separate them.'
verify_parser.add_argument('--required-valid-signature-count', dest='required_valid_signature_count', type=validate_signature_count,
help=valid_signature_count_help, default=C.GALAXY_REQUIRED_VALID_SIGNATURE_COUNT)
verify_parser.add_argument('--ignore-signature-status-code', dest='ignore_gpg_errors', type=str, action='append',
+ help=opt_help.argparse.SUPPRESS, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
+ choices=list(GPG_ERROR_MAP.keys()))
+ verify_parser.add_argument('--ignore-signature-status-codes', dest='ignore_gpg_errors', type=str, action='extend', nargs='+',
help=ignore_gpg_status_help, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
choices=list(GPG_ERROR_MAP.keys()))
@@ -501,9 +511,9 @@ class GalaxyCLI(CLI):
valid_signature_count_help = 'The number of signatures that must successfully verify the collection. This should be a positive integer ' \
'or -1 to signify that all signatures must be used to verify the collection. ' \
'Prepend the value with + to fail if no valid signatures are found for the collection (e.g. +all).'
- ignore_gpg_status_help = 'A status code to ignore during signature verification (for example, NO_PUBKEY). ' \
- 'Provide this option multiple times to ignore a list of status codes. ' \
- 'Descriptions for the choices can be seen at L(https://github.com/gpg/gnupg/blob/master/doc/DETAILS#general-status-codes).'
+ ignore_gpg_status_help = 'A space separated list of status codes to ignore during signature verification (for example, NO_PUBKEY FAILURE). ' \
+ 'Descriptions for the choices can be seen at L(https://github.com/gpg/gnupg/blob/master/doc/DETAILS#general-status-codes).' \
+ 'Note: specify these after positional arguments or use -- to separate them.'
if galaxy_type == 'collection':
install_parser.add_argument('-p', '--collections-path', dest='collections_path',
@@ -527,6 +537,9 @@ class GalaxyCLI(CLI):
install_parser.add_argument('--required-valid-signature-count', dest='required_valid_signature_count', type=validate_signature_count,
help=valid_signature_count_help, default=C.GALAXY_REQUIRED_VALID_SIGNATURE_COUNT)
install_parser.add_argument('--ignore-signature-status-code', dest='ignore_gpg_errors', type=str, action='append',
+ help=opt_help.argparse.SUPPRESS, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
+ choices=list(GPG_ERROR_MAP.keys()))
+ install_parser.add_argument('--ignore-signature-status-codes', dest='ignore_gpg_errors', type=str, action='extend', nargs='+',
help=ignore_gpg_status_help, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
choices=list(GPG_ERROR_MAP.keys()))
install_parser.add_argument('--offline', dest='offline', action='store_true', default=False,
@@ -551,6 +564,9 @@ class GalaxyCLI(CLI):
install_parser.add_argument('--required-valid-signature-count', dest='required_valid_signature_count', type=validate_signature_count,
help=valid_signature_count_help, default=C.GALAXY_REQUIRED_VALID_SIGNATURE_COUNT)
install_parser.add_argument('--ignore-signature-status-code', dest='ignore_gpg_errors', type=str, action='append',
+ help=opt_help.argparse.SUPPRESS, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
+ choices=list(GPG_ERROR_MAP.keys()))
+ install_parser.add_argument('--ignore-signature-status-codes', dest='ignore_gpg_errors', type=str, action='extend', nargs='+',
help=ignore_gpg_status_help, default=C.GALAXY_IGNORE_INVALID_SIGNATURE_STATUS_CODES,
choices=list(GPG_ERROR_MAP.keys()))
@@ -622,7 +638,7 @@ class GalaxyCLI(CLI):
return config_def
galaxy_options = {}
- for optional_key in ['clear_response_cache', 'no_cache', 'timeout']:
+ for optional_key in ['clear_response_cache', 'no_cache']:
if optional_key in context.CLIARGS:
galaxy_options[optional_key] = context.CLIARGS[optional_key]
@@ -647,17 +663,22 @@ class GalaxyCLI(CLI):
client_id = server_options.pop('client_id')
token_val = server_options['token'] or NoTokenSentinel
username = server_options['username']
- v3 = server_options.pop('v3')
+ api_version = server_options.pop('api_version')
if server_options['validate_certs'] is None:
server_options['validate_certs'] = context.CLIARGS['resolved_validate_certs']
validate_certs = server_options['validate_certs']
- if v3:
- # This allows a user to explicitly indicate the server uses the /v3 API
- # This was added for testing against pulp_ansible and I'm not sure it has
- # a practical purpose outside of this use case. As such, this option is not
- # documented as of now
- server_options['available_api_versions'] = {'v3': '/v3'}
+ # This allows a user to explicitly force use of an API version when
+ # multiple versions are supported. This was added for testing
+ # against pulp_ansible and I'm not sure it has a practical purpose
+ # outside of this use case. As such, this option is not documented
+ # as of now
+ if api_version:
+ display.warning(
+ f'The specified "api_version" configuration for the galaxy server "{server_key}" is '
+ 'not a public configuration, and may be removed at any time without warning.'
+ )
+ server_options['available_api_versions'] = {'v%s' % api_version: '/v%s' % api_version}
# default case if no auth info is provided.
server_options['token'] = None
@@ -683,9 +704,17 @@ class GalaxyCLI(CLI):
))
cmd_server = context.CLIARGS['api_server']
+ if context.CLIARGS['api_version']:
+ api_version = context.CLIARGS['api_version']
+ display.warning(
+ 'The --api-version is not a public argument, and may be removed at any time without warning.'
+ )
+ galaxy_options['available_api_versions'] = {'v%s' % api_version: '/v%s' % api_version}
+
cmd_token = GalaxyToken(token=context.CLIARGS['api_key'])
validate_certs = context.CLIARGS['resolved_validate_certs']
+ default_server_timeout = context.CLIARGS['timeout'] if context.CLIARGS['timeout'] is not None else C.GALAXY_SERVER_TIMEOUT
if cmd_server:
# Cmd args take precedence over the config entry but fist check if the arg was a name and use that config
# entry, otherwise create a new API entry for the server specified.
@@ -697,6 +726,7 @@ class GalaxyCLI(CLI):
self.galaxy, 'cmd_arg', cmd_server, token=cmd_token,
priority=len(config_servers) + 1,
validate_certs=validate_certs,
+ timeout=default_server_timeout,
**galaxy_options
))
else:
@@ -708,6 +738,7 @@ class GalaxyCLI(CLI):
self.galaxy, 'default', C.GALAXY_SERVER, token=cmd_token,
priority=0,
validate_certs=validate_certs,
+ timeout=default_server_timeout,
**galaxy_options
))
@@ -804,7 +835,7 @@ class GalaxyCLI(CLI):
for role_req in file_requirements:
requirements['roles'] += parse_role_req(role_req)
- else:
+ elif isinstance(file_requirements, dict):
# Newer format with a collections and/or roles key
extra_keys = set(file_requirements.keys()).difference(set(['roles', 'collections']))
if extra_keys:
@@ -823,6 +854,9 @@ class GalaxyCLI(CLI):
for collection_req in file_requirements.get('collections') or []
]
+ else:
+ raise AnsibleError(f"Expecting requirements yaml to be a list or dictionary but got {type(file_requirements).__name__}")
+
return requirements
def _init_coll_req_dict(self, coll_req):
@@ -1186,11 +1220,16 @@ class GalaxyCLI(CLI):
df.write(b_rendered)
else:
f_rel_path = os.path.relpath(os.path.join(root, f), obj_skeleton)
- shutil.copyfile(os.path.join(root, f), os.path.join(obj_path, f_rel_path))
+ shutil.copyfile(os.path.join(root, f), os.path.join(obj_path, f_rel_path), follow_symlinks=False)
for d in dirs:
b_dir_path = to_bytes(os.path.join(obj_path, rel_root, d), errors='surrogate_or_strict')
- if not os.path.exists(b_dir_path):
+ if os.path.exists(b_dir_path):
+ continue
+ b_src_dir = to_bytes(os.path.join(root, d), errors='surrogate_or_strict')
+ if os.path.islink(b_src_dir):
+ shutil.copyfile(b_src_dir, b_dir_path, follow_symlinks=False)
+ else:
os.makedirs(b_dir_path)
display.display("- %s %s was created successfully" % (galaxy_type.title(), obj_name))
@@ -1254,7 +1293,7 @@ class GalaxyCLI(CLI):
"""Compare checksums with the collection(s) found on the server and the installed copy. This does not verify dependencies."""
collections = context.CLIARGS['args']
- search_paths = context.CLIARGS['collections_path']
+ search_paths = AnsibleCollectionConfig.collection_paths
ignore_errors = context.CLIARGS['ignore_errors']
local_verify_only = context.CLIARGS['offline']
requirements_file = context.CLIARGS['requirements']
@@ -1394,7 +1433,19 @@ class GalaxyCLI(CLI):
upgrade = context.CLIARGS.get('upgrade', False)
collections_path = C.COLLECTIONS_PATHS
- if len([p for p in collections_path if p.startswith(path)]) == 0:
+
+ managed_paths = set(validate_collection_path(p) for p in C.COLLECTIONS_PATHS)
+ read_req_paths = set(validate_collection_path(p) for p in AnsibleCollectionConfig.collection_paths)
+
+ unexpected_path = C.GALAXY_COLLECTIONS_PATH_WARNING and not any(p.startswith(path) for p in managed_paths)
+ if unexpected_path and any(p.startswith(path) for p in read_req_paths):
+ display.warning(
+ f"The specified collections path '{path}' appears to be part of the pip Ansible package. "
+ "Managing these directly with ansible-galaxy could break the Ansible package. "
+ "Install collections to a configured collections path, which will take precedence over "
+ "collections found in the PYTHONPATH."
+ )
+ elif unexpected_path:
display.warning("The specified collections path '%s' is not part of the configured Ansible "
"collections paths '%s'. The installed collection will not be picked up in an Ansible "
"run, unless within a playbook-adjacent collections directory." % (to_text(path), to_text(":".join(collections_path))))
@@ -1411,6 +1462,7 @@ class GalaxyCLI(CLI):
artifacts_manager=artifacts_manager,
disable_gpg_verify=disable_gpg_verify,
offline=context.CLIARGS.get('offline', False),
+ read_requirement_paths=read_req_paths,
)
return 0
@@ -1579,7 +1631,9 @@ class GalaxyCLI(CLI):
display.warning(w)
if not path_found:
- raise AnsibleOptionsError("- None of the provided paths were usable. Please specify a valid path with --{0}s-path".format(context.CLIARGS['type']))
+ raise AnsibleOptionsError(
+ "- None of the provided paths were usable. Please specify a valid path with --{0}s-path".format(context.CLIARGS['type'])
+ )
return 0
@@ -1594,100 +1648,65 @@ class GalaxyCLI(CLI):
artifacts_manager.require_build_metadata = False
output_format = context.CLIARGS['output_format']
- collections_search_paths = set(context.CLIARGS['collections_path'])
collection_name = context.CLIARGS['collection']
- default_collections_path = AnsibleCollectionConfig.collection_paths
+ default_collections_path = set(C.COLLECTIONS_PATHS)
+ collections_search_paths = (
+ set(context.CLIARGS['collections_path'] or []) | default_collections_path | set(AnsibleCollectionConfig.collection_paths)
+ )
collections_in_paths = {}
warnings = []
path_found = False
collection_found = False
+
+ namespace_filter = None
+ collection_filter = None
+ if collection_name:
+ # list a specific collection
+
+ validate_collection_name(collection_name)
+ namespace_filter, collection_filter = collection_name.split('.')
+
+ collections = list(find_existing_collections(
+ list(collections_search_paths),
+ artifacts_manager,
+ namespace_filter=namespace_filter,
+ collection_filter=collection_filter,
+ dedupe=False
+ ))
+
+ seen = set()
+ fqcn_width, version_width = _get_collection_widths(collections)
+ for collection in sorted(collections, key=lambda c: c.src):
+ collection_found = True
+ collection_path = pathlib.Path(to_text(collection.src)).parent.parent.as_posix()
+
+ if output_format in {'yaml', 'json'}:
+ collections_in_paths.setdefault(collection_path, {})
+ collections_in_paths[collection_path][collection.fqcn] = {'version': collection.ver}
+ else:
+ if collection_path not in seen:
+ _display_header(
+ collection_path,
+ 'Collection',
+ 'Version',
+ fqcn_width,
+ version_width
+ )
+ seen.add(collection_path)
+ _display_collection(collection, fqcn_width, version_width)
+
+ path_found = False
for path in collections_search_paths:
- collection_path = GalaxyCLI._resolve_path(path)
if not os.path.exists(path):
if path in default_collections_path:
# don't warn for missing default paths
continue
- warnings.append("- the configured path {0} does not exist.".format(collection_path))
- continue
-
- if not os.path.isdir(collection_path):
- warnings.append("- the configured path {0}, exists, but it is not a directory.".format(collection_path))
- continue
-
- path_found = True
-
- if collection_name:
- # list a specific collection
-
- validate_collection_name(collection_name)
- namespace, collection = collection_name.split('.')
-
- collection_path = validate_collection_path(collection_path)
- b_collection_path = to_bytes(os.path.join(collection_path, namespace, collection), errors='surrogate_or_strict')
-
- if not os.path.exists(b_collection_path):
- warnings.append("- unable to find {0} in collection paths".format(collection_name))
- continue
-
- if not os.path.isdir(collection_path):
- warnings.append("- the configured path {0}, exists, but it is not a directory.".format(collection_path))
- continue
-
- collection_found = True
-
- try:
- collection = Requirement.from_dir_path_as_unknown(
- b_collection_path,
- artifacts_manager,
- )
- except ValueError as val_err:
- six.raise_from(AnsibleError(val_err), val_err)
-
- if output_format in {'yaml', 'json'}:
- collections_in_paths[collection_path] = {
- collection.fqcn: {'version': collection.ver}
- }
-
- continue
-
- fqcn_width, version_width = _get_collection_widths([collection])
-
- _display_header(collection_path, 'Collection', 'Version', fqcn_width, version_width)
- _display_collection(collection, fqcn_width, version_width)
-
+ warnings.append("- the configured path {0} does not exist.".format(path))
+ elif os.path.exists(path) and not os.path.isdir(path):
+ warnings.append("- the configured path {0}, exists, but it is not a directory.".format(path))
else:
- # list all collections
- collection_path = validate_collection_path(path)
- if os.path.isdir(collection_path):
- display.vvv("Searching {0} for collections".format(collection_path))
- collections = list(find_existing_collections(
- collection_path, artifacts_manager,
- ))
- else:
- # There was no 'ansible_collections/' directory in the path, so there
- # or no collections here.
- display.vvv("No 'ansible_collections' directory found at {0}".format(collection_path))
- continue
-
- if not collections:
- display.vvv("No collections found at {0}".format(collection_path))
- continue
-
- if output_format in {'yaml', 'json'}:
- collections_in_paths[collection_path] = {
- collection.fqcn: {'version': collection.ver} for collection in collections
- }
-
- continue
-
- # Display header
- fqcn_width, version_width = _get_collection_widths(collections)
- _display_header(collection_path, 'Collection', 'Version', fqcn_width, version_width)
-
- # Sort collections by the namespace and name
- for collection in sorted(collections, key=to_text):
- _display_collection(collection, fqcn_width, version_width)
+ path_found = True
# Do not warn if the specific collection was found in any of the search paths
if collection_found and collection_name:
@@ -1696,8 +1715,10 @@ class GalaxyCLI(CLI):
for w in warnings:
display.warning(w)
- if not path_found:
- raise AnsibleOptionsError("- None of the provided paths were usable. Please specify a valid path with --{0}s-path".format(context.CLIARGS['type']))
+ if not collections and not path_found:
+ raise AnsibleOptionsError(
+ "- None of the provided paths were usable. Please specify a valid path with --{0}s-path".format(context.CLIARGS['type'])
+ )
if output_format == 'json':
display.display(json.dumps(collections_in_paths))
@@ -1731,8 +1752,8 @@ class GalaxyCLI(CLI):
tags=context.CLIARGS['galaxy_tags'], author=context.CLIARGS['author'], page_size=page_size)
if response['count'] == 0:
- display.display("No roles match your search.", color=C.COLOR_ERROR)
- return 1
+ display.warning("No roles match your search.")
+ return 0
data = [u'']
@@ -1771,6 +1792,7 @@ class GalaxyCLI(CLI):
github_user = to_text(context.CLIARGS['github_user'], errors='surrogate_or_strict')
github_repo = to_text(context.CLIARGS['github_repo'], errors='surrogate_or_strict')
+ rc = 0
if context.CLIARGS['check_status']:
task = self.api.get_import_task(github_user=github_user, github_repo=github_repo)
else:
@@ -1788,7 +1810,7 @@ class GalaxyCLI(CLI):
display.display('%s.%s' % (t['summary_fields']['role']['namespace'], t['summary_fields']['role']['name']), color=C.COLOR_CHANGED)
display.display(u'\nTo properly namespace this role, remove each of the above and re-import %s/%s from scratch' % (github_user, github_repo),
color=C.COLOR_CHANGED)
- return 0
+ return rc
# found a single role as expected
display.display("Successfully submitted import request %d" % task[0]['id'])
if not context.CLIARGS['wait']:
@@ -1805,12 +1827,13 @@ class GalaxyCLI(CLI):
if msg['id'] not in msg_list:
display.display(msg['message_text'], color=colors[msg['message_type']])
msg_list.append(msg['id'])
- if task[0]['state'] in ['SUCCESS', 'FAILED']:
+ if (state := task[0]['state']) in ['SUCCESS', 'FAILED']:
+ rc = ['SUCCESS', 'FAILED'].index(state)
finished = True
else:
time.sleep(10)
- return 0
+ return rc
def execute_setup(self):
""" Setup an integration from Github or Travis for Ansible Galaxy roles"""
diff --git a/lib/ansible/cli/inventory.py b/lib/ansible/cli/inventory.py
index 56c370cc..3550079b 100755
--- a/lib/ansible/cli/inventory.py
+++ b/lib/ansible/cli/inventory.py
@@ -18,7 +18,7 @@ from ansible import constants as C
from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleError, AnsibleOptionsError
-from ansible.module_utils._text import to_bytes, to_native, to_text
+from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
from ansible.utils.vars import combine_vars
from ansible.utils.display import Display
from ansible.vars.plugins import get_vars_from_inventory_sources, get_vars_from_path
@@ -72,7 +72,6 @@ class InventoryCLI(CLI):
opt_help.add_runtask_options(self.parser)
# remove unused default options
- self.parser.add_argument('-l', '--limit', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument, nargs='?')
self.parser.add_argument('--list-hosts', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument)
self.parser.add_argument('args', metavar='host|group', nargs='?')
@@ -80,9 +79,10 @@ class InventoryCLI(CLI):
# Actions
action_group = self.parser.add_argument_group("Actions", "One of following must be used on invocation, ONLY ONE!")
action_group.add_argument("--list", action="store_true", default=False, dest='list', help='Output all hosts info, works as inventory script')
- action_group.add_argument("--host", action="store", default=None, dest='host', help='Output specific host info, works as inventory script')
+ action_group.add_argument("--host", action="store", default=None, dest='host',
+ help='Output specific host info, works as inventory script. It will ignore limit')
action_group.add_argument("--graph", action="store_true", default=False, dest='graph',
- help='create inventory graph, if supplying pattern it must be a valid group name')
+ help='create inventory graph, if supplying pattern it must be a valid group name. It will ignore limit')
self.parser.add_argument_group(action_group)
# graph
@@ -144,17 +144,22 @@ class InventoryCLI(CLI):
# FIXME: should we template first?
results = self.dump(myvars)
- elif context.CLIARGS['graph']:
- results = self.inventory_graph()
- elif context.CLIARGS['list']:
- top = self._get_group('all')
- if context.CLIARGS['yaml']:
- results = self.yaml_inventory(top)
- elif context.CLIARGS['toml']:
- results = self.toml_inventory(top)
- else:
- results = self.json_inventory(top)
- results = self.dump(results)
+ else:
+ if context.CLIARGS['subset']:
+ # not doing single host, set limit in general if given
+ self.inventory.subset(context.CLIARGS['subset'])
+
+ if context.CLIARGS['graph']:
+ results = self.inventory_graph()
+ elif context.CLIARGS['list']:
+ top = self._get_group('all')
+ if context.CLIARGS['yaml']:
+ results = self.yaml_inventory(top)
+ elif context.CLIARGS['toml']:
+ results = self.toml_inventory(top)
+ else:
+ results = self.json_inventory(top)
+ results = self.dump(results)
if results:
outfile = context.CLIARGS['output_file']
@@ -249,7 +254,7 @@ class InventoryCLI(CLI):
return dump
@staticmethod
- def _remove_empty(dump):
+ def _remove_empty_keys(dump):
# remove empty keys
for x in ('hosts', 'vars', 'children'):
if x in dump and not dump[x]:
@@ -296,33 +301,34 @@ class InventoryCLI(CLI):
def json_inventory(self, top):
- seen = set()
+ seen_groups = set()
- def format_group(group):
+ def format_group(group, available_hosts):
results = {}
results[group.name] = {}
if group.name != 'all':
- results[group.name]['hosts'] = [h.name for h in group.hosts]
+ results[group.name]['hosts'] = [h.name for h in group.hosts if h.name in available_hosts]
results[group.name]['children'] = []
for subgroup in group.child_groups:
results[group.name]['children'].append(subgroup.name)
- if subgroup.name not in seen:
- results.update(format_group(subgroup))
- seen.add(subgroup.name)
+ if subgroup.name not in seen_groups:
+ results.update(format_group(subgroup, available_hosts))
+ seen_groups.add(subgroup.name)
if context.CLIARGS['export']:
results[group.name]['vars'] = self._get_group_variables(group)
- self._remove_empty(results[group.name])
+ self._remove_empty_keys(results[group.name])
+ # remove empty groups
if not results[group.name]:
del results[group.name]
return results
- results = format_group(top)
+ hosts = self.inventory.get_hosts(top.name)
+ results = format_group(top, frozenset(h.name for h in hosts))
# populate meta
results['_meta'] = {'hostvars': {}}
- hosts = self.inventory.get_hosts()
for host in hosts:
hvars = self._get_host_variables(host)
if hvars:
@@ -332,9 +338,10 @@ class InventoryCLI(CLI):
def yaml_inventory(self, top):
- seen = []
+ seen_hosts = set()
+ seen_groups = set()
- def format_group(group):
+ def format_group(group, available_hosts):
results = {}
# initialize group + vars
@@ -344,15 +351,21 @@ class InventoryCLI(CLI):
results[group.name]['children'] = {}
for subgroup in group.child_groups:
if subgroup.name != 'all':
- results[group.name]['children'].update(format_group(subgroup))
+ if subgroup.name in seen_groups:
+ results[group.name]['children'].update({subgroup.name: {}})
+ else:
+ results[group.name]['children'].update(format_group(subgroup, available_hosts))
+ seen_groups.add(subgroup.name)
# hosts for group
results[group.name]['hosts'] = {}
if group.name != 'all':
for h in group.hosts:
+ if h.name not in available_hosts:
+ continue # observe limit
myvars = {}
- if h.name not in seen: # avoid defining host vars more than once
- seen.append(h.name)
+ if h.name not in seen_hosts: # avoid defining host vars more than once
+ seen_hosts.add(h.name)
myvars = self._get_host_variables(host=h)
results[group.name]['hosts'][h.name] = myvars
@@ -361,17 +374,22 @@ class InventoryCLI(CLI):
if gvars:
results[group.name]['vars'] = gvars
- self._remove_empty(results[group.name])
+ self._remove_empty_keys(results[group.name])
+ # remove empty groups
+ if not results[group.name]:
+ del results[group.name]
return results
- return format_group(top)
+ available_hosts = frozenset(h.name for h in self.inventory.get_hosts(top.name))
+ return format_group(top, available_hosts)
def toml_inventory(self, top):
- seen = set()
+ seen_hosts = set()
+ seen_hosts = set()
has_ungrouped = bool(next(g.hosts for g in top.child_groups if g.name == 'ungrouped'))
- def format_group(group):
+ def format_group(group, available_hosts):
results = {}
results[group.name] = {}
@@ -381,12 +399,14 @@ class InventoryCLI(CLI):
continue
if group.name != 'all':
results[group.name]['children'].append(subgroup.name)
- results.update(format_group(subgroup))
+ results.update(format_group(subgroup, available_hosts))
if group.name != 'all':
for host in group.hosts:
- if host.name not in seen:
- seen.add(host.name)
+ if host.name not in available_hosts:
+ continue
+ if host.name not in seen_hosts:
+ seen_hosts.add(host.name)
host_vars = self._get_host_variables(host=host)
else:
host_vars = {}
@@ -398,13 +418,15 @@ class InventoryCLI(CLI):
if context.CLIARGS['export']:
results[group.name]['vars'] = self._get_group_variables(group)
- self._remove_empty(results[group.name])
+ self._remove_empty_keys(results[group.name])
+ # remove empty groups
if not results[group.name]:
del results[group.name]
return results
- results = format_group(top)
+ available_hosts = frozenset(h.name for h in self.inventory.get_hosts(top.name))
+ results = format_group(top, available_hosts)
return results
diff --git a/lib/ansible/cli/playbook.py b/lib/ansible/cli/playbook.py
index 9c091a67..e63785b0 100755
--- a/lib/ansible/cli/playbook.py
+++ b/lib/ansible/cli/playbook.py
@@ -18,7 +18,7 @@ from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleError
from ansible.executor.playbook_executor import PlaybookExecutor
-from ansible.module_utils._text import to_bytes
+from ansible.module_utils.common.text.converters import to_bytes
from ansible.playbook.block import Block
from ansible.plugins.loader import add_all_plugin_dirs
from ansible.utils.collection_loader import AnsibleCollectionConfig
@@ -67,8 +67,19 @@ class PlaybookCLI(CLI):
self.parser.add_argument('args', help='Playbook(s)', metavar='playbook', nargs='+')
def post_process_args(self, options):
+
+ # for listing, we need to know if user had tag input
+ # capture here as parent function sets defaults for tags
+ havetags = bool(options.tags or options.skip_tags)
+
options = super(PlaybookCLI, self).post_process_args(options)
+ if options.listtags:
+ # default to all tags (including never), when listing tags
+ # unless user specified tags
+ if not havetags:
+ options.tags = ['never', 'all']
+
display.verbosity = options.verbosity
self.validate_conflicts(options, runas_opts=True, fork_opts=True)
diff --git a/lib/ansible/cli/pull.py b/lib/ansible/cli/pull.py
index 47084989..f369c390 100755
--- a/lib/ansible/cli/pull.py
+++ b/lib/ansible/cli/pull.py
@@ -24,7 +24,7 @@ from ansible import constants as C
from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleOptionsError
-from ansible.module_utils._text import to_native, to_text
+from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.plugins.loader import module_loader
from ansible.utils.cmd_functions import run_cmd
from ansible.utils.display import Display
@@ -81,7 +81,7 @@ class PullCLI(CLI):
super(PullCLI, self).init_parser(
usage='%prog -U <repository> [options] [<playbook.yml>]',
- desc="pulls playbooks from a VCS repo and executes them for the local host")
+ desc="pulls playbooks from a VCS repo and executes them on target host")
# Do not add check_options as there's a conflict with --checkout/-C
opt_help.add_connect_options(self.parser)
@@ -275,8 +275,15 @@ class PullCLI(CLI):
for vault_id in context.CLIARGS['vault_ids']:
cmd += " --vault-id=%s" % vault_id
+ if context.CLIARGS['become_password_file']:
+ cmd += " --become-password-file=%s" % context.CLIARGS['become_password_file']
+
+ if context.CLIARGS['connection_password_file']:
+ cmd += " --connection-password-file=%s" % context.CLIARGS['connection_password_file']
+
for ev in context.CLIARGS['extra_vars']:
cmd += ' -e %s' % shlex.quote(ev)
+
if context.CLIARGS['become_ask_pass']:
cmd += ' --ask-become-pass'
if context.CLIARGS['skip_tags']:
diff --git a/lib/ansible/cli/scripts/ansible_connection_cli_stub.py b/lib/ansible/cli/scripts/ansible_connection_cli_stub.py
index 9109137e..b1ed18c9 100755
--- a/lib/ansible/cli/scripts/ansible_connection_cli_stub.py
+++ b/lib/ansible/cli/scripts/ansible_connection_cli_stub.py
@@ -6,7 +6,6 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
-import argparse
import fcntl
import hashlib
import io
@@ -24,12 +23,12 @@ from contextlib import contextmanager
from ansible import constants as C
from ansible.cli.arguments import option_helpers as opt_help
-from ansible.module_utils._text import to_bytes, to_text
+from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.module_utils.connection import Connection, ConnectionError, send_data, recv_data
from ansible.module_utils.service import fork_process
from ansible.parsing.ajson import AnsibleJSONEncoder, AnsibleJSONDecoder
from ansible.playbook.play_context import PlayContext
-from ansible.plugins.loader import connection_loader
+from ansible.plugins.loader import connection_loader, init_plugin_loader
from ansible.utils.path import unfrackpath, makedirs_safe
from ansible.utils.display import Display
from ansible.utils.jsonrpc import JsonRpcServer
@@ -230,6 +229,7 @@ def main(args=None):
parser.add_argument('playbook_pid')
parser.add_argument('task_uuid')
args = parser.parse_args(args[1:] if args is not None else args)
+ init_plugin_loader()
# initialize verbosity
display.verbosity = args.verbosity
diff --git a/lib/ansible/cli/vault.py b/lib/ansible/cli/vault.py
index 3e60329d..cf2c9dd9 100755
--- a/lib/ansible/cli/vault.py
+++ b/lib/ansible/cli/vault.py
@@ -17,7 +17,7 @@ from ansible import constants as C
from ansible import context
from ansible.cli.arguments import option_helpers as opt_help
from ansible.errors import AnsibleOptionsError
-from ansible.module_utils._text import to_text, to_bytes
+from ansible.module_utils.common.text.converters import to_text, to_bytes
from ansible.parsing.dataloader import DataLoader
from ansible.parsing.vault import VaultEditor, VaultLib, match_encrypt_secret
from ansible.utils.display import Display
@@ -61,20 +61,20 @@ class VaultCLI(CLI):
epilog="\nSee '%s <command> --help' for more information on a specific command.\n\n" % os.path.basename(sys.argv[0])
)
- common = opt_help.argparse.ArgumentParser(add_help=False)
+ common = opt_help.ArgumentParser(add_help=False)
opt_help.add_vault_options(common)
opt_help.add_verbosity_options(common)
subparsers = self.parser.add_subparsers(dest='action')
subparsers.required = True
- output = opt_help.argparse.ArgumentParser(add_help=False)
+ output = opt_help.ArgumentParser(add_help=False)
output.add_argument('--output', default=None, dest='output_file',
help='output file name for encrypt or decrypt; use - for stdout',
type=opt_help.unfrack_path())
# For encrypting actions, we can also specify which of multiple vault ids should be used for encrypting
- vault_id = opt_help.argparse.ArgumentParser(add_help=False)
+ vault_id = opt_help.ArgumentParser(add_help=False)
vault_id.add_argument('--encrypt-vault-id', default=[], dest='encrypt_vault_id',
action='store', type=str,
help='the vault id used to encrypt (required if more than one vault-id is provided)')
@@ -82,6 +82,8 @@ class VaultCLI(CLI):
create_parser = subparsers.add_parser('create', help='Create new vault encrypted file', parents=[vault_id, common])
create_parser.set_defaults(func=self.execute_create)
create_parser.add_argument('args', help='Filename', metavar='file_name', nargs='*')
+ create_parser.add_argument('--skip-tty-check', default=False, help='allows editor to be opened when no tty attached',
+ dest='skip_tty_check', action='store_true')
decrypt_parser = subparsers.add_parser('decrypt', help='Decrypt vault encrypted file', parents=[output, common])
decrypt_parser.set_defaults(func=self.execute_decrypt)
@@ -384,6 +386,11 @@ class VaultCLI(CLI):
sys.stderr.write(err)
b_outs.append(to_bytes(out))
+ # The output must end with a newline to play nice with terminal representation.
+ # Refs:
+ # * https://stackoverflow.com/a/729795/595220
+ # * https://github.com/ansible/ansible/issues/78932
+ b_outs.append(b'')
self.editor.write_data(b'\n'.join(b_outs), context.CLIARGS['output_file'] or '-')
if sys.stdout.isatty():
@@ -442,8 +449,11 @@ class VaultCLI(CLI):
if len(context.CLIARGS['args']) != 1:
raise AnsibleOptionsError("ansible-vault create can take only one filename argument")
- self.editor.create_file(context.CLIARGS['args'][0], self.encrypt_secret,
- vault_id=self.encrypt_vault_id)
+ if sys.stdout.isatty() or context.CLIARGS['skip_tty_check']:
+ self.editor.create_file(context.CLIARGS['args'][0], self.encrypt_secret,
+ vault_id=self.encrypt_vault_id)
+ else:
+ raise AnsibleOptionsError("not a tty, editor cannot be opened")
def execute_edit(self):
''' open and decrypt an existing vaulted file in an editor, that will be encrypted again when closed'''