#!/usr/bin/python # Copyright Istio Authors. All Rights Reserved. # # 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. import collections import linecache import string import sys import os import re import requests from ruamel import yaml # # This script generates the installation option differences between the # current release and the previous release. It generates the index.md content for # the current release from the values.yaml files under the charts and subcharts # directory and compares the configuration options against the index.md from # the previous release (It gets the previous release version from the github api: # https://api.github.com/repos/istio/istio/branches). # # # Reads a documented Helm values.yaml file and produces a # MD formatted table. pip install ruamel to obtain the proper # YAML decoder. ruamel maintains ordering and comments. The # comments are needed in order to decode the commented helm # values.yaml file # ISTIO_CONFIG_DIR = "istio/install/kubernetes/helm/istio" YAML_CONFIG_DIR = ISTIO_CONFIG_DIR + "/charts" VALUES_YAML = "values.yaml" ISTIO_IO_DIR = os.path.abspath(__file__ + "/../../") CONFIG_INDEX_DIR = "content/en/docs/reference/config/installation-options/index.md" CONFIG_INDEX_DIFF_DIR = "content/en/docs/reference/config/installation-options-changes/index.md" CONFIG_IGNORE_LIST = ["global.hub"] def endOfTheList(context, lineNum, lastLineNum, totalNum): flag = 0 valueList = [] nextLineNum = lineNum + 1 currentLine = context[lastLineNum] whitespaces = (len(currentLine) - len(currentLine.lstrip())) / 2 if lineNum != lastLineNum: return False, valueList for nextLineNum in range(lineNum + 1, totalNum): nextLine = context[nextLineNum] if len(nextLine.lstrip()) != 0 and '#' != nextLine.lstrip()[0] and ':' in nextLine: if whitespaces >= (len(nextLine) - len(nextLine.lstrip())) / 2: if flag == 0: valueList.append(currentLine.split(':', 1)[1].strip()) return True, valueList else: return True, valueList elif len(nextLine.lstrip()) != 0 and '#' != nextLine.lstrip()[0] and ':' not in nextLine and len(nextLine.strip()) != 0: value = nextLine.replace(' ', '') valueList.append(value.lstrip('-').strip()) flag += 1 nextLineNum += 1 if lastLineNum == totalNum - 1 and len(currentLine.lstrip()) != 0 and '#' != currentLine.lstrip()[0]: valueList.append(currentLine.split(':', 1)[1].strip()) return True, valueList # ordered dictionary to store the configuration options for the subcomponents of Istio. This # will be used to populate a new index.md prdict = collections.defaultdict(list) # ordered dictionary to store the differences of configuration options between the new # index.md and the previous version (i.e, configurations options already listed in the index.md). od_diff = collections.defaultdict(list) od_diff_new = collections.defaultdict(list) od_diff_removed = collections.defaultdict(list) od_diff_unchanged = collections.defaultdict(list) def decode_helm_yaml(s): ret_val = '' # # Iterate through all the directories under /istio/install/kubernetes/heml/subcharts # and process the configuration options from the respective values.yaml. The # configuration option name is the name of the directory that contains values.yaml. # This name will be passed in to the the function process_helm_yaml # subchart_dir = os.path.join(ISTIO_IO_DIR, YAML_CONFIG_DIR) for cfile in os.listdir(subchart_dir): values_yaml_dir = os.path.join(subchart_dir, cfile) values_yaml_file = os.path.join(values_yaml_dir, VALUES_YAML) process_helm_yaml(values_yaml_file, cfile) # # Process configuration options in values.yaml under istio/install/kubernetes/helm/istio. # The configuration option names are present in the values.yaml, hence we do not need to # pass it to process_helm_yaml. # istio_yaml_config_dir = os.path.join(ISTIO_IO_DIR, ISTIO_CONFIG_DIR) values_yaml_file = os.path.join(istio_yaml_config_dir, VALUES_YAML) process_helm_yaml(values_yaml_file, '') return ret_val def process_helm_yaml(values_yaml, option): ret_val = '' storekey = '' desc = '' newkey = '' whitespaces = 0 flag = 0 lineNum = 0 newConfigList = [] loaded = None context = linecache.getlines(values_yaml) totalNum = len(context) lastLineNum = 0 key = option count = 0 with open(values_yaml, 'r') as f_v: d_v = f_v.read() loaded = yaml.round_trip_load(d_v) for lineNum in range(0, totalNum): if context[lineNum].strip().startswith('- '): pass elif '#' in context[lineNum] and '#' == context[lineNum].lstrip()[0]: if "Description: " in context[lineNum]: desc = context[lineNum].strip() elif ':' in context[lineNum] and '#' != context[lineNum].lstrip()[0]: lastLineNum = lineNum if flag == 1: whitespaces = (len(context[lineNum]) - len(context[lineNum].lstrip())) / 2 periods = key.count('.') if (option == ''): while (whitespaces <= periods): key = key.rstrip(string.ascii_letters[::-1] + string.digits + '_' + '-' + '/').rstrip('.') whitespaces += 1 else: while (whitespaces < periods): key = key.rstrip(string.ascii_letters[::-1] + string.digits + '_' + '-' + '/').rstrip('.') whitespaces += 1 flag = 0 key = key + '.' + context[lineNum].split(':', 1)[0].strip() isEnd, ValueList = endOfTheList(context, lineNum, lastLineNum, totalNum) if isEnd: flag = 1 storekey = key sk = storekey.split('.', 2) if len(sk) > 1: storekey = '.'.join(sk[:1]).lstrip('.') else: storekey = '.'.join(sk[:0]).lstrip('.') # # If we are processing the configurations options within the values.yaml under istio, # if the options have already been processed (from the subcharts directory), then we # do not want to process it again. If the configuration option has not been processed # before, then it is a new configuration option which needs to be processed (for e.g, # global, istiocoredns) # # option == '' - This condition means that we are looking at the values.yaml under the # istio directory. Hence, the configuration option names will be inside # the values.yaml file. (On the other hand, for the values.yaml file under # the subcharts directory, we get the name of the configuration option # from the name of the directories under the subcharts directory.) # newConfigList - This list is used to track configuration options in istio/values.yaml # that haven't been processed before (or that does not have a corresponding # directory under subcharts directory with values.yaml. E.g: global, # istiocoredns) # # This first condition checks that if this is the values.yaml file under istio directory, # and the configuration option to process (storekey) has not already been processed (this # conditions: "prdict.get(storekey) != None and (storekey in newConfigList)" together # makes sure that the condition where some parameters for a new configuration option like # 'global' has been processed and entered into the dictionary 'prdict' is still processed # because it is in the newConfigList. If a configuration option was processed from # the values.yaml under the subcharts directory, it will not be in the newConfigList. # subcharts directory), then go ahead and process the parameters for this option. # if option == '' and prdict.get(storekey) is not None and (storekey in newConfigList): pass # # This second condition checks if this is the values.yaml file under istio directory, and # the configuration option to process (storekey) has not been processed (this could # happen the first time we read a configuration option from the istio/values.yaml file), # then add this configuration option to the newConfigList to mark it as an option that # needs to be processed. # elif option == '' and prdict.get(storekey) is None: newConfigList.append(storekey) # # This third condition checks if this is the values.yaml file under istio directory, # and the configuration option to process (storekey) has already been processed and if # this is not a new configuration option, (this could happen if we have already # processed the corresponding values.yaml under the subcharts directory), then ignore # this configuration option and do not process the values in this file. # elif option == '' and prdict.get(storekey) is not None: continue if len(context[lastLineNum].lstrip()) != 0 and '#' != context[lastLineNum].lstrip()[0]: isEnd, ValueList = endOfTheList(context, lineNum, lastLineNum, totalNum) if (isEnd): flag = 1 keysplit = key.split('.') for kv in keysplit: if kv != '': newkey = newkey + '.' + kv newkey = newkey.lstrip('.') # Filling Description Fields if ("." in newkey): plist = newkey.split('.') da = None for item in plist: desc = '' # If this is the same as the configuration option name, then # continue to the next key in the list if item.rstrip() == option.rstrip(): continue if da is None: if loaded.ca.items: if item in loaded.ca.items: desc = processComments(loaded.ca.items[item]) da = loaded[item] elif isinstance(da, dict): if item in da.keys()[0]: commentTokens = da.ca.comment if commentTokens is not None: desc = processComments(commentTokens) if da.ca.items: if item in da.ca.items: desc = desc + processComments(da.ca.items[item]) da = da[item] else: if item in da.keys(): da = da.get(item) else: da = da.values()[0] ValueStr = (' ').join(ValueList) if ValueStr: if (desc in ValueStr): ValueStr = ValueStr.replace("#" + desc, "") desc = desc.replace('`', '') desc = sanitizeValueStr(desc) if desc.strip(): desc = '`' + desc.strip() + '`' prdict[storekey].append("| `%s` | `%s` | %s |" % (newkey, ValueStr.rstrip(), desc)) desc = '' key = newkey newkey = '' lineNum += 1 return ret_val def processComments(comments): description = '' for c in comments: if c is None: pass elif isinstance(c, list): for comment in c: if (comment is None): pass else: # We want to avoid including commented out key: value pairs in the values.yaml as # part of the description/comments. For example: # # minAvailable: 1 # # maxUnavailable: 1 # # - secretName: grafana-tls # sessionAffinityEnabled: false # We do not want the commented out key-value pairs (minAvailable,maxUnavailable, secretName) # to be included as part of the description for 'sessionAffinityEnabled' # pattern = re.compile(r"#\s[-\s]*[\S]+:(?:\s(?!\S+:)\S+)*") groups = pattern.match(comment.value) if groups: description = '' break if comment.value.endswith('\n\n'): description = '' else: if comment.value.rstrip() == '#': continue else: description = description + comment.value.replace('`', '').replace("#", '').rstrip() elif isinstance(c, yaml.Token): description = description + c.value.rstrip().replace("#", '') return description def sanitizeValueStr(value): # We can include more special characters later if they need to # be escaped. For now just including the 'pipe' symbol appearing # in the value of a configuration option. # e.g: | `global.tracer.lightstep.secure` | `true # example: true\|false` | | # # Without escaping the 'pipe' character, it was interpreting it as the end/start # of table column. Using the example above, without escaping the pipe symbol, it # was interpreting it as: # | `global.tracer.lightstep.secure` | `true # example: true |false` | | # regex = re.compile(r"\|") if value is not None and regex.search(value) is not None: value = value.replace("|", r"\|") return value # Compares the configuration option value from the newly discovered set of values (stored # in prdict dictionary) and its previous version (stored in index.md). If there is no # change in the configuration option value between the 2 versions, it will be ignored. If # there are any differences, we will store the differences (will track differences for key, # value and description of a configuration option) in the 'od_diff' dictionary. The values # stored in this dictionary will later be written to CONFIG_INDEX_DIFF_DIR. # # The difference between the configuration option values is stored in the CONFIG_INDEX_DIFF_DIR # in the format: # | KEY | OLD DEFAULT VALUE | NEW DEFAULT VALUE | OLD DESCRIPTION | NEW DESCRIPTION | # | ------ | ------------ | ------------ | ------------ | ------------ | # | Key | oldValue | newValue | oldDesc | newDesc | # # If a configuration option is present only in the latest version, then the oldKey, oldValue # and oldDescription will be represented as 'n/a' (vice-versa applies to newKey, newValue and # newDescription). # # oValue - configuration option from the existing index.md # nValue - configuration option from the current processing of configuration options to be # stored in a new version of index.md # k - istio component name for which these configuration options are being processed. This is # used to populate the contents of 'od_diff' dictionary. # def compareValues(oValue, nValue, k): # oValue and nVAlue contains configuration option in the format: # '| `` | `` | `` | # This needs to be split in order to get the Key, Value and Description values to compare. oldKey = '' oldValue = '' oldDesc = '' newKey = '' newValue = '' newDesc = '' key = None if nValue is not None: groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \| (.*) |", nValue.strip()) if groups: newKey = groups.group(1) newValue = groups.group(2) newDesc = groups.group(3) if oValue is not None and nValue is not None: if len(oValue) == 1: item = oValue[0] if item == nValue: key = newKey oValue.remove(item) od_diff_unchanged[k].append("| `%s` | `%s` | %s |" % (newKey, newValue.rstrip(), newDesc)) else: groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \|\s*(.*)\s*\|", item.strip()) if groups: oldKey = groups.group(1) oldValue = groups.group(2) oldDesc = groups.group(3) key = oldKey if oldKey in CONFIG_IGNORE_LIST: oValue.remove(item) return key if oldValue != newValue: if oldValue is None: oldValue = 'n/a' if newValue is None: newValue = 'n/a' if oldDesc.strip() != newDesc.strip(): if (newDesc is None or newDesc == '') and (oldDesc is None or oldDesc == ''): pass if oldDesc is None: oldDesc = 'n/a' if newDesc is None or newDesc == '': newDesc = 'n/a' oValue.remove(item) od_diff[k].append("| `%s` | `%s` | `%s` | %s | %s |" % (newKey, oldValue.rstrip(), newValue.rstrip(), oldDesc, newDesc)) else: # This is the case where values are the same but descriptions are different. Right now, there is nothing more to do since # we do not care about displaying values that haven't changed between releases. oValue.remove(item) #od_diff_unchanged[k].append("| `%s` | `%s` | %s |" % (newKey, newValue.rstrip(), newDesc)) else: foundItem = 'false' for item in oValue: if item == nValue: key = newKey oValue.remove(item) od_diff_unchanged[k].append("| `%s` | `%s` | %s |" % (newKey, newValue.rstrip(), newDesc)) foundItem = 'true' break else: groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \|\s*(.*)\s*\|", item.strip()) if groups: oldKey = groups.group(1) oldValue = groups.group(2) oldDesc = groups.group(3) if oldKey == newKey: if oldValue == newValue and oldDesc != newDesc: key = newKey od_diff[k].append("| `%s` | `%s` | `%s` | %s | %s |" % (newKey, oldValue.rstrip(), newValue.rstrip(), oldDesc, newDesc)) oValue.remove(item) foundItem = 'true' break if foundItem == 'false': od_diff_new[k].append("| `%s` | `%s` | %s |" % (newKey, newValue.rstrip(), newDesc)) elif oValue is None: key = newKey od_diff_new[k].append("| `%s` | `%s` | %s |" % (newKey, newValue.rstrip(), newDesc)) elif nValue is None: for item in oValue: groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \|\s*(.*)\s*\|", item.strip()) if groups: oldKey = groups.group(1) oldValue = groups.group(2) oldDesc = groups.group(3) key = oldKey od_diff_removed[k].append("| `%s` | `%s` | %s |" % (oldKey, oldValue.rstrip(), oldDesc)) return key # # Get the previous release number so that we can retrieve the index.md for that # release. The release branches are tagged in the following format: release- # def getPreviousRelease(): req = requests.get("https://api.github.com/repos/istio/istio/branches") jsonData = req.json() previousRelease = 0.0 for x in jsonData: releaseName = x['name'] if releaseName.startswith('release-'): releaseNum = releaseName.split('release-') if releaseNum[1] > previousRelease: previousRelease = releaseNum[1] return previousRelease # # Get the index.md for the previous release. # def getContentFromPreviousRelease(releaseName): istio_url = 'https://raw.githubusercontent.com/istio/istio.io/release-' + releaseName + '/content/en/docs/reference/config/installation-options/index.md' req = requests.get(istio_url) content = req.text indexMap = collections.defaultdict(list) # store all the configurations options from the index.md file into the indexMap # dictionary. This will be used to compare the values with the latest version # of configuration options. data = content.split('\n') for d in data: if d.rstrip() != '' and d != '| Key | Default Value | Description |' and d != '| --- | --- | --- |' and d[0:1] == '|' and d[-1] == '|': groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \| (.*) |", d.strip()) if groups: key = groups.group(1) if key in indexMap: value = indexMap.get(key) value.append(d.strip()) else: indexMap[key].append(d.strip()) return indexMap def writeVersionDiffs(index_diff_file): meta = "" for d in index_diff_file: meta = meta + d if "" in d: break index_diff_file.seek(0) index_diff_file.write(meta) ''' if od_diff_unchanged: index_diff_file.write('\n## Unmodified configuration options\n') for k, v in od_diff_unchanged.items(): index_diff_file.write("\n### Unmodified `%s` key/value pairs\n\n" % k) index_diff_file.write('| Key | Default Value | Description |\n') index_diff_file.write('| --- | --- | --- |\n') for value in v: index_diff_file.write('%s\n' % (value)) ''' if od_diff: index_diff_file.write('\n## Modified configuration options\n') for k, v in od_diff.items(): index_diff_file.write("\n### Modified `%s` key/value pairs\n\n" % k) index_diff_file.write('| Key | Old Default Value | New Default Value | Old Description | New Description |\n') index_diff_file.write('| --- | --- | --- | --- | --- |\n') for value in v: index_diff_file.write('%s\n' % (value)) if od_diff_new: index_diff_file.write('\n## New configuration options\n') for k, v in od_diff_new.items(): index_diff_file.write("\n### New `%s` key/value pairs\n\n" % k) index_diff_file.write('| Key | Default Value | Description |\n') index_diff_file.write('| --- | --- | --- |\n') for value in v: index_diff_file.write('%s\n' % (value)) if od_diff_removed: index_diff_file.write('\n## Removed configuration options\n') for k, v in od_diff_removed.items(): index_diff_file.write("\n### Removed `%s` key/value pairs\n\n" % k) index_diff_file.write('| Key | Default Value | Description |\n') index_diff_file.write('| --- | --- | --- |\n') for value in v: index_diff_file.write('%s\n' % (value)) index_diff_file.write("\n\n") index_diff_file.truncate() with open(os.path.join(ISTIO_IO_DIR, CONFIG_INDEX_DIR), 'r') as f: endReached = False key = '' # A list used to track the configuration options that has been compared and processed when going # through the configurations processed in the latest version indexList = [] previousRelease = getPreviousRelease() indexMap = getContentFromPreviousRelease(previousRelease) # transform values.yaml into a encoded string dictionary pyaml = yaml.YAML() pyaml.explicit_start = True pyaml.dump('', sys.stdout, transform=decode_helm_yaml) # Order the encoded string dictionary od = collections.OrderedDict(sorted(prdict.items(), key=lambda t: t[0])) # Print encoded string dictionary for k, v in od.items(): for value in v: # Compare configuration option values from the latest version # with the older version. groups = re.search(r"\| \`(.*)\` \| \`(.*)\` \| (.*) |", value.strip()) if groups: key = groups.group(1) indexValue = indexMap.get(key) indexList.append(compareValues(indexValue, value, k)) # We want to include any configuration options that was discovered in # the older version but not available in the current version for k in indexMap.keys(): key = k.split('.')[0] indexList.append(compareValues(indexMap.get(k), None, key)) # This index.md file is used to track the differences of configuration # option values between the current and previous release. All the # differences in configuration option values between the current # and previous release (tracked in the 'od_diff' dictionary) will be # written to the index.md file index_diff_file = open(os.path.join(ISTIO_IO_DIR, CONFIG_INDEX_DIFF_DIR), 'r+') writeVersionDiffs(index_diff_file) index_diff_file.close()