
Create a gerrit group to handle branches in Unmaintained status across all projects, as described in TC resolution 2023-11-14, which is commit 90982cd in the governance repository. Also adjust the acl file normalization tool so that it will guarantee that the Release Managers group has 'abandon' permission on Unmaintained branches if any project chooses to override the global openstack-unmaintained-core group with a project-specific unmaintained core team (as is allowed by TC resolution 2023-11-14). This entails a change in that script to require the acl file's namespace be passed in so that the check doesn't affect non-OpenStack OpenInfra projects. Change-Id: Ife8e5f175cb8a7d396dfe2a5d52fd6d524ae0b43
417 lines
14 KiB
Python
Executable File
417 lines
14 KiB
Python
Executable File
#!/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)
|
|
|
|
# TODO(rosmaita): refactor this, there's nothing in the 'try'
|
|
# that will raise a KeyError, and in any case, an out-of-range slice
|
|
# reference already returns an empty list
|
|
try:
|
|
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)]
|
|
except KeyError:
|
|
transformations = []
|
|
|
|
|
|
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()
|