1# Copyright: (c) 2013, James Cammarata <jcammarata@ansible.com> 2# Copyright: (c) 2018, Ansible Project 3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 4 5from __future__ import (absolute_import, division, print_function) 6__metaclass__ = type 7 8import os.path 9import re 10import shutil 11import textwrap 12import time 13import yaml 14 15from jinja2 import BaseLoader, Environment, FileSystemLoader 16from yaml.error import YAMLError 17 18import ansible.constants as C 19from ansible import context 20from ansible.cli import CLI 21from ansible.cli.arguments import option_helpers as opt_help 22from ansible.errors import AnsibleError, AnsibleOptionsError 23from ansible.galaxy import Galaxy, get_collections_galaxy_meta_info 24from ansible.galaxy.api import GalaxyAPI 25from ansible.galaxy.collection import build_collection, install_collections, publish_collection, \ 26 validate_collection_name 27from ansible.galaxy.role import GalaxyRole 28from ansible.galaxy.token import BasicAuthToken, GalaxyToken, KeycloakToken, NoTokenSentinel 29from ansible.module_utils.ansible_release import __version__ as ansible_version 30from ansible.module_utils._text import to_bytes, to_native, to_text 31from ansible.module_utils import six 32from ansible.parsing.yaml.loader import AnsibleLoader 33from ansible.playbook.role.requirement import RoleRequirement 34from ansible.utils.display import Display 35from ansible.utils.plugin_docs import get_versioned_doclink 36 37display = Display() 38urlparse = six.moves.urllib.parse.urlparse 39 40 41class GalaxyCLI(CLI): 42 '''command to manage Ansible roles in shared repositories, the default of which is Ansible Galaxy *https://galaxy.ansible.com*.''' 43 44 SKIP_INFO_KEYS = ("name", "description", "readme_html", "related", "summary_fields", "average_aw_composite", "average_aw_score", "url") 45 46 def __init__(self, args): 47 if len(args) > 1: 48 # Inject role into sys.argv[1] as a backwards compatibility step 49 if args[1] not in ['-h', '--help', '--version'] and 'role' not in args and 'collection' not in args: 50 # TODO: Should we add a warning here and eventually deprecate the implicit role subcommand choice 51 # Remove this in Ansible 2.13 when we also remove -v as an option on the root parser for ansible-galaxy. 52 idx = 2 if args[1].startswith('-v') else 1 53 args.insert(idx, 'role') 54 # since argparse doesn't allow hidden subparsers, handle dead login arg from raw args after "role" normalization 55 if args[1:3] == ['role', 'login']: 56 display.error( 57 "The login command was removed in late 2020. An API key is now required to publish roles or collections " 58 "to Galaxy. The key can be found at https://galaxy.ansible.com/me/preferences, and passed to the " 59 "ansible-galaxy CLI via a file at {0} or (insecurely) via the `--token` " 60 "command-line argument.".format(to_text(C.GALAXY_TOKEN_PATH))) 61 exit(1) 62 63 self.api_servers = [] 64 self.galaxy = None 65 self._api = None 66 super(GalaxyCLI, self).__init__(args) 67 68 def init_parser(self): 69 ''' create an options parser for bin/ansible ''' 70 71 super(GalaxyCLI, self).init_parser( 72 desc="Perform various Role and Collection related operations.", 73 ) 74 75 # Common arguments that apply to more than 1 action 76 common = opt_help.argparse.ArgumentParser(add_help=False) 77 common.add_argument('-s', '--server', dest='api_server', help='The Galaxy API server URL') 78 common.add_argument('--api-key', dest='api_key', 79 help='The Ansible Galaxy API key which can be found at ' 80 'https://galaxy.ansible.com/me/preferences. You can also set the token ' 81 'for the GALAXY_SERVER_LIST entry.') 82 common.add_argument('-c', '--ignore-certs', action='store_true', dest='ignore_certs', 83 default=C.GALAXY_IGNORE_CERTS, help='Ignore SSL certificate validation errors.') 84 opt_help.add_verbosity_options(common) 85 86 force = opt_help.argparse.ArgumentParser(add_help=False) 87 force.add_argument('-f', '--force', dest='force', action='store_true', default=False, 88 help='Force overwriting an existing role or collection') 89 90 github = opt_help.argparse.ArgumentParser(add_help=False) 91 github.add_argument('github_user', help='GitHub username') 92 github.add_argument('github_repo', help='GitHub repository') 93 94 offline = opt_help.argparse.ArgumentParser(add_help=False) 95 offline.add_argument('--offline', dest='offline', default=False, action='store_true', 96 help="Don't query the galaxy API when creating roles") 97 98 default_roles_path = C.config.get_configuration_definition('DEFAULT_ROLES_PATH').get('default', '') 99 roles_path = opt_help.argparse.ArgumentParser(add_help=False) 100 roles_path.add_argument('-p', '--roles-path', dest='roles_path', type=opt_help.unfrack_path(pathsep=True), 101 default=C.DEFAULT_ROLES_PATH, action=opt_help.PrependListAction, 102 help='The path to the directory containing your roles. The default is the first ' 103 'writable one configured via DEFAULT_ROLES_PATH: %s ' % default_roles_path) 104 105 # Add sub parser for the Galaxy role type (role or collection) 106 type_parser = self.parser.add_subparsers(metavar='TYPE', dest='type') 107 type_parser.required = True 108 109 # Add sub parser for the Galaxy collection actions 110 collection = type_parser.add_parser('collection', help='Manage an Ansible Galaxy collection.') 111 collection_parser = collection.add_subparsers(metavar='COLLECTION_ACTION', dest='action') 112 collection_parser.required = True 113 self.add_init_options(collection_parser, parents=[common, force]) 114 self.add_build_options(collection_parser, parents=[common, force]) 115 self.add_publish_options(collection_parser, parents=[common]) 116 self.add_install_options(collection_parser, parents=[common, force]) 117 118 # Add sub parser for the Galaxy role actions 119 role = type_parser.add_parser('role', help='Manage an Ansible Galaxy role.') 120 role_parser = role.add_subparsers(metavar='ROLE_ACTION', dest='action') 121 role_parser.required = True 122 self.add_init_options(role_parser, parents=[common, force, offline]) 123 self.add_remove_options(role_parser, parents=[common, roles_path]) 124 self.add_delete_options(role_parser, parents=[common, github]) 125 self.add_list_options(role_parser, parents=[common, roles_path]) 126 self.add_search_options(role_parser, parents=[common]) 127 self.add_import_options(role_parser, parents=[common, github]) 128 self.add_setup_options(role_parser, parents=[common, roles_path]) 129 self.add_info_options(role_parser, parents=[common, roles_path, offline]) 130 self.add_install_options(role_parser, parents=[common, force, roles_path]) 131 132 def add_init_options(self, parser, parents=None): 133 galaxy_type = 'collection' if parser.metavar == 'COLLECTION_ACTION' else 'role' 134 135 init_parser = parser.add_parser('init', parents=parents, 136 help='Initialize new {0} with the base structure of a ' 137 '{0}.'.format(galaxy_type)) 138 init_parser.set_defaults(func=self.execute_init) 139 140 init_parser.add_argument('--init-path', dest='init_path', default='./', 141 help='The path in which the skeleton {0} will be created. The default is the ' 142 'current working directory.'.format(galaxy_type)) 143 init_parser.add_argument('--{0}-skeleton'.format(galaxy_type), dest='{0}_skeleton'.format(galaxy_type), 144 default=C.GALAXY_ROLE_SKELETON, 145 help='The path to a {0} skeleton that the new {0} should be based ' 146 'upon.'.format(galaxy_type)) 147 148 obj_name_kwargs = {} 149 if galaxy_type == 'collection': 150 obj_name_kwargs['type'] = validate_collection_name 151 init_parser.add_argument('{0}_name'.format(galaxy_type), help='{0} name'.format(galaxy_type.capitalize()), 152 **obj_name_kwargs) 153 154 if galaxy_type == 'role': 155 init_parser.add_argument('--type', dest='role_type', action='store', default='default', 156 help="Initialize using an alternate role type. Valid types include: 'container', " 157 "'apb' and 'network'.") 158 159 def add_remove_options(self, parser, parents=None): 160 remove_parser = parser.add_parser('remove', parents=parents, help='Delete roles from roles_path.') 161 remove_parser.set_defaults(func=self.execute_remove) 162 163 remove_parser.add_argument('args', help='Role(s)', metavar='role', nargs='+') 164 165 def add_delete_options(self, parser, parents=None): 166 delete_parser = parser.add_parser('delete', parents=parents, 167 help='Removes the role from Galaxy. It does not remove or alter the actual ' 168 'GitHub repository.') 169 delete_parser.set_defaults(func=self.execute_delete) 170 171 def add_list_options(self, parser, parents=None): 172 list_parser = parser.add_parser('list', parents=parents, 173 help='Show the name and version of each role installed in the roles_path.') 174 list_parser.set_defaults(func=self.execute_list) 175 176 list_parser.add_argument('role', help='Role', nargs='?', metavar='role') 177 178 def add_search_options(self, parser, parents=None): 179 search_parser = parser.add_parser('search', parents=parents, 180 help='Search the Galaxy database by tags, platforms, author and multiple ' 181 'keywords.') 182 search_parser.set_defaults(func=self.execute_search) 183 184 search_parser.add_argument('--platforms', dest='platforms', help='list of OS platforms to filter by') 185 search_parser.add_argument('--galaxy-tags', dest='galaxy_tags', help='list of galaxy tags to filter by') 186 search_parser.add_argument('--author', dest='author', help='GitHub username') 187 search_parser.add_argument('args', help='Search terms', metavar='searchterm', nargs='*') 188 189 def add_import_options(self, parser, parents=None): 190 import_parser = parser.add_parser('import', parents=parents, help='Import a role') 191 import_parser.set_defaults(func=self.execute_import) 192 193 import_parser.add_argument('--no-wait', dest='wait', action='store_false', default=True, 194 help="Don't wait for import results.") 195 import_parser.add_argument('--branch', dest='reference', 196 help='The name of a branch to import. Defaults to the repository\'s default branch ' 197 '(usually master)') 198 import_parser.add_argument('--role-name', dest='role_name', 199 help='The name the role should have, if different than the repo name') 200 import_parser.add_argument('--status', dest='check_status', action='store_true', default=False, 201 help='Check the status of the most recent import request for given github_' 202 'user/github_repo.') 203 204 def add_setup_options(self, parser, parents=None): 205 setup_parser = parser.add_parser('setup', parents=parents, 206 help='Manage the integration between Galaxy and the given source.') 207 setup_parser.set_defaults(func=self.execute_setup) 208 209 setup_parser.add_argument('--remove', dest='remove_id', default=None, 210 help='Remove the integration matching the provided ID value. Use --list to see ' 211 'ID values.') 212 setup_parser.add_argument('--list', dest="setup_list", action='store_true', default=False, 213 help='List all of your integrations.') 214 setup_parser.add_argument('source', help='Source') 215 setup_parser.add_argument('github_user', help='GitHub username') 216 setup_parser.add_argument('github_repo', help='GitHub repository') 217 setup_parser.add_argument('secret', help='Secret') 218 219 def add_info_options(self, parser, parents=None): 220 info_parser = parser.add_parser('info', parents=parents, help='View more details about a specific role.') 221 info_parser.set_defaults(func=self.execute_info) 222 223 info_parser.add_argument('args', nargs='+', help='role', metavar='role_name[,version]') 224 225 def add_install_options(self, parser, parents=None): 226 galaxy_type = 'collection' if parser.metavar == 'COLLECTION_ACTION' else 'role' 227 228 args_kwargs = {} 229 if galaxy_type == 'collection': 230 args_kwargs['help'] = 'The collection(s) name or path/url to a tar.gz collection artifact. This is ' \ 231 'mutually exclusive with --requirements-file.' 232 ignore_errors_help = 'Ignore errors during installation and continue with the next specified ' \ 233 'collection. This will not ignore dependency conflict errors.' 234 else: 235 args_kwargs['help'] = 'Role name, URL or tar file' 236 ignore_errors_help = 'Ignore errors and continue with the next specified role.' 237 238 install_parser = parser.add_parser('install', parents=parents, 239 help='Install {0}(s) from file(s), URL(s) or Ansible ' 240 'Galaxy'.format(galaxy_type)) 241 install_parser.set_defaults(func=self.execute_install) 242 243 install_parser.add_argument('args', metavar='{0}_name'.format(galaxy_type), nargs='*', **args_kwargs) 244 install_parser.add_argument('-i', '--ignore-errors', dest='ignore_errors', action='store_true', default=False, 245 help=ignore_errors_help) 246 247 install_exclusive = install_parser.add_mutually_exclusive_group() 248 install_exclusive.add_argument('-n', '--no-deps', dest='no_deps', action='store_true', default=False, 249 help="Don't download {0}s listed as dependencies.".format(galaxy_type)) 250 install_exclusive.add_argument('--force-with-deps', dest='force_with_deps', action='store_true', default=False, 251 help="Force overwriting an existing {0} and its " 252 "dependencies.".format(galaxy_type)) 253 254 if galaxy_type == 'collection': 255 install_parser.add_argument('-p', '--collections-path', dest='collections_path', 256 default=C.COLLECTIONS_PATHS[0], 257 help='The path to the directory containing your collections.') 258 install_parser.add_argument('-r', '--requirements-file', dest='requirements', 259 help='A file containing a list of collections to be installed.') 260 else: 261 install_parser.add_argument('-r', '--role-file', dest='role_file', 262 help='A file containing a list of roles to be imported.') 263 install_parser.add_argument('-g', '--keep-scm-meta', dest='keep_scm_meta', action='store_true', 264 default=False, 265 help='Use tar instead of the scm archive option when packaging the role.') 266 267 def add_build_options(self, parser, parents=None): 268 build_parser = parser.add_parser('build', parents=parents, 269 help='Build an Ansible collection artifact that can be publish to Ansible ' 270 'Galaxy.') 271 build_parser.set_defaults(func=self.execute_build) 272 273 build_parser.add_argument('args', metavar='collection', nargs='*', default=('.',), 274 help='Path to the collection(s) directory to build. This should be the directory ' 275 'that contains the galaxy.yml file. The default is the current working ' 276 'directory.') 277 build_parser.add_argument('--output-path', dest='output_path', default='./', 278 help='The path in which the collection is built to. The default is the current ' 279 'working directory.') 280 281 def add_publish_options(self, parser, parents=None): 282 publish_parser = parser.add_parser('publish', parents=parents, 283 help='Publish a collection artifact to Ansible Galaxy.') 284 publish_parser.set_defaults(func=self.execute_publish) 285 286 publish_parser.add_argument('args', metavar='collection_path', 287 help='The path to the collection tarball to publish.') 288 publish_parser.add_argument('--no-wait', dest='wait', action='store_false', default=True, 289 help="Don't wait for import validation results.") 290 publish_parser.add_argument('--import-timeout', dest='import_timeout', type=int, default=0, 291 help="The time to wait for the collection import process to finish.") 292 293 def post_process_args(self, options): 294 options = super(GalaxyCLI, self).post_process_args(options) 295 display.verbosity = options.verbosity 296 return options 297 298 def run(self): 299 300 super(GalaxyCLI, self).run() 301 302 self.galaxy = Galaxy() 303 304 def server_config_def(section, key, required): 305 return { 306 'description': 'The %s of the %s Galaxy server' % (key, section), 307 'ini': [ 308 { 309 'section': 'galaxy_server.%s' % section, 310 'key': key, 311 } 312 ], 313 'env': [ 314 {'name': 'ANSIBLE_GALAXY_SERVER_%s_%s' % (section.upper(), key.upper())}, 315 ], 316 'required': required, 317 } 318 server_def = [('url', True), ('username', False), ('password', False), ('token', False), 319 ('auth_url', False)] 320 321 config_servers = [] 322 323 # Need to filter out empty strings or non truthy values as an empty server list env var is equal to ['']. 324 server_list = [s for s in C.GALAXY_SERVER_LIST or [] if s] 325 for server_key in server_list: 326 # Config definitions are looked up dynamically based on the C.GALAXY_SERVER_LIST entry. We look up the 327 # section [galaxy_server.<server>] for the values url, username, password, and token. 328 config_dict = dict((k, server_config_def(server_key, k, req)) for k, req in server_def) 329 defs = AnsibleLoader(yaml.safe_dump(config_dict)).get_single_data() 330 C.config.initialize_plugin_configuration_definitions('galaxy_server', server_key, defs) 331 332 server_options = C.config.get_plugin_options('galaxy_server', server_key) 333 # auth_url is used to create the token, but not directly by GalaxyAPI, so 334 # it doesn't need to be passed as kwarg to GalaxyApi 335 auth_url = server_options.pop('auth_url', None) 336 token_val = server_options['token'] or NoTokenSentinel 337 username = server_options['username'] 338 339 # default case if no auth info is provided. 340 server_options['token'] = None 341 342 if username: 343 server_options['token'] = BasicAuthToken(username, 344 server_options['password']) 345 else: 346 if token_val: 347 if auth_url: 348 server_options['token'] = KeycloakToken(access_token=token_val, 349 auth_url=auth_url, 350 validate_certs=not context.CLIARGS['ignore_certs']) 351 else: 352 # The galaxy v1 / github / django / 'Token' 353 server_options['token'] = GalaxyToken(token=token_val) 354 355 config_servers.append(GalaxyAPI(self.galaxy, server_key, **server_options)) 356 357 cmd_server = context.CLIARGS['api_server'] 358 cmd_token = GalaxyToken(token=context.CLIARGS['api_key']) 359 if cmd_server: 360 # Cmd args take precedence over the config entry but fist check if the arg was a name and use that config 361 # entry, otherwise create a new API entry for the server specified. 362 config_server = next((s for s in config_servers if s.name == cmd_server), None) 363 if config_server: 364 self.api_servers.append(config_server) 365 else: 366 self.api_servers.append(GalaxyAPI(self.galaxy, 'cmd_arg', cmd_server, token=cmd_token)) 367 else: 368 self.api_servers = config_servers 369 370 # Default to C.GALAXY_SERVER if no servers were defined 371 if len(self.api_servers) == 0: 372 self.api_servers.append(GalaxyAPI(self.galaxy, 'default', C.GALAXY_SERVER, token=cmd_token)) 373 374 context.CLIARGS['func']() 375 376 @property 377 def api(self): 378 if self._api: 379 return self._api 380 381 for server in self.api_servers: 382 try: 383 if u'v1' in server.available_api_versions: 384 self._api = server 385 break 386 except Exception: 387 continue 388 389 if not self._api: 390 self._api = self.api_servers[0] 391 392 return self._api 393 394 def _parse_requirements_file(self, requirements_file, allow_old_format=True): 395 """ 396 Parses an Ansible requirement.yml file and returns all the roles and/or collections defined in it. There are 2 397 requirements file format: 398 399 # v1 (roles only) 400 - src: The source of the role, required if include is not set. Can be Galaxy role name, URL to a SCM repo or tarball. 401 name: Downloads the role to the specified name, defaults to Galaxy name from Galaxy or name of repo if src is a URL. 402 scm: If src is a URL, specify the SCM. Only git or hd are supported and defaults ot git. 403 version: The version of the role to download. Can also be tag, commit, or branch name and defaults to master. 404 include: Path to additional requirements.yml files. 405 406 # v2 (roles and collections) 407 --- 408 roles: 409 # Same as v1 format just under the roles key 410 411 collections: 412 - namespace.collection 413 - name: namespace.collection 414 version: version identifier, multiple identifiers are separated by ',' 415 source: the URL or a predefined source name that relates to C.GALAXY_SERVER_LIST 416 417 :param requirements_file: The path to the requirements file. 418 :param allow_old_format: Will fail if a v1 requirements file is found and this is set to False. 419 :return: a dict containing roles and collections to found in the requirements file. 420 """ 421 requirements = { 422 'roles': [], 423 'collections': [], 424 } 425 426 b_requirements_file = to_bytes(requirements_file, errors='surrogate_or_strict') 427 if not os.path.exists(b_requirements_file): 428 raise AnsibleError("The requirements file '%s' does not exist." % to_native(requirements_file)) 429 430 display.vvv("Reading requirement file at '%s'" % requirements_file) 431 with open(b_requirements_file, 'rb') as req_obj: 432 try: 433 file_requirements = yaml.safe_load(req_obj) 434 except YAMLError as err: 435 raise AnsibleError( 436 "Failed to parse the requirements yml at '%s' with the following error:\n%s" 437 % (to_native(requirements_file), to_native(err))) 438 439 if file_requirements is None: 440 raise AnsibleError("No requirements found in file '%s'" % to_native(requirements_file)) 441 442 def parse_role_req(requirement): 443 if "include" not in requirement: 444 role = RoleRequirement.role_yaml_parse(requirement) 445 display.vvv("found role %s in yaml file" % to_text(role)) 446 if "name" not in role and "src" not in role: 447 raise AnsibleError("Must specify name or src for role") 448 return [GalaxyRole(self.galaxy, self.api, **role)] 449 else: 450 b_include_path = to_bytes(requirement["include"], errors="surrogate_or_strict") 451 if not os.path.isfile(b_include_path): 452 raise AnsibleError("Failed to find include requirements file '%s' in '%s'" 453 % (to_native(b_include_path), to_native(requirements_file))) 454 455 with open(b_include_path, 'rb') as f_include: 456 try: 457 return [GalaxyRole(self.galaxy, self.api, **r) for r in 458 (RoleRequirement.role_yaml_parse(i) for i in yaml.safe_load(f_include))] 459 except Exception as e: 460 raise AnsibleError("Unable to load data from include requirements file: %s %s" 461 % (to_native(requirements_file), to_native(e))) 462 463 if isinstance(file_requirements, list): 464 # Older format that contains only roles 465 if not allow_old_format: 466 raise AnsibleError("Expecting requirements file to be a dict with the key 'collections' that contains " 467 "a list of collections to install") 468 469 for role_req in file_requirements: 470 requirements['roles'] += parse_role_req(role_req) 471 472 else: 473 # Newer format with a collections and/or roles key 474 extra_keys = set(file_requirements.keys()).difference(set(['roles', 'collections'])) 475 if extra_keys: 476 raise AnsibleError("Expecting only 'roles' and/or 'collections' as base keys in the requirements " 477 "file. Found: %s" % (to_native(", ".join(extra_keys)))) 478 479 for role_req in file_requirements.get('roles', []): 480 requirements['roles'] += parse_role_req(role_req) 481 482 for collection_req in file_requirements.get('collections', []): 483 if isinstance(collection_req, dict): 484 req_name = collection_req.get('name', None) 485 if req_name is None: 486 raise AnsibleError("Collections requirement entry should contain the key name.") 487 488 req_version = collection_req.get('version', '*') 489 req_source = collection_req.get('source', None) 490 if req_source: 491 # Try and match up the requirement source with our list of Galaxy API servers defined in the 492 # config, otherwise create a server with that URL without any auth. 493 req_source = next(iter([a for a in self.api_servers if req_source in [a.name, a.api_server]]), 494 GalaxyAPI(self.galaxy, "explicit_requirement_%s" % req_name, req_source)) 495 496 requirements['collections'].append((req_name, req_version, req_source)) 497 else: 498 requirements['collections'].append((collection_req, '*', None)) 499 500 return requirements 501 502 @staticmethod 503 def exit_without_ignore(rc=1): 504 """ 505 Exits with the specified return code unless the 506 option --ignore-errors was specified 507 """ 508 if not context.CLIARGS['ignore_errors']: 509 raise AnsibleError('- you can use --ignore-errors to skip failed roles and finish processing the list.') 510 511 @staticmethod 512 def _display_role_info(role_info): 513 514 text = [u"", u"Role: %s" % to_text(role_info['name'])] 515 text.append(u"\tdescription: %s" % role_info.get('description', '')) 516 517 for k in sorted(role_info.keys()): 518 519 if k in GalaxyCLI.SKIP_INFO_KEYS: 520 continue 521 522 if isinstance(role_info[k], dict): 523 text.append(u"\t%s:" % (k)) 524 for key in sorted(role_info[k].keys()): 525 if key in GalaxyCLI.SKIP_INFO_KEYS: 526 continue 527 text.append(u"\t\t%s: %s" % (key, role_info[k][key])) 528 else: 529 text.append(u"\t%s: %s" % (k, role_info[k])) 530 531 return u'\n'.join(text) 532 533 @staticmethod 534 def _resolve_path(path): 535 return os.path.abspath(os.path.expanduser(os.path.expandvars(path))) 536 537 @staticmethod 538 def _get_skeleton_galaxy_yml(template_path, inject_data): 539 with open(to_bytes(template_path, errors='surrogate_or_strict'), 'rb') as template_obj: 540 meta_template = to_text(template_obj.read(), errors='surrogate_or_strict') 541 542 galaxy_meta = get_collections_galaxy_meta_info() 543 544 required_config = [] 545 optional_config = [] 546 for meta_entry in galaxy_meta: 547 config_list = required_config if meta_entry.get('required', False) else optional_config 548 549 value = inject_data.get(meta_entry['key'], None) 550 if not value: 551 meta_type = meta_entry.get('type', 'str') 552 553 if meta_type == 'str': 554 value = '' 555 elif meta_type == 'list': 556 value = [] 557 elif meta_type == 'dict': 558 value = {} 559 560 meta_entry['value'] = value 561 config_list.append(meta_entry) 562 563 link_pattern = re.compile(r"L\(([^)]+),\s+([^)]+)\)") 564 const_pattern = re.compile(r"C\(([^)]+)\)") 565 566 def comment_ify(v): 567 if isinstance(v, list): 568 v = ". ".join([l.rstrip('.') for l in v]) 569 570 v = link_pattern.sub(r"\1 <\2>", v) 571 v = const_pattern.sub(r"'\1'", v) 572 573 return textwrap.fill(v, width=117, initial_indent="# ", subsequent_indent="# ", break_on_hyphens=False) 574 575 def to_yaml(v): 576 return yaml.safe_dump(v, default_flow_style=False).rstrip() 577 578 env = Environment(loader=BaseLoader) 579 env.filters['comment_ify'] = comment_ify 580 env.filters['to_yaml'] = to_yaml 581 582 template = env.from_string(meta_template) 583 meta_value = template.render({'required_config': required_config, 'optional_config': optional_config}) 584 585 return meta_value 586 587############################ 588# execute actions 589############################ 590 591 def execute_role(self): 592 """ 593 Perform the action on an Ansible Galaxy role. Must be combined with a further action like delete/install/init 594 as listed below. 595 """ 596 # To satisfy doc build 597 pass 598 599 def execute_collection(self): 600 """ 601 Perform the action on an Ansible Galaxy collection. Must be combined with a further action like init/install as 602 listed below. 603 """ 604 # To satisfy doc build 605 pass 606 607 def execute_build(self): 608 """ 609 Build an Ansible Galaxy collection artifact that can be stored in a central repository like Ansible Galaxy. 610 By default, this command builds from the current working directory. You can optionally pass in the 611 collection input path (where the ``galaxy.yml`` file is). 612 """ 613 force = context.CLIARGS['force'] 614 output_path = GalaxyCLI._resolve_path(context.CLIARGS['output_path']) 615 b_output_path = to_bytes(output_path, errors='surrogate_or_strict') 616 617 if not os.path.exists(b_output_path): 618 os.makedirs(b_output_path) 619 elif os.path.isfile(b_output_path): 620 raise AnsibleError("- the output collection directory %s is a file - aborting" % to_native(output_path)) 621 622 for collection_path in context.CLIARGS['args']: 623 collection_path = GalaxyCLI._resolve_path(collection_path) 624 build_collection(collection_path, output_path, force) 625 626 def execute_init(self): 627 """ 628 Creates the skeleton framework of a role or collection that complies with the Galaxy metadata format. 629 Requires a role or collection name. The collection name must be in the format ``<namespace>.<collection>``. 630 """ 631 632 galaxy_type = context.CLIARGS['type'] 633 init_path = context.CLIARGS['init_path'] 634 force = context.CLIARGS['force'] 635 obj_skeleton = context.CLIARGS['{0}_skeleton'.format(galaxy_type)] 636 637 obj_name = context.CLIARGS['{0}_name'.format(galaxy_type)] 638 639 inject_data = dict( 640 description='your {0} description'.format(galaxy_type), 641 ansible_plugin_list_dir=get_versioned_doclink('plugins/plugins.html'), 642 ) 643 if galaxy_type == 'role': 644 inject_data.update(dict( 645 author='your name', 646 company='your company (optional)', 647 license='license (GPL-2.0-or-later, MIT, etc)', 648 role_name=obj_name, 649 role_type=context.CLIARGS['role_type'], 650 issue_tracker_url='http://example.com/issue/tracker', 651 repository_url='http://example.com/repository', 652 documentation_url='http://docs.example.com', 653 homepage_url='http://example.com', 654 min_ansible_version=ansible_version[:3], # x.y 655 )) 656 657 obj_path = os.path.join(init_path, obj_name) 658 elif galaxy_type == 'collection': 659 namespace, collection_name = obj_name.split('.', 1) 660 661 inject_data.update(dict( 662 namespace=namespace, 663 collection_name=collection_name, 664 version='1.0.0', 665 readme='README.md', 666 authors=['your name <example@domain.com>'], 667 license=['GPL-2.0-or-later'], 668 repository='http://example.com/repository', 669 documentation='http://docs.example.com', 670 homepage='http://example.com', 671 issues='http://example.com/issue/tracker', 672 )) 673 674 obj_path = os.path.join(init_path, namespace, collection_name) 675 676 b_obj_path = to_bytes(obj_path, errors='surrogate_or_strict') 677 678 if os.path.exists(b_obj_path): 679 if os.path.isfile(obj_path): 680 raise AnsibleError("- the path %s already exists, but is a file - aborting" % to_native(obj_path)) 681 elif not force: 682 raise AnsibleError("- the directory %s already exists. " 683 "You can use --force to re-initialize this directory,\n" 684 "however it will reset any main.yml files that may have\n" 685 "been modified there already." % to_native(obj_path)) 686 687 if obj_skeleton is not None: 688 own_skeleton = False 689 skeleton_ignore_expressions = C.GALAXY_ROLE_SKELETON_IGNORE 690 else: 691 own_skeleton = True 692 obj_skeleton = self.galaxy.default_role_skeleton_path 693 skeleton_ignore_expressions = ['^.*/.git_keep$'] 694 695 obj_skeleton = os.path.expanduser(obj_skeleton) 696 skeleton_ignore_re = [re.compile(x) for x in skeleton_ignore_expressions] 697 698 if not os.path.exists(obj_skeleton): 699 raise AnsibleError("- the skeleton path '{0}' does not exist, cannot init {1}".format( 700 to_native(obj_skeleton), galaxy_type) 701 ) 702 703 template_env = Environment(loader=FileSystemLoader(obj_skeleton)) 704 705 # create role directory 706 if not os.path.exists(b_obj_path): 707 os.makedirs(b_obj_path) 708 709 for root, dirs, files in os.walk(obj_skeleton, topdown=True): 710 rel_root = os.path.relpath(root, obj_skeleton) 711 rel_dirs = rel_root.split(os.sep) 712 rel_root_dir = rel_dirs[0] 713 if galaxy_type == 'collection': 714 # A collection can contain templates in playbooks/*/templates and roles/*/templates 715 in_templates_dir = rel_root_dir in ['playbooks', 'roles'] and 'templates' in rel_dirs 716 else: 717 in_templates_dir = rel_root_dir == 'templates' 718 719 dirs[:] = [d for d in dirs if not any(r.match(d) for r in skeleton_ignore_re)] 720 721 for f in files: 722 filename, ext = os.path.splitext(f) 723 724 if any(r.match(os.path.join(rel_root, f)) for r in skeleton_ignore_re): 725 continue 726 elif galaxy_type == 'collection' and own_skeleton and rel_root == '.' and f == 'galaxy.yml.j2': 727 # Special use case for galaxy.yml.j2 in our own default collection skeleton. We build the options 728 # dynamically which requires special options to be set. 729 730 # The templated data's keys must match the key name but the inject data contains collection_name 731 # instead of name. We just make a copy and change the key back to name for this file. 732 template_data = inject_data.copy() 733 template_data['name'] = template_data.pop('collection_name') 734 735 meta_value = GalaxyCLI._get_skeleton_galaxy_yml(os.path.join(root, rel_root, f), template_data) 736 b_dest_file = to_bytes(os.path.join(obj_path, rel_root, filename), errors='surrogate_or_strict') 737 with open(b_dest_file, 'wb') as galaxy_obj: 738 galaxy_obj.write(to_bytes(meta_value, errors='surrogate_or_strict')) 739 elif ext == ".j2" and not in_templates_dir: 740 src_template = os.path.join(rel_root, f) 741 dest_file = os.path.join(obj_path, rel_root, filename) 742 template_env.get_template(src_template).stream(inject_data).dump(dest_file, encoding='utf-8') 743 else: 744 f_rel_path = os.path.relpath(os.path.join(root, f), obj_skeleton) 745 shutil.copyfile(os.path.join(root, f), os.path.join(obj_path, f_rel_path)) 746 747 for d in dirs: 748 b_dir_path = to_bytes(os.path.join(obj_path, rel_root, d), errors='surrogate_or_strict') 749 if not os.path.exists(b_dir_path): 750 os.makedirs(b_dir_path) 751 752 display.display("- %s %s was created successfully" % (galaxy_type.title(), obj_name)) 753 754 def execute_info(self): 755 """ 756 prints out detailed information about an installed role as well as info available from the galaxy API. 757 """ 758 759 roles_path = context.CLIARGS['roles_path'] 760 761 data = '' 762 for role in context.CLIARGS['args']: 763 764 role_info = {'path': roles_path} 765 gr = GalaxyRole(self.galaxy, self.api, role) 766 767 install_info = gr.install_info 768 if install_info: 769 if 'version' in install_info: 770 install_info['installed_version'] = install_info['version'] 771 del install_info['version'] 772 role_info.update(install_info) 773 774 remote_data = False 775 if not context.CLIARGS['offline']: 776 remote_data = self.api.lookup_role_by_name(role, False) 777 778 if remote_data: 779 role_info.update(remote_data) 780 781 if gr.metadata: 782 role_info.update(gr.metadata) 783 784 req = RoleRequirement() 785 role_spec = req.role_yaml_parse({'role': role}) 786 if role_spec: 787 role_info.update(role_spec) 788 789 data = self._display_role_info(role_info) 790 # FIXME: This is broken in both 1.9 and 2.0 as 791 # _display_role_info() always returns something 792 if not data: 793 data = u"\n- the role %s was not found" % role 794 795 self.pager(data) 796 797 def execute_install(self): 798 """ 799 Install one or more roles(``ansible-galaxy role install``), or one or more collections(``ansible-galaxy collection install``). 800 You can pass in a list (roles or collections) or use the file 801 option listed below (these are mutually exclusive). If you pass in a list, it 802 can be a name (which will be downloaded via the galaxy API and github), or it can be a local tar archive file. 803 """ 804 if context.CLIARGS['type'] == 'collection': 805 collections = context.CLIARGS['args'] 806 force = context.CLIARGS['force'] 807 output_path = context.CLIARGS['collections_path'] 808 ignore_certs = context.CLIARGS['ignore_certs'] 809 ignore_errors = context.CLIARGS['ignore_errors'] 810 requirements_file = context.CLIARGS['requirements'] 811 no_deps = context.CLIARGS['no_deps'] 812 force_deps = context.CLIARGS['force_with_deps'] 813 814 if collections and requirements_file: 815 raise AnsibleError("The positional collection_name arg and --requirements-file are mutually exclusive.") 816 elif not collections and not requirements_file: 817 raise AnsibleError("You must specify a collection name or a requirements file.") 818 819 if requirements_file: 820 requirements_file = GalaxyCLI._resolve_path(requirements_file) 821 requirements = self._parse_requirements_file(requirements_file, allow_old_format=False)['collections'] 822 else: 823 requirements = [] 824 for collection_input in collections: 825 requirement = None 826 if os.path.isfile(to_bytes(collection_input, errors='surrogate_or_strict')) or \ 827 urlparse(collection_input).scheme.lower() in ['http', 'https']: 828 # Arg is a file path or URL to a collection 829 name = collection_input 830 else: 831 name, dummy, requirement = collection_input.partition(':') 832 requirements.append((name, requirement or '*', None)) 833 834 output_path = GalaxyCLI._resolve_path(output_path) 835 collections_path = C.COLLECTIONS_PATHS 836 837 if len([p for p in collections_path if p.startswith(output_path)]) == 0: 838 display.warning("The specified collections path '%s' is not part of the configured Ansible " 839 "collections paths '%s'. The installed collection won't be picked up in an Ansible " 840 "run." % (to_text(output_path), to_text(":".join(collections_path)))) 841 842 if os.path.split(output_path)[1] != 'ansible_collections': 843 output_path = os.path.join(output_path, 'ansible_collections') 844 845 b_output_path = to_bytes(output_path, errors='surrogate_or_strict') 846 if not os.path.exists(b_output_path): 847 os.makedirs(b_output_path) 848 849 install_collections(requirements, output_path, self.api_servers, (not ignore_certs), ignore_errors, 850 no_deps, force, force_deps) 851 852 return 0 853 854 role_file = context.CLIARGS['role_file'] 855 856 if not context.CLIARGS['args'] and role_file is None: 857 # the user needs to specify one of either --role-file or specify a single user/role name 858 raise AnsibleOptionsError("- you must specify a user/role name or a roles file") 859 860 no_deps = context.CLIARGS['no_deps'] 861 force_deps = context.CLIARGS['force_with_deps'] 862 863 force = context.CLIARGS['force'] or force_deps 864 865 roles_left = [] 866 if role_file: 867 if not (role_file.endswith('.yaml') or role_file.endswith('.yml')): 868 raise AnsibleError("Invalid role requirements file, it must end with a .yml or .yaml extension") 869 870 roles_left = self._parse_requirements_file(role_file)['roles'] 871 else: 872 # roles were specified directly, so we'll just go out grab them 873 # (and their dependencies, unless the user doesn't want us to). 874 for rname in context.CLIARGS['args']: 875 role = RoleRequirement.role_yaml_parse(rname.strip()) 876 roles_left.append(GalaxyRole(self.galaxy, self.api, **role)) 877 878 for role in roles_left: 879 # only process roles in roles files when names matches if given 880 if role_file and context.CLIARGS['args'] and role.name not in context.CLIARGS['args']: 881 display.vvv('Skipping role %s' % role.name) 882 continue 883 884 display.vvv('Processing role %s ' % role.name) 885 886 # query the galaxy API for the role data 887 888 if role.install_info is not None: 889 if role.install_info['version'] != role.version or force: 890 if force: 891 display.display('- changing role %s from %s to %s' % 892 (role.name, role.install_info['version'], role.version or "unspecified")) 893 role.remove() 894 else: 895 display.warning('- %s (%s) is already installed - use --force to change version to %s' % 896 (role.name, role.install_info['version'], role.version or "unspecified")) 897 continue 898 else: 899 if not force: 900 display.display('- %s is already installed, skipping.' % str(role)) 901 continue 902 903 try: 904 installed = role.install() 905 except AnsibleError as e: 906 display.warning(u"- %s was NOT installed successfully: %s " % (role.name, to_text(e))) 907 self.exit_without_ignore() 908 continue 909 910 # install dependencies, if we want them 911 if not no_deps and installed: 912 if not role.metadata: 913 display.warning("Meta file %s is empty. Skipping dependencies." % role.path) 914 else: 915 role_dependencies = role.metadata.get('dependencies') or [] 916 for dep in role_dependencies: 917 display.debug('Installing dep %s' % dep) 918 dep_req = RoleRequirement() 919 dep_info = dep_req.role_yaml_parse(dep) 920 dep_role = GalaxyRole(self.galaxy, self.api, **dep_info) 921 if '.' not in dep_role.name and '.' not in dep_role.src and dep_role.scm is None: 922 # we know we can skip this, as it's not going to 923 # be found on galaxy.ansible.com 924 continue 925 if dep_role.install_info is None: 926 if dep_role not in roles_left: 927 display.display('- adding dependency: %s' % to_text(dep_role)) 928 roles_left.append(dep_role) 929 else: 930 display.display('- dependency %s already pending installation.' % dep_role.name) 931 else: 932 if dep_role.install_info['version'] != dep_role.version: 933 if force_deps: 934 display.display('- changing dependant role %s from %s to %s' % 935 (dep_role.name, dep_role.install_info['version'], dep_role.version or "unspecified")) 936 dep_role.remove() 937 roles_left.append(dep_role) 938 else: 939 display.warning('- dependency %s (%s) from role %s differs from already installed version (%s), skipping' % 940 (to_text(dep_role), dep_role.version, role.name, dep_role.install_info['version'])) 941 else: 942 if force_deps: 943 roles_left.append(dep_role) 944 else: 945 display.display('- dependency %s is already installed, skipping.' % dep_role.name) 946 947 if not installed: 948 display.warning("- %s was NOT installed successfully." % role.name) 949 self.exit_without_ignore() 950 951 return 0 952 953 def execute_remove(self): 954 """ 955 removes the list of roles passed as arguments from the local system. 956 """ 957 958 if not context.CLIARGS['args']: 959 raise AnsibleOptionsError('- you must specify at least one role to remove.') 960 961 for role_name in context.CLIARGS['args']: 962 role = GalaxyRole(self.galaxy, self.api, role_name) 963 try: 964 if role.remove(): 965 display.display('- successfully removed %s' % role_name) 966 else: 967 display.display('- %s is not installed, skipping.' % role_name) 968 except Exception as e: 969 raise AnsibleError("Failed to remove role %s: %s" % (role_name, to_native(e))) 970 971 return 0 972 973 def execute_list(self): 974 """ 975 lists the roles installed on the local system or matches a single role passed as an argument. 976 """ 977 978 def _display_role(gr): 979 install_info = gr.install_info 980 version = None 981 if install_info: 982 version = install_info.get("version", None) 983 if not version: 984 version = "(unknown version)" 985 display.display("- %s, %s" % (gr.name, version)) 986 987 if context.CLIARGS['role']: 988 # show the requested role, if it exists 989 name = context.CLIARGS['role'] 990 gr = GalaxyRole(self.galaxy, self.api, name) 991 if gr.metadata: 992 display.display('# %s' % os.path.dirname(gr.path)) 993 _display_role(gr) 994 else: 995 display.display("- the role %s was not found" % name) 996 else: 997 # show all valid roles in the roles_path directory 998 roles_path = context.CLIARGS['roles_path'] 999 path_found = False 1000 warnings = [] 1001 for path in roles_path: 1002 role_path = os.path.expanduser(path) 1003 if not os.path.exists(role_path): 1004 warnings.append("- the configured path %s does not exist." % role_path) 1005 continue 1006 elif not os.path.isdir(role_path): 1007 warnings.append("- the configured path %s, exists, but it is not a directory." % role_path) 1008 continue 1009 display.display('# %s' % role_path) 1010 path_files = os.listdir(role_path) 1011 path_found = True 1012 for path_file in path_files: 1013 gr = GalaxyRole(self.galaxy, self.api, path_file, path=path) 1014 if gr.metadata: 1015 _display_role(gr) 1016 for w in warnings: 1017 display.warning(w) 1018 if not path_found: 1019 raise AnsibleOptionsError("- None of the provided paths was usable. Please specify a valid path with --roles-path") 1020 return 0 1021 1022 def execute_publish(self): 1023 """ 1024 Publish a collection into Ansible Galaxy. Requires the path to the collection tarball to publish. 1025 """ 1026 collection_path = GalaxyCLI._resolve_path(context.CLIARGS['args']) 1027 wait = context.CLIARGS['wait'] 1028 timeout = context.CLIARGS['import_timeout'] 1029 1030 publish_collection(collection_path, self.api, wait, timeout) 1031 1032 def execute_search(self): 1033 ''' searches for roles on the Ansible Galaxy server''' 1034 page_size = 1000 1035 search = None 1036 1037 if context.CLIARGS['args']: 1038 search = '+'.join(context.CLIARGS['args']) 1039 1040 if not search and not context.CLIARGS['platforms'] and not context.CLIARGS['galaxy_tags'] and not context.CLIARGS['author']: 1041 raise AnsibleError("Invalid query. At least one search term, platform, galaxy tag or author must be provided.") 1042 1043 response = self.api.search_roles(search, platforms=context.CLIARGS['platforms'], 1044 tags=context.CLIARGS['galaxy_tags'], author=context.CLIARGS['author'], page_size=page_size) 1045 1046 if response['count'] == 0: 1047 display.display("No roles match your search.", color=C.COLOR_ERROR) 1048 return True 1049 1050 data = [u''] 1051 1052 if response['count'] > page_size: 1053 data.append(u"Found %d roles matching your search. Showing first %s." % (response['count'], page_size)) 1054 else: 1055 data.append(u"Found %d roles matching your search:" % response['count']) 1056 1057 max_len = [] 1058 for role in response['results']: 1059 max_len.append(len(role['username'] + '.' + role['name'])) 1060 name_len = max(max_len) 1061 format_str = u" %%-%ds %%s" % name_len 1062 data.append(u'') 1063 data.append(format_str % (u"Name", u"Description")) 1064 data.append(format_str % (u"----", u"-----------")) 1065 for role in response['results']: 1066 data.append(format_str % (u'%s.%s' % (role['username'], role['name']), role['description'])) 1067 1068 data = u'\n'.join(data) 1069 self.pager(data) 1070 1071 return True 1072 1073 def execute_import(self): 1074 """ used to import a role into Ansible Galaxy """ 1075 1076 colors = { 1077 'INFO': 'normal', 1078 'WARNING': C.COLOR_WARN, 1079 'ERROR': C.COLOR_ERROR, 1080 'SUCCESS': C.COLOR_OK, 1081 'FAILED': C.COLOR_ERROR, 1082 } 1083 1084 github_user = to_text(context.CLIARGS['github_user'], errors='surrogate_or_strict') 1085 github_repo = to_text(context.CLIARGS['github_repo'], errors='surrogate_or_strict') 1086 1087 if context.CLIARGS['check_status']: 1088 task = self.api.get_import_task(github_user=github_user, github_repo=github_repo) 1089 else: 1090 # Submit an import request 1091 task = self.api.create_import_task(github_user, github_repo, 1092 reference=context.CLIARGS['reference'], 1093 role_name=context.CLIARGS['role_name']) 1094 1095 if len(task) > 1: 1096 # found multiple roles associated with github_user/github_repo 1097 display.display("WARNING: More than one Galaxy role associated with Github repo %s/%s." % (github_user, github_repo), 1098 color='yellow') 1099 display.display("The following Galaxy roles are being updated:" + u'\n', color=C.COLOR_CHANGED) 1100 for t in task: 1101 display.display('%s.%s' % (t['summary_fields']['role']['namespace'], t['summary_fields']['role']['name']), color=C.COLOR_CHANGED) 1102 display.display(u'\nTo properly namespace this role, remove each of the above and re-import %s/%s from scratch' % (github_user, github_repo), 1103 color=C.COLOR_CHANGED) 1104 return 0 1105 # found a single role as expected 1106 display.display("Successfully submitted import request %d" % task[0]['id']) 1107 if not context.CLIARGS['wait']: 1108 display.display("Role name: %s" % task[0]['summary_fields']['role']['name']) 1109 display.display("Repo: %s/%s" % (task[0]['github_user'], task[0]['github_repo'])) 1110 1111 if context.CLIARGS['check_status'] or context.CLIARGS['wait']: 1112 # Get the status of the import 1113 msg_list = [] 1114 finished = False 1115 while not finished: 1116 task = self.api.get_import_task(task_id=task[0]['id']) 1117 for msg in task[0]['summary_fields']['task_messages']: 1118 if msg['id'] not in msg_list: 1119 display.display(msg['message_text'], color=colors[msg['message_type']]) 1120 msg_list.append(msg['id']) 1121 if task[0]['state'] in ['SUCCESS', 'FAILED']: 1122 finished = True 1123 else: 1124 time.sleep(10) 1125 1126 return 0 1127 1128 def execute_setup(self): 1129 """ Setup an integration from Github or Travis for Ansible Galaxy roles""" 1130 1131 if context.CLIARGS['setup_list']: 1132 # List existing integration secrets 1133 secrets = self.api.list_secrets() 1134 if len(secrets) == 0: 1135 # None found 1136 display.display("No integrations found.") 1137 return 0 1138 display.display(u'\n' + "ID Source Repo", color=C.COLOR_OK) 1139 display.display("---------- ---------- ----------", color=C.COLOR_OK) 1140 for secret in secrets: 1141 display.display("%-10s %-10s %s/%s" % (secret['id'], secret['source'], secret['github_user'], 1142 secret['github_repo']), color=C.COLOR_OK) 1143 return 0 1144 1145 if context.CLIARGS['remove_id']: 1146 # Remove a secret 1147 self.api.remove_secret(context.CLIARGS['remove_id']) 1148 display.display("Secret removed. Integrations using this secret will not longer work.", color=C.COLOR_OK) 1149 return 0 1150 1151 source = context.CLIARGS['source'] 1152 github_user = context.CLIARGS['github_user'] 1153 github_repo = context.CLIARGS['github_repo'] 1154 secret = context.CLIARGS['secret'] 1155 1156 resp = self.api.add_secret(source, github_user, github_repo, secret) 1157 display.display("Added integration for %s %s/%s" % (resp['source'], resp['github_user'], resp['github_repo'])) 1158 1159 return 0 1160 1161 def execute_delete(self): 1162 """ Delete a role from Ansible Galaxy. """ 1163 1164 github_user = context.CLIARGS['github_user'] 1165 github_repo = context.CLIARGS['github_repo'] 1166 resp = self.api.delete_role(github_user, github_repo) 1167 1168 if len(resp['deleted_roles']) > 1: 1169 display.display("Deleted the following roles:") 1170 display.display("ID User Name") 1171 display.display("------ --------------- ----------") 1172 for role in resp['deleted_roles']: 1173 display.display("%-8s %-15s %s" % (role.id, role.namespace, role.name)) 1174 1175 display.display(resp['status']) 1176 1177 return True 1178