235 lines
8 KiB
Python
235 lines
8 KiB
Python
|
#!/usr/bin/env python
|
||
|
# Copyright (c) Ansible Project
|
||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||
|
"""Check BOTMETA file."""
|
||
|
from __future__ import (absolute_import, division, print_function)
|
||
|
__metaclass__ = type
|
||
|
|
||
|
import ast
|
||
|
import os
|
||
|
import re
|
||
|
import sys
|
||
|
|
||
|
import yaml
|
||
|
|
||
|
from voluptuous import All, Any, MultipleInvalid, PREVENT_EXTRA
|
||
|
from voluptuous import Required, Schema, Invalid
|
||
|
from voluptuous.humanize import humanize_error
|
||
|
|
||
|
|
||
|
IGNORE_NO_MAINTAINERS = [
|
||
|
'plugins/cache/memcached.py',
|
||
|
'plugins/cache/redis.py',
|
||
|
'plugins/callback/cgroup_memory_recap.py',
|
||
|
'plugins/callback/context_demo.py',
|
||
|
'plugins/callback/counter_enabled.py',
|
||
|
'plugins/callback/hipchat.py',
|
||
|
'plugins/callback/jabber.py',
|
||
|
'plugins/callback/log_plays.py',
|
||
|
'plugins/callback/logdna.py',
|
||
|
'plugins/callback/logentries.py',
|
||
|
'plugins/callback/null.py',
|
||
|
'plugins/callback/selective.py',
|
||
|
'plugins/callback/slack.py',
|
||
|
'plugins/callback/splunk.py',
|
||
|
'plugins/callback/yaml.py',
|
||
|
'plugins/inventory/nmap.py',
|
||
|
'plugins/inventory/virtualbox.py',
|
||
|
'plugins/connection/chroot.py',
|
||
|
'plugins/connection/iocage.py',
|
||
|
'plugins/connection/lxc.py',
|
||
|
'plugins/lookup/cartesian.py',
|
||
|
'plugins/lookup/chef_databag.py',
|
||
|
'plugins/lookup/consul_kv.py',
|
||
|
'plugins/lookup/credstash.py',
|
||
|
'plugins/lookup/cyberarkpassword.py',
|
||
|
'plugins/lookup/flattened.py',
|
||
|
'plugins/lookup/keyring.py',
|
||
|
'plugins/lookup/lastpass.py',
|
||
|
'plugins/lookup/passwordstore.py',
|
||
|
'plugins/lookup/shelvefile.py',
|
||
|
'plugins/filter/json_query.py',
|
||
|
'plugins/filter/random_mac.py',
|
||
|
]
|
||
|
|
||
|
FILENAME = '.github/BOTMETA.yml'
|
||
|
|
||
|
LIST_ENTRIES = frozenset(('supershipit', 'maintainers', 'labels', 'keywords', 'notify', 'ignore'))
|
||
|
|
||
|
AUTHOR_REGEX = re.compile(r'^\w.*\(@([\w-]+)\)(?![\w.])')
|
||
|
|
||
|
|
||
|
def read_authors(filename):
|
||
|
data = {}
|
||
|
try:
|
||
|
with open(filename, 'rb') as b_module_data:
|
||
|
M = ast.parse(b_module_data.read())
|
||
|
|
||
|
for child in M.body:
|
||
|
if isinstance(child, ast.Assign):
|
||
|
for t in child.targets:
|
||
|
try:
|
||
|
theid = t.id
|
||
|
except AttributeError:
|
||
|
# skip errors can happen when trying to use the normal code
|
||
|
continue
|
||
|
|
||
|
if theid == 'DOCUMENTATION':
|
||
|
if isinstance(child.value, ast.Dict):
|
||
|
data = ast.literal_eval(child.value)
|
||
|
else:
|
||
|
data = yaml.safe_load(child.value.s)
|
||
|
|
||
|
except Exception as e:
|
||
|
print('%s:%d:%d: Cannot load DOCUMENTATION: %s' % (filename, 0, 0, e))
|
||
|
return []
|
||
|
|
||
|
author = data.get('author') or []
|
||
|
if isinstance(author, str):
|
||
|
author = [author]
|
||
|
return author
|
||
|
|
||
|
|
||
|
def extract_author_name(author):
|
||
|
m = AUTHOR_REGEX.match(author)
|
||
|
if m:
|
||
|
return m.group(1)
|
||
|
if author == 'Ansible Core Team':
|
||
|
return '$team_ansible_core'
|
||
|
return None
|
||
|
|
||
|
|
||
|
def validate(filename, filedata):
|
||
|
if not filename.startswith('plugins/'):
|
||
|
return
|
||
|
if filename.startswith(('plugins/doc_fragments/', 'plugins/module_utils/')):
|
||
|
return
|
||
|
# Compile lis tof all active and inactive maintainers
|
||
|
all_maintainers = filedata['maintainers'] + filedata['ignore']
|
||
|
if not filename.startswith('plugins/filter/'):
|
||
|
maintainers = read_authors(filename)
|
||
|
for maintainer in maintainers:
|
||
|
maintainer = extract_author_name(maintainer)
|
||
|
if maintainer is not None and maintainer not in all_maintainers:
|
||
|
msg = 'Author %s not mentioned as active or inactive maintainer for %s (mentioned are: %s)' % (
|
||
|
maintainer, filename, ', '.join(all_maintainers))
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, msg))
|
||
|
should_have_no_maintainer = filename in IGNORE_NO_MAINTAINERS
|
||
|
if not all_maintainers and not should_have_no_maintainer:
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, 'No (active or inactive) maintainer mentioned for %s' % filename))
|
||
|
if all_maintainers and should_have_no_maintainer:
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, 'Please remove %s from the ignore list of %s' % (filename, sys.argv[0])))
|
||
|
|
||
|
|
||
|
def main():
|
||
|
"""Main entry point."""
|
||
|
try:
|
||
|
with open(FILENAME, 'rb') as f:
|
||
|
botmeta = yaml.safe_load(f)
|
||
|
except yaml.error.MarkedYAMLError as ex:
|
||
|
print('%s:%d:%d: YAML load failed: %s' % (FILENAME, ex.context_mark.line +
|
||
|
1, ex.context_mark.column + 1, re.sub(r'\s+', ' ', str(ex))))
|
||
|
return
|
||
|
except Exception as ex: # pylint: disable=broad-except
|
||
|
print('%s:%d:%d: YAML load failed: %s' %
|
||
|
(FILENAME, 0, 0, re.sub(r'\s+', ' ', str(ex))))
|
||
|
return
|
||
|
|
||
|
# Validate schema
|
||
|
|
||
|
MacroSchema = Schema({
|
||
|
(str): Any(str, None),
|
||
|
}, extra=PREVENT_EXTRA)
|
||
|
|
||
|
FilesSchema = Schema({
|
||
|
(str): {
|
||
|
('supershipit'): str,
|
||
|
('support'): Any('community'),
|
||
|
('maintainers'): str,
|
||
|
('labels'): str,
|
||
|
('keywords'): str,
|
||
|
('notify'): str,
|
||
|
('ignore'): str,
|
||
|
},
|
||
|
}, extra=PREVENT_EXTRA)
|
||
|
|
||
|
schema = Schema({
|
||
|
('notifications'): bool,
|
||
|
('automerge'): bool,
|
||
|
('macros'): MacroSchema,
|
||
|
('files'): FilesSchema,
|
||
|
}, extra=PREVENT_EXTRA)
|
||
|
|
||
|
try:
|
||
|
schema(botmeta)
|
||
|
except MultipleInvalid as ex:
|
||
|
for error in ex.errors:
|
||
|
# No way to get line/column numbers
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, humanize_error(botmeta, error)))
|
||
|
return
|
||
|
|
||
|
# Preprocess (substitute macros, convert to lists)
|
||
|
macros = botmeta.get('macros') or {}
|
||
|
macro_re = re.compile(r'\$([a-zA-Z_]+)')
|
||
|
|
||
|
def convert_macros(text, macros):
|
||
|
def f(m):
|
||
|
macro = m.group(1)
|
||
|
replacement = (macros[macro] or '')
|
||
|
if macro == 'team_ansible_core':
|
||
|
return '$team_ansible_core %s' % replacement
|
||
|
return replacement
|
||
|
|
||
|
return macro_re.sub(f, text)
|
||
|
|
||
|
files = {}
|
||
|
try:
|
||
|
for file, filedata in (botmeta.get('files') or {}).items():
|
||
|
file = convert_macros(file, macros)
|
||
|
filedata = dict((k, convert_macros(v, macros)) for k, v in filedata.items())
|
||
|
files[file] = filedata
|
||
|
for k, v in filedata.items():
|
||
|
if k in LIST_ENTRIES:
|
||
|
filedata[k] = v.split()
|
||
|
except KeyError as e:
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, 'Found unknown macro %s' % e))
|
||
|
return
|
||
|
|
||
|
# Scan all files
|
||
|
unmatched = set(files)
|
||
|
for dirs in ('plugins', 'tests', 'changelogs'):
|
||
|
for dirpath, dirnames, filenames in os.walk(dirs):
|
||
|
for file in sorted(filenames):
|
||
|
if file.endswith('.pyc'):
|
||
|
continue
|
||
|
filename = os.path.join(dirpath, file)
|
||
|
if os.path.islink(filename):
|
||
|
continue
|
||
|
if os.path.isfile(filename):
|
||
|
matching_files = []
|
||
|
for file, filedata in files.items():
|
||
|
if filename.startswith(file):
|
||
|
matching_files.append((file, filedata))
|
||
|
if file in unmatched:
|
||
|
unmatched.remove(file)
|
||
|
if not matching_files:
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, 'Did not find any entry for %s' % filename))
|
||
|
|
||
|
matching_files.sort(key=lambda kv: kv[0])
|
||
|
filedata = dict()
|
||
|
for k in LIST_ENTRIES:
|
||
|
filedata[k] = []
|
||
|
for dummy, data in matching_files:
|
||
|
for k, v in data.items():
|
||
|
if k in LIST_ENTRIES:
|
||
|
v = filedata[k] + v
|
||
|
filedata[k] = v
|
||
|
validate(filename, filedata)
|
||
|
|
||
|
for file in unmatched:
|
||
|
print('%s:%d:%d: %s' % (FILENAME, 0, 0, 'Entry %s was not used' % file))
|
||
|
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
main()
|