#!/usr/bin/env python3 # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. # Usage: normalize_acl.py NAMESPACE acl.config [transform [transform [...]]] # # The NAMESPACE specifies the OpenInfra project, e.g., 'openstack', and # conventionally corresponds to the directory name containing that project's # acl files. # # Transforms are described in user-facing detail below # # Transformations: # all Report all transformations as a dry run. # apply Apply all transformations to the file directly. # 0 - dry run (default, print to stdout rather than modifying file in place) # 1 - strip/condense whitespace and sort (implied by any other transformation) # 2 - get rid of unneeded create on refs/tags # 3 - remove any project.stat{e,us} = active since it's a default or a typo # 4 - strip default *.owner = group Administrators permissions # 5 - sort the exclusiveGroupPermissions group lists # 6 - replace openstack-ci-admins and openstack-ci-core with infra-core # 7 - add at least one core team, if no team is defined with special suffixes # like core, admins, milestone or Users # 8 - fix All-Projects inheritance shadowed by exclusiveGroupPermissions # 9 - Ensure submit requirements # * functions only noblock # * each label has a s-r block # 10- Values should be indented with a hard tab, as that is the gerrit default # import re import sys # If adding a normalization step, add human-parsable description of it # here. NORMALIZATION_HELP = ''' One or more files have failed the Gerrit ACL normalization checks. A diff of the expected output is presented above. You can reference this with the following transformations to correct any problems. The current transformations 1. Whitespace should be stripped/condensed and keys should be alphabetically sorted. 2. [access "refs/tags/*"] should not have create permissions 3. No "project.stat{e,us} = active" since it's a default or a typo 4. Remove default "*.owner = group" Administrators permissions 5. The exclusiveGroupPermissions group lists should be sorted 6. Old references to openstack-ci-admins and openstack-ci-core should now be infra-core 7. There should be at least one core team, if no team is defined with special suffixes like core, admins, milestone or Users 8. Whenever a project-specific ACL declares exclusiveGroupPermissions on some permission, it overrides standard permissions that would otherwise be inherited from All-Projects ACL. These conditions must be duplicated into the project-specific rule to maintain standard behaviour. 9. Labels must have submit-requirements - must have "function = NoBlock" - each label must have a corresponding submit-requirement block 10. Values should be indented with a hard tab, as that is the gerrit default; e.g. [section] key1 = value key2 = value ''' # noqa: W191, E101 LAST_TRANSFORMATION = 10 USAGE_STRING = ("Usage:\n normalize_acl.py NAMESPACE acl.config [transform " "[transform [...]]]\n or 'normalize_acl.py -help' for info " "on the available transforms") try: namespace = sys.argv[1] except IndexError: print('error: missing NAMESPACE or -help') print(USAGE_STRING) sys.exit(1) # NOTE(ianw) : 2023-04-20 obviously we would not write any of this # like this if we were starting fresh. But this has grown from a # simple thing into something difficult for people to deal with. If # we have any errors during the tox job, we use this to print out a # help message. if (namespace == '-help'): print(NORMALIZATION_HELP) sys.exit(1) try: aclfile = sys.argv[2] except IndexError: print('error: missing acl filespec') print(USAGE_STRING) sys.exit(1) transformations = sys.argv[3:] if transformations: RANGE_END = LAST_TRANSFORMATION + 1 if transformations[0] == 'all': transformations = [str(x) for x in range(0, RANGE_END)] elif transformations[0] == 'apply': transformations = [str(x) for x in range(1, RANGE_END)] def tokens(data): """Human-order comparison This handles embedded positive and negative integers, for sorting strings in a more human-friendly order.""" data = data.replace('.', ' ').split() for n in range(len(data)): try: data[n] = int(data[n]) except ValueError: pass return data def normalize_boolean_ops(key, value): # Gerrit 3.6 takes lower-case "and/or" literally -- as in # you literally need to have and/or in the commit string. # Gerrit 3.7 fixes this, but let's standarise on capital # booleans if key in ('copyCondition', 'submittableIf', 'applicableIf'): value = value.replace(' and ', ' AND ') value = value.replace(' or ', ' OR ') return "%s = %s" % (key, value) acl = {} out = '' valid_keys = { 'abandon', 'access', 'applicableIf', 'create', 'createSignedTag', 'copyCondition', 'defaultValue', 'delete', 'description', 'editHashtags', 'exclusiveGroupPermissions', 'forgeAuthor', 'forgeCommitter', 'function', 'inheritFrom', 'label-Allow-Post-Review', 'label-Backport-Candidate', 'label-Code-Review', 'label-PTL-Approved', 'label-Review-Priority', 'label-Rollcall-Vote', 'label-Workflow', 'label-Verified', 'mergeContent', 'push', 'pushMerge', 'requireChangeId', 'requireContributorAgreement', 'state', 'submit', 'submittableIf', 'toggleWipState', 'value', } # push and label-* are handled specially and should not be in this list group_keys = { 'abandon', 'create', 'createSignedTag', 'delete', 'editHashtags', 'forgeCommitter', 'pushMerge', 'submit', 'toggleWipState', } if '0' in transformations or not transformations: dry_run = True else: dry_run = False aclfd = open(aclfile) for line in aclfd: # condense whitespace to single spaces and get rid of leading/trailing line = re.sub(r'\s+', ' ', line).strip() # skip empty lines if not line: continue # this is a section heading if line.startswith('['): section = line.strip(' []') # use a list for this because some options can have the same "key" acl[section] = [] # key=value lines elif '=' in line: acl[section].append(line) # Check for valid keys key, value = [x.strip() for x in line.split('=', 1)] if key not in valid_keys: raise Exception( '(%s) Unrecognized key "%s" in line: "%s"' % (aclfile, key, line)) # group keywords, special handling for label-* votes and push +force values = [x.strip() for x in value.split(' ')] if ((key in group_keys and len(values) < 2) or (key.startswith("label-") and len(values) < 3)): raise Exception( '(%s) Not enough parameters in line: "%s"' % (aclfile, line)) if ((key in group_keys and values[0] != "group") or (key.startswith("label-") and values[1] != "group") or (key == "push" and "group" not in values)): raise Exception( '(%s) Missing "group" keyword in line: "%s"' % (aclfile, line)) # WTF else: raise Exception('Unrecognized line: "%s"' % line) aclfd.close() if '2' in transformations: for key in acl: if key.startswith('access "refs/tags/'): acl[key] = [ x for x in acl[key] if not x.startswith('create = ')] if '3' in transformations: try: acl['project'] = [x for x in acl['project'] if x not in ('state = active', 'status = active')] except KeyError: pass if '4' in transformations: for section in acl.keys(): acl[section] = [x for x in acl[section] if x != 'owner = group Administrators'] if '5' in transformations: for section in acl.keys(): newsection = [] for option in acl[section]: key, value = [x.strip() for x in option.split('=', 1)] if key == 'exclusiveGroupPermissions': newsection.append('%s = %s' % ( key, ' '.join(sorted(value.split())))) else: newsection.append(option) acl[section] = newsection if '6' in transformations: for section in acl.keys(): newsection = [] for option in acl[section]: for group in ('openstack-ci-admins', 'openstack-ci-core'): option = option.replace('group %s' % group, 'group infra-core') newsection.append(option) acl[section] = newsection if '7' in transformations: special_projects = ( 'ossa', 'reviewday', ) special_teams = ( 'admins', 'Bootstrappers', 'committee', 'core', 'maint', 'Managers', 'milestone', 'packagers', 'release', 'Users', ) for section in acl.keys(): newsection = [] for option in acl[section]: if ('refs/heads' in section and 'group' in option and '-2..+2' in option and not any(x in option for x in special_teams) and not any(x in aclfile for x in special_projects)): option = '%s%s' % (option, '-core') newsection.append(option) acl[section] = newsection if '8' in transformations: for section in acl.keys(): newsection = [] for option in acl[section]: newsection.append(option) key, value = [x.strip() for x in option.split('=', 1)] if key == 'exclusiveGroupPermissions': exclusives = value.split() # It's safe for these to be duplicates since we de-dup later if 'abandon' in exclusives: newsection.append('abandon = group Change Owner') newsection.append('abandon = group Project Bootstrappers') if (namespace == 'openstack' and 'refs/heads/unmaintained' in section): newsection.append('abandon = group Release Managers') if 'label-Code-Review' in exclusives: newsection.append('label-Code-Review = -2..+2 ' 'group Project Bootstrappers') newsection.append('label-Code-Review = -1..+1 ' 'group Registered Users') if 'label-Workflow' in exclusives: newsection.append('label-Workflow = -1..+1 ' 'group Project Bootstrappers') newsection.append('label-Workflow = -1..+0 ' 'group Change Owner') acl[section] = newsection # submit-requirements have taken over the role of "function" in labels # since Gerrit 3.6. We ensure that the only function in a label # section now is the noop "NoBlock" function -- all labels now need to # explicitly write their own submit-requirement. e.g. for any # [label "Foo"] # there should be a matching submit requirement section # [submit-requirement "Foo"] # We can't really decide what the rules will be, so we just add the # section with a dummy comment. if '9' in transformations: missing_sr = {} for section in acl.keys(): newsection = [] if section.startswith("label "): label_name = section.split(' ')[1] sr_found = False for sr in acl.keys(): if sr == 'submit-requirement %s' % (label_name): sr_found = True break if not sr_found: msg = ('# You must have a submit-requirement section for %s' % label_name) missing_sr['submit-requirement %s' % label_name] = [msg] keys = [] for option in acl[section]: key, value = [x.strip() for x in option.split('=', 1)] keys.append(key) # Insert an inline comment if the ACL uses an invalid function if key == 'function': if value != 'NoBlock': newsection.append( '# XXX: The only supported function type is ' 'NoBlock') newsection.append(normalize_boolean_ops(key, value)) # Add function = NoBlock to label sections if not set as the # default is MaxWithBlock which will interfere with submit # requirements. if 'function' not in keys: newsection.append('function = NoBlock') else: for option in acl[section]: key, value = [x.strip() for x in option.split('=', 1)] newsection.append(normalize_boolean_ops(key, value)) acl[section] = newsection acl.update(missing_sr) for section in sorted(acl.keys()): if acl[section]: out += '\n[%s]\n' % section lastoption = '' for option in sorted(acl[section], key=tokens): if option != lastoption: if '10' in transformations: # Gerrit prefers all option lines indented by a single # hard tab; this minimises diffs if things like # upgrades need to modify the acls out += '\t' out += '%s\n' % option lastoption = option if dry_run: print(out[1:-1]) else: aclfd = open(aclfile, 'w') aclfd.write(out[1:]) aclfd.close()