# (c) 2012-2014, Michael DeHaan # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # Make coding more python3-ish from __future__ import (absolute_import, division, print_function) __metaclass__ = type from six import iteritems, string_types import os from hashlib import md5 from ansible.errors import AnsibleError, AnsibleParserError from ansible.parsing.yaml import DataLoader from ansible.playbook.attribute import FieldAttribute from ansible.playbook.base import Base from ansible.playbook.block import Block from ansible.parsing.yaml.objects import AnsibleBaseYAMLObject, AnsibleMapping __all__ = ['Role'] # The role cache is used to prevent re-loading roles, which # may already exist. Keys into this cache are the MD5 hash # of the role definition (for dictionary definitions, this # will be based on the repr() of the dictionary object) _ROLE_CACHE = dict() # The valid metadata keys for meta/main.yml files _VALID_METADATA_KEYS = [ 'dependencies', 'allow_duplicates', 'galaxy_info', ] class Role(Base): _role_name = FieldAttribute(isa='string') _role_path = FieldAttribute(isa='string') _src = FieldAttribute(isa='string') _scm = FieldAttribute(isa='string') _version = FieldAttribute(isa='string') _task_blocks = FieldAttribute(isa='list', default=[]) _handler_blocks = FieldAttribute(isa='list', default=[]) _params = FieldAttribute(isa='dict', default=dict()) _default_vars = FieldAttribute(isa='dict', default=dict()) _role_vars = FieldAttribute(isa='dict', default=dict()) # Attributes based on values in metadata. These MUST line up # with the values stored in _VALID_METADATA_KEYS _dependencies = FieldAttribute(isa='list', default=[]) _allow_duplicates = FieldAttribute(isa='bool', default=False) _galaxy_info = FieldAttribute(isa='dict', default=dict()) def __init__(self, loader=DataLoader): self._role_path = None self._parents = [] super(Role, self).__init__(loader=loader) def __repr__(self): return self.get_name() def get_name(self): return self._attributes['role_name'] @staticmethod def load(data, parent_role=None): assert isinstance(data, string_types) or isinstance(data, dict) # Check to see if this role has been loaded already, based on the # role definition, partially to save loading time and also to make # sure that roles are run a single time unless specifically allowed # to run more than once # FIXME: the tags and conditionals, if specified in the role def, # should not figure into the resulting hash cache_key = md5(repr(data)) if cache_key in _ROLE_CACHE: r = _ROLE_CACHE[cache_key] else: try: # load the role r = Role() r.load_data(data) # and cache it for next time _ROLE_CACHE[cache_key] = r except RuntimeError: raise AnsibleError("A recursive loop was detected while loading your roles", obj=data) # now add the parent to the (new) role if parent_role: r.add_parent(parent_role) return r #------------------------------------------------------------------------------ # munge, and other functions used for loading the ds def munge(self, ds): # create the new ds as an AnsibleMapping, so we can preserve any line/column # data from the parser, and copy that info from the old ds (if applicable) new_ds = AnsibleMapping() if isinstance(ds, AnsibleBaseYAMLObject): new_ds.copy_position_info(ds) # Role definitions can be strings or dicts, so we fix things up here. # Anything that is not a role name, tag, or conditional will also be # added to the params sub-dictionary for loading later if isinstance(ds, string_types): new_ds['role_name'] = ds else: # munge the role ds here to correctly fill in the various fields which # may be used to define the role, like: role, src, scm, etc. ds = self._munge_role(ds) # now we split any random role params off from the role spec and store # them in a dictionary of params for parsing later params = dict() attr_names = [attr_name for (attr_name, attr_value) in self._get_base_attributes().iteritems()] for (key, value) in iteritems(ds): if key not in attr_names and key != 'role': # this key does not match a field attribute, so it must be a role param params[key] = value else: # this is a field attribute, so copy it over directly new_ds[key] = value new_ds['params'] = params # Set the role name and path, based on the role definition (role_name, role_path) = self._get_role_path(new_ds.get('role_name')) new_ds['role_name'] = role_name new_ds['role_path'] = role_path # load the role's files, if they exist new_ds['task_blocks'] = self._load_role_yaml(role_path, 'tasks') new_ds['handler_blocks'] = self._load_role_yaml(role_path, 'handlers') new_ds['default_vars'] = self._load_role_yaml(role_path, 'defaults') new_ds['role_vars'] = self._load_role_yaml(role_path, 'vars') # we treat metadata slightly differently: we instead pull out the # valid metadata keys and munge them directly into new_ds metadata_ds = self._munge_metadata(role_name, role_path) new_ds.update(metadata_ds) # and return the newly munged ds return new_ds def _load_role_yaml(self, role_path, subdir): file_path = os.path.join(role_path, subdir) if os.path.exists(file_path) and os.path.isdir(file_path): main_file = self._resolve_main(file_path) if os.path.exists(main_file): return self._loader.load_from_file(main_file) return None def _resolve_main(self, basepath): ''' flexibly handle variations in main filenames ''' possible_mains = ( os.path.join(basepath, 'main'), os.path.join(basepath, 'main.yml'), os.path.join(basepath, 'main.yaml'), os.path.join(basepath, 'main.json'), ) if sum([os.path.isfile(x) for x in possible_mains]) > 1: raise AnsibleError("found multiple main files at %s, only one allowed" % (basepath)) else: for m in possible_mains: if os.path.isfile(m): return m # exactly one main file return possible_mains[0] # zero mains (we still need to return something) def _get_role_path(self, role): ''' the 'role', as specified in the ds (or as a bare string), can either be a simple name or a full path. If it is a full path, we use the basename as the role name, otherwise we take the name as-given and append it to the default role path ''' # FIXME: this should use unfrackpath once the utils code has been sorted out role_path = os.path.normpath(role) if os.path.exists(role_path): role_name = os.path.basename(role) return (role_name, role_path) else: for path in ('./roles', '/etc/ansible/roles'): role_path = os.path.join(path, role) if os.path.exists(role_path): return (role, role_path) # FIXME: make the parser smart about list/string entries # in the yaml so the error line/file can be reported # here raise AnsibleError("the role '%s' was not found" % role, obj=role) def _repo_url_to_role_name(self, repo_url): # gets the role name out of a repo like # http://git.example.com/repos/repo.git" => "repo" if '://' not in repo_url and '@' not in repo_url: return repo_url trailing_path = repo_url.split('/')[-1] if trailing_path.endswith('.git'): trailing_path = trailing_path[:-4] if trailing_path.endswith('.tar.gz'): trailing_path = trailing_path[:-7] if ',' in trailing_path: trailing_path = trailing_path.split(',')[0] return trailing_path def _role_spec_parse(self, role_spec): # takes a repo and a version like # git+http://git.example.com/repos/repo.git,v1.0 # and returns a list of properties such as: # { # 'scm': 'git', # 'src': 'http://git.example.com/repos/repo.git', # 'version': 'v1.0', # 'name': 'repo' # } default_role_versions = dict(git='master', hg='tip') role_spec = role_spec.strip() role_version = '' if role_spec == "" or role_spec.startswith("#"): return (None, None, None, None) tokens = [s.strip() for s in role_spec.split(',')] # assume https://github.com URLs are git+https:// URLs and not # tarballs unless they end in '.zip' if 'github.com/' in tokens[0] and not tokens[0].startswith("git+") and not tokens[0].endswith('.tar.gz'): tokens[0] = 'git+' + tokens[0] if '+' in tokens[0]: (scm, role_url) = tokens[0].split('+') else: scm = None role_url = tokens[0] if len(tokens) >= 2: role_version = tokens[1] if len(tokens) == 3: role_name = tokens[2] else: role_name = self._repo_url_to_role_name(tokens[0]) if scm and not role_version: role_version = default_role_versions.get(scm, '') return dict(scm=scm, src=role_url, version=role_version, role_name=role_name) def _munge_role(self, ds): if 'role' in ds: # Old style: {role: "galaxy.role,version,name", other_vars: "here" } role_info = self._role_spec_parse(ds['role']) if isinstance(role_info, dict): # Warning: Slight change in behaviour here. name may be being # overloaded. Previously, name was only a parameter to the role. # Now it is both a parameter to the role and the name that # ansible-galaxy will install under on the local system. if 'name' in ds and 'name' in role_info: del role_info['name'] ds.update(role_info) else: # New style: { src: 'galaxy.role,version,name', other_vars: "here" } if 'github.com' in ds["src"] and 'http' in ds["src"] and '+' not in ds["src"] and not ds["src"].endswith('.tar.gz'): ds["src"] = "git+" + ds["src"] if '+' in ds["src"]: (scm, src) = ds["src"].split('+') ds["scm"] = scm ds["src"] = src if 'name' in role: ds["role"] = ds["name"] del ds["name"] else: ds["role"] = self._repo_url_to_role_name(ds["src"]) # set some values to a default value, if none were specified ds.setdefault('version', '') ds.setdefault('scm', None) return ds def _munge_metadata(self, role_name, role_path): ''' loads the metadata main.yml (if it exists) and creates a clean datastructure we can merge into the newly munged ds ''' meta_ds = dict() metadata = self._load_role_yaml(role_path, 'meta') if metadata: if not isinstance(metadata, dict): raise AnsibleParserError("The metadata for role '%s' should be a dictionary, instead it is a %s" % (role_name, type(metadata)), obj=metadata) for key in metadata: if key in _VALID_METADATA_KEYS: if isinstance(metadata[key], dict): meta_ds[key] = metadata[key].copy() elif isinstance(metadata[key], list): meta_ds[key] = metadata[key][:] else: meta_ds[key] = metadata[key] else: raise AnsibleParserError("%s is not a valid metadata key for role '%s'" % (key, role_name), obj=metadata) return meta_ds #------------------------------------------------------------------------------ # attribute loading defs def _load_list_of_blocks(self, ds): assert type(ds) == list block_list = [] for block in ds: b = Block(block) block_list.append(b) return block_list def _load_task_blocks(self, attr, ds): if ds is None: return [] return self._load_list_of_blocks(ds) def _load_handler_blocks(self, attr, ds): if ds is None: return [] return self._load_list_of_blocks(ds) def _load_dependencies(self, attr, ds): assert type(ds) in (list, type(None)) deps = [] if ds: for role_def in ds: r = Role.load(role_def, parent_role=self) deps.append(r) return deps #------------------------------------------------------------------------------ # other functions def add_parent(self, parent_role): ''' adds a role to the list of this roles parents ''' assert isinstance(parent_role, Role) if parent_role not in self._parents: self._parents.append(parent_role) def get_parents(self): return self._parents # FIXME: not yet used #def get_variables(self): # # returns the merged variables for this role, including # # recursively merging those of all child roles # return dict() def get_direct_dependencies(self): return self._attributes['dependencies'][:] def get_all_dependencies(self): # returns a list built recursively, of all deps from # all child dependencies child_deps = [] direct_deps = self.get_direct_dependencies() for dep in direct_deps: dep_deps = dep.get_all_dependencies() for dep_dep in dep_deps: if dep_dep not in child_deps: child_deps.append(dep_dep) return direct_deps + child_deps