diff --git a/setup.py b/setup.py index d83d6265..f6b49485 100644 --- a/setup.py +++ b/setup.py @@ -7,45 +7,17 @@ import os import fnmatch import re import sys - +import subprocess import yaml -import sh # Always prefer setuptools over distutils from setuptools import setup, Command +from setuptools_lint.setuptools_command import PylintCommand from six import string_types +from six.moves import reload_module from yamllint.config import YamlLintConfig from yamllint.cli import Format from yamllint import linter -from sh import ErrorReturnCode - - -def find_dirs(base_dir, exclude_dirs, include_dirs, dir_name): - ''' find directories matching dir_name ''' - found = [] - exclude_regex = '' - include_regex = '' - - if exclude_dirs is not None: - exclude_regex = r'|'.join([fnmatch.translate(x) for x in exclude_dirs]) or r'$.' - - if include_dirs is not None: - include_regex = r'|'.join([fnmatch.translate(x) for x in include_dirs]) or r'$.' - - for root, dirs, _files in os.walk(base_dir): - if exclude_dirs is not None: - # filter out excludes for dirs - dirs[:] = [d for d in dirs if not re.match(exclude_regex, d)] - - if include_dirs is not None: - # filter for includes for dirs - dirs[:] = [d for d in dirs if re.match(include_regex, d)] - - if dir_name in dirs: - found.append(os.path.join(root, dir_name)) - dirs = [] - - return found def find_files(base_dir, exclude_dirs, include_dirs, file_regex): @@ -57,6 +29,7 @@ def find_files(base_dir, exclude_dirs, include_dirs, file_regex): if exclude_dirs is not None: exclude_regex = r'|'.join([fnmatch.translate(x) for x in exclude_dirs]) or r'$.' + # Don't use include_dirs, it is broken if include_dirs is not None: include_regex = r'|'.join([fnmatch.translate(x) for x in include_dirs]) or r'$.' @@ -75,6 +48,61 @@ def find_files(base_dir, exclude_dirs, include_dirs, file_regex): return found +def recursive_search(search_list, field): + """ + Takes a list with nested dicts, and searches all dicts for a key of the + field provided. If the items in the list are not dicts, the items are not + processed. + """ + fields_found = [] + + for item in search_list: + if isinstance(item, dict): + for key, value in item.items(): + if key == field: + fields_found.append(value) + elif isinstance(value, list): + results = recursive_search(value, field) + for result in results: + fields_found.append(result) + + return fields_found + + +def find_entrypoint_playbooks(): + '''find entry point playbooks as defined by openshift-ansible''' + playbooks = set() + included_playbooks = set() + + exclude_dirs = ['adhoc', 'tasks'] + for yaml_file in find_files( + os.path.join(os.getcwd(), 'playbooks'), + exclude_dirs, None, r'\.ya?ml$'): + with open(yaml_file, 'r') as contents: + for task in yaml.safe_load(contents) or {}: + if not isinstance(task, dict): + # Skip yaml files which are not a dictionary of tasks + continue + if 'include' in task or 'import_playbook' in task: + # Add the playbook and capture included playbooks + playbooks.add(yaml_file) + if 'include' in task: + directive = task['include'] + else: + directive = task['import_playbook'] + included_file_name = directive.split()[0] + included_file = os.path.normpath( + os.path.join(os.path.dirname(yaml_file), + included_file_name)) + included_playbooks.add(included_file) + elif 'hosts' in task: + playbooks.add(yaml_file) + # Evaluate the difference between all playbooks and included playbooks + entrypoint_playbooks = sorted(playbooks.difference(included_playbooks)) + print('Entry point playbook count: {}'.format(len(entrypoint_playbooks))) + return entrypoint_playbooks + + class OpenShiftAnsibleYamlLint(Command): ''' Command to run yamllint ''' description = "Run yamllint tests" @@ -145,6 +173,195 @@ class OpenShiftAnsibleYamlLint(Command): raise SystemExit(1) +class OpenShiftAnsiblePylint(PylintCommand): + ''' Class to override the default behavior of PylintCommand ''' + + # Reason: This method needs to be an instance method to conform to the + # overridden method's signature + # Status: permanently disabled + # pylint: disable=no-self-use + def find_all_modules(self): + ''' find all python files to test ''' + exclude_dirs = ['.tox', 'utils', 'test', 'tests', 'git'] + modules = [] + for match in find_files(os.getcwd(), exclude_dirs, None, r'\.py$'): + package = os.path.basename(match).replace('.py', '') + modules.append(('openshift_ansible', package, match)) + return modules + + def get_finalized_command(self, cmd): + ''' override get_finalized_command to ensure we use our + find_all_modules method ''' + if cmd == 'build_py': + return self + + # Reason: This method needs to be an instance method to conform to the + # overridden method's signature + # Status: permanently disabled + # pylint: disable=no-self-use + def with_project_on_sys_path(self, func, func_args, func_kwargs): + ''' override behavior, since we don't need to build ''' + return func(*func_args, **func_kwargs) + + +class OpenShiftAnsibleGenerateValidation(Command): + ''' Command to run generated module validation''' + description = "Run generated module validation" + user_options = [] + + def initialize_options(self): + ''' initialize_options ''' + pass + + def finalize_options(self): + ''' finalize_options ''' + pass + + # self isn't used but I believe is required when it is called. + # pylint: disable=no-self-use + def run(self): + ''' run command ''' + # find the files that call generate + generate_files = find_files('roles', + ['inventory', + 'test', + 'playbooks', + 'utils'], + None, + 'generate.py$') + + if len(generate_files) < 1: + print('Did not find any code generation. Please verify module code generation.') # noqa: E501 + raise SystemExit(1) + + errors = False + for gen in generate_files: + print('Checking generated module code: {0}'.format(gen)) + try: + sys.path.insert(0, os.path.dirname(gen)) + # we are importing dynamically. This isn't in + # the python path. + # pylint: disable=import-error + import generate + reload_module(generate) + generate.verify() + except generate.GenerateAnsibleException as gae: + print(gae.args) + errors = True + + if errors: + print('Found errors while generating module code.') + raise SystemExit(1) + + print('\nAll generate scripts passed.\n') + + +class OpenShiftAnsibleSyntaxCheck(Command): + ''' Command to run Ansible syntax check''' + description = "Run Ansible syntax check" + user_options = [] + + # Colors + FAIL = '\033[31m' # Red + ENDC = '\033[0m' # Reset + + def initialize_options(self): + ''' initialize_options ''' + pass + + def finalize_options(self): + ''' finalize_options ''' + pass + + def deprecate_jinja2_in_when(self, yaml_contents, yaml_file): + ''' Check for Jinja2 templating delimiters in when conditions ''' + test_result = False + failed_items = [] + + search_results = recursive_search(yaml_contents, 'when') + for item in search_results: + if isinstance(item, str): + if '{{' in item or '{%' in item: + failed_items.append(item) + else: + for sub_item in item: + if '{{' in sub_item or '{%' in sub_item: + failed_items.append(sub_item) + + if len(failed_items) > 0: + print('{}Error: Usage of Jinja2 templating delimiters in when ' + 'conditions is deprecated in Ansible 2.3.\n' + ' File: {}'.format(self.FAIL, yaml_file)) + for item in failed_items: + print(' Found: "{}"'.format(item)) + print(self.ENDC) + test_result = True + + return test_result + + def deprecate_include(self, yaml_contents, yaml_file): + ''' Check for usage of include directive ''' + test_result = False + + search_results = recursive_search(yaml_contents, 'include') + + if len(search_results) > 0: + print('{}Error: The `include` directive is deprecated in Ansible 2.4.\n' + 'https://github.com/ansible/ansible/blob/devel/CHANGELOG.md\n' + ' File: {}'.format(self.FAIL, yaml_file)) + for item in search_results: + print(' Found: "include: {}"'.format(item)) + print(self.ENDC) + test_result = True + + return test_result + + def run(self): + ''' run command ''' + + has_errors = False + + print('Ansible Deprecation Checks') + exclude_dirs = ['adhoc', 'files', 'meta', 'vars', 'defaults', '.tox'] + for yaml_file in find_files( + os.getcwd(), exclude_dirs, None, r'\.ya?ml$'): + with open(yaml_file, 'r') as contents: + yaml_contents = yaml.safe_load(contents) + if not isinstance(yaml_contents, list): + continue + + # Check for Jinja2 templating delimiters in when conditions + result = self.deprecate_jinja2_in_when(yaml_contents, yaml_file) + has_errors = result or has_errors + + # Check for usage of include: directive + result = self.deprecate_include(yaml_contents, yaml_file) + has_errors = result or has_errors + + if not has_errors: + print('...PASSED') + print('Ansible Playbook Entry Point Syntax Checks') + for playbook in find_entrypoint_playbooks(): + print('-' * 60) + print('Syntax checking playbook: {}'.format(playbook)) + + # --syntax-check each entry point playbook + try: + # Create a host group list to avoid WARNING on unmatched host patterns + tox_ansible_inv = os.environ['TOX_ANSIBLE_INV_PATH'] + subprocess.check_output( + ['ansible-playbook', '-i', tox_ansible_inv, + '--syntax-check', playbook, '-e', '@{}_extras'.format(tox_ansible_inv)] + ) + except subprocess.CalledProcessError as cpe: + print('{}Execution failed: {}{}'.format( + self.FAIL, cpe, self.ENDC)) + has_errors = True + + if has_errors: + raise SystemExit(1) + + class UnsupportedCommand(Command): ''' Basic Command to override unsupported commands ''' user_options = [] @@ -174,69 +391,6 @@ class UnsupportedCommand(Command): print("Unsupported command for openshift-ansible") -class MoleculeTests(Command): - ''' Command for running molecule tests ''' - description = "Run molecule tests" - user_options = [ - ('roles=', 'r', 'which roles to test'), - ('excludes=', 'e', 'patterns to exclude'), - ] - - def initialize_options(self): - ''' initialize_options ''' - # pylint: disable=attribute-defined-outside-init - self.roles = None - self.excludes = None - - def finalize_options(self): - ''' finalize_options ''' - # pylint: disable=attribute-defined-outside-init - if isinstance(self.roles, string_types): - self.roles = self.roles.split(',') - - if isinstance(self.excludes, string_types): - self.excludes = self.excludes.split(',') - - def run(self): - ''' run command ''' - if self.roles is not None: - print("Roles:\n{0}".format(yaml.dump(self.roles, default_flow_style=False))) - if self.excludes is not None: - print("Excludes:\n{0}".format(yaml.dump(self.excludes, default_flow_style=False))) - - starting_dir = '.' - if self.roles is not None: - starting_dir = 'roles' - - molecule_dirs = find_dirs(starting_dir, self.excludes, self.roles, 'molecule') - - print("Found:\n{0}".format(yaml.dump(molecule_dirs, default_flow_style=False))) - base_dir = os.getcwd() - - errors = "" - warnings = "" - - for role in molecule_dirs: - role = os.path.dirname(role) - print("Testing: {0}".format(role)) - os.chdir(role) - - try: - print(sh.molecule.test()) - except ErrorReturnCode as e: - print(e.stdout) - errors += e.stdout - - os.chdir(base_dir) - - if len(warnings) > 0: - print("Warnings:\n{0}\n".format(warnings)) - - if len(errors) > 0: - print("Errors:\n{0}\n".format(errors)) - sys.exit(1) - - setup( name='openshift-ansible', license="Apache 2.0", @@ -248,8 +402,10 @@ setup( 'build_ext': UnsupportedCommand, 'egg_info': UnsupportedCommand, 'sdist': UnsupportedCommand, + 'lint': OpenShiftAnsiblePylint, 'yamllint': OpenShiftAnsibleYamlLint, - 'molecule_tests': MoleculeTests, + 'generate_validation': OpenShiftAnsibleGenerateValidation, + 'ansible_syntax': OpenShiftAnsibleSyntaxCheck, }, packages=[], ) diff --git a/tox.ini b/tox.ini index 1158b0ac..8b5089e3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] minversion=2.3.1 envlist = - py27-ansible22-molecule - py{27,35}-ansible22-{flake8,yamllint} + py{27,35}-{flake8,pylint,unit} + py27-{yamllint,ansible_syntax,generate_validation} skipsdist=True skip_missing_interpreters=True