diff --git a/Dockerfile b/Dockerfile index 342ff93..170d32a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,15 +6,12 @@ ARG UPDATECLI_VERSION=0.54.0 RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ - git \ && rm -rf /var/lib/apt/lists/* # Install Updatecli RUN curl -sL -o /tmp/updatecli_amd64.deb https://github.com/updatecli/updatecli/releases/download/v0.54.0/updatecli_amd64.deb && \ apt install /tmp/updatecli_amd64.deb -# TODO Maybe install Helm also? - # Create the virtualenv and add it to the path ENV VIRTUAL_ENV=/opt/venv RUN python3 -m venv $VIRTUAL_ENV diff --git a/README.adoc b/README.adoc index 9669d57..edd1f2c 100644 --- a/README.adoc +++ b/README.adoc @@ -40,6 +40,4 @@ The following references were used to create this action: // TODO Add proper .gitignore // TODO Add proper changelog and release please process -// - Only works with Helm 3 - // TODO On caller workflow we need to add the conditional pull request step, input variable on workflow dispatch to dry-run, set upgrade-strategy and set exclusions diff --git a/action.yaml b/action.yaml index b1905b0..3be580f 100644 --- a/action.yaml +++ b/action.yaml @@ -5,6 +5,11 @@ inputs: chart-path: description: "Path to the Helm chart folder." required: true + readme-path: + description: "Path to a README.adoc with AsciiDoc attributes in the format ':-chart-version: + ' that will be updated in the event of an upgrade." + required: false + default: "" upgrade-strategy: description: "Upgrade strategy to use. Valid values are 'major', 'minor' or 'patch'." required: false @@ -19,14 +24,17 @@ inputs: default: "false" outputs: - chart-upgraded: + is-updated: description: "Boolean indicating whether any dependencies were upgraded." + is-major: + description: "Boolean indicating at least one of the dependencies was subject to a major upgrade." runs: using: docker image: Dockerfile args: - ${{ inputs.dry-run }} + - ${{ inputs.readme-path }} - ${{ inputs.chart-path }} - ${{ inputs.upgrade-strategy }} - ${{ inputs.excluded-dependencies }} diff --git a/entrypoint.sh b/entrypoint.sh index 422d304..5fca0c4 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,24 +1,26 @@ #!/bin/bash -# Add the "--dry-run" flag if the dry-run argument is "true" +# Add the "--dry-run" flag if the dry-run argument is "true". dry_run=$( [ $1 == "true" ] && echo "--dry-run" || echo "" ) -# Run the Python script -python3 /helm_dependency_bumper.py $dry_run --chart $2 --upgrade-strategy $3 --exclude-dependency $4 +# Add the "--update-readme" flag if the update-readme argument is not empty. +update_readme=$( [ $2 != "" ] && echo "--update-readme $2" || echo "" ) -# Exit with error code if the Python script fails +# Run the Python script. +python3 /helm_dependency_bumper.py $dry_run \ + $update_readme \ + --chart $3 \ + --upgrade-strategy $4 \ + --exclude-dependency $5 \ + --output output.txt + +# Exit with error code if the Python script fails. if [ $(echo $?) == 1 ]; then exit 1 fi -# Remove the manifest so it is not committed to the repository -rm manifest.yaml - -# Set up the workspace directory as a safe directory for Git otherwise the git command will fail -git config --global --add safe.directory /github/workspace - -# Return a boolean indicating whether the any dependency was upgraded -upgraded=$( [[ `git status --porcelain` ]] && echo true || echo false ) -echo "chart-upgraded=$upgraded" >> $GITHUB_OUTPUT +# Return the booleans indicating whether any dependency was upgraded or if there was a major upgrade. +echo $(cat output.txt) >> $GITHUB_OUTPUT +rm output.txt exit 0 diff --git a/helm_dependency_bumper.py b/helm_dependency_bumper.py index 1d120d6..6fb58b6 100644 --- a/helm_dependency_bumper.py +++ b/helm_dependency_bumper.py @@ -4,46 +4,60 @@ from pathlib import Path import argparse +import fileinput +import os import subprocess import sys -import yaml import traceback +import yaml PROGRAM_VERSION = "0.1.5" # x-release-please-version DEFAULT_UPGRADE_STRATEGY = "minor" parser = argparse.ArgumentParser( - prog="helm_dependency_bumper.py", + prog="python3 helm_dependency_bumper.py", description="Python script to upgrade the dependencies of an Helm chart.", add_help=False, ) -parser.add_argument('-d', '--dry-run', action='store_true', - help="Show which dependencies will be upgraded but do not execute the upgrade.") parser.add_argument('-h', '--help', action='help', help="Show this help message and exit.") -parser.add_argument('-s', '--upgrade-strategy', choices=['major', 'minor', 'patch'], +parser.add_argument('-v', '--version', action='version', version=f"%(prog)s {PROGRAM_VERSION}", + help="Show program's version number and exit.") +parser.add_argument('-c', '--chart', default='.', type=str, + help="Path to the Helm chart. Defaults to the current directory.") +parser.add_argument('-e', '--exclude-dependency', nargs='?', default=[], + help="List the dependencies you want to exclude from the update script. Should be a " + "comma-separated list.") +parser.add_argument('-u', '--upgrade-strategy', choices=['major', 'minor', 'patch'], default=f"{DEFAULT_UPGRADE_STRATEGY}", type=str, help=f"Choose the Helm dependency upgrade strategy. " f"\'major\' will upgrade to the absolute latest version available (i.e. *.*.*), " f"\'minor\' will upgrade to the latest minor version available (i.e. X.*.*) and " f"\'patch\' will upgrade to the latest patch version available (i.e. X.Y.*). " f"Defaults to {DEFAULT_UPGRADE_STRATEGY}.") -parser.add_argument('-v', '--version', action='version', version=f"%(prog)s {PROGRAM_VERSION}", - help="Show program's version number and exit.") -parser.add_argument('-c', '--chart', default='.', type=str, help="Path to the Helm chart. Defaults to the current " - "directory.") -parser.add_argument('-e', '--exclude-dependency', nargs='?', default=[], - help="List the dependencies you want to exclude from the update script. Should be a " - "comma-separated list.") +parser.add_argument('-r', '--update-readme', type=str, + help="Update the AsciiDoc attributes with the chart versions on a given *.adoc file. Does not " + "have any effect if running in dry-run mode.") +parser.add_argument('-o', '--output', type=str, + help="Create a file to output if there was a major upgrade or an upgrade at all. Does not have " + "any effect if running in dry-run mode.") +parser.add_argument('-s', '--save-manifest', action='store_true', + help="Save the generated manifest.yaml instead of deleting it automatically.") +parser.add_argument('-d', '--dry-run', action='store_true', + help="Run updatecli in dry-run mode.") args = parser.parse_args() -def generate_updatecli_manifest(path_chart: str, excluded_dependencies: list, upgrade_strategy: str): +def read_chart_yaml(path: str) -> dict: # Open the Chart.yaml file and transform it to a Python object the script can work with. - with open(path_chart + "/Chart.yaml") as f: + with open(path + "/Chart.yaml") as f: yaml_content = f.read() chart: dict = yaml.safe_load(yaml_content) + return chart + + +def generate_updatecli_manifest(chart: dict, path: str, excluded_dependencies: list, upgrade_strategy: str): # Create a prototype manifest object that will be converted into a YAML file in the end. manifest = { "sources": {}, @@ -51,13 +65,14 @@ def generate_updatecli_manifest(path_chart: str, excluded_dependencies: list, up "targets": {}, } - # Quit the functions if no dependencies are found in the provided + # If no dependencies are found in the provided chart generate an empty manifest.yaml and quit the function if "dependencies" not in chart: + with open("manifest.yaml", "w") as manifest_yaml_file: + yaml.dump(manifest, manifest_yaml_file) return - for i, dependency in enumerate(chart["dependencies"]): + for i, dependency in enumerate(chart['dependencies']): if dependency['name'] in excluded_dependencies: - print(f"Skipping {dependency['name']} because it is excluded..") continue parse_versions = { @@ -82,45 +97,164 @@ def generate_updatecli_manifest(path_chart: str, excluded_dependencies: list, up } manifest['targets'][f"{dependency['name']}_dependency_bump"] = { - "name": f"Upgrade dependency on {path_chart.split('/')[-1]} chart", + "name": f"Upgrade dependency on {chart['name']} chart", "kind": "helmchart", "sourceid": f"{dependency['name']}_repository_update", "spec": { - "name": path_chart, + "name": path, "file": "Chart.yaml", "key": f"$.dependencies[{i}].version", - "versionincrement": "none", + "versionincrement": "none", # Do not increase the version of the mother chart itself }, } - with open("manifest.yaml", "w") as yaml_file: - yaml.dump(manifest, yaml_file) + with open("manifest.yaml", "w") as manifest_yaml_file: + yaml.dump(manifest, manifest_yaml_file) + + +def sort_dependencies(old_chart_dict: dict, new_chart_dict: dict): + # Sort dependencies on both objects to be sure that we are comparing the versions of the same dependencies. + # Inspiration: https://stackoverflow.com/questions/72899/how-to-sort-a-list-of-dictionaries-by-a-value-of-the-dictionary-in-python + old_chart_dict['dependencies'].sort(key=lambda k: k['name']) + new_chart_dict['dependencies'].sort(key=lambda k: k['name']) + +def is_updated(old_chart_dict: dict, new_chart_dict: dict) -> bool: + sort_dependencies(old_chart_dict, new_chart_dict) -def run_updatecli_manifest(dry_run: bool): - subprocess.check_output(f"updatecli {'diff' if dry_run else 'apply'} --config manifest.yaml".split(" ")) - return + if len(old_chart_dict['dependencies']) != len(new_chart_dict['dependencies']): + raise ValueError("Not the same number of dependencies on both charts.") + + # Compare the versions of each dependency, starting from the smallest to biggest. + for i in range(len(old_chart_dict['dependencies'])): + if old_chart_dict['dependencies'][i]['version'].split('.')[2] < \ + new_chart_dict['dependencies'][i]['version'].split('.')[2]: + return True + elif old_chart_dict['dependencies'][i]['version'].split('.')[1] < \ + new_chart_dict['dependencies'][i]['version'].split('.')[1]: + return True + elif old_chart_dict['dependencies'][i]['version'].split('.')[0] < \ + new_chart_dict['dependencies'][i]['version'].split('.')[0]: + return True + else: + continue + + # Return false in the case that none of the dependencies had an update. + return False + + +def is_major(old_chart_dict: dict, new_chart_dict: dict) -> bool: + sort_dependencies(old_chart_dict, new_chart_dict) + + if len(old_chart_dict['dependencies']) != len(new_chart_dict['dependencies']): + raise ValueError("Not the same number of dependencies on both charts.") + + # Compare the versions of each dependency. + for i in range(len(old_chart_dict['dependencies'])): + if old_chart_dict['dependencies'][i]['version'].split('.')[0] < \ + new_chart_dict['dependencies'][i]['version'].split('.')[0]: + return True + else: + continue + + # Return false in the case that none of the dependencies had a major upgrade. + return False + + +def read_versions(chart: dict) -> dict: + deps_versions = {} + for i, dependency in enumerate(chart['dependencies']): + deps_versions.update({f"{dependency['name']}": f"{dependency['version']}"}) + return deps_versions + + +# FIXME Probably there is a more performance optimised way of doing this instead of nested for loops with a check on +# each one. +def update_asciidoc_attributes(path: str, versions_dict: dict): + for x in versions_dict: + for line in fileinput.input(path, inplace=True): + if line.contains(f":{x}-chart-version:"): + print(f":{x}-chart-version: {versions_dict[x]}") + + +def error_exit(message: str, enable_traceback=False): + print(message) + if enable_traceback: + print(traceback.format_exc()) + sys.exit(1) if __name__ == "__main__": + # Test if Updatecli is installed. try: - # Test if Updatecli is installed subprocess.check_call(['updatecli'], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) except OSError: print("\nCould not find the updatecli executable.\nPlease make sure it is installed and added to $PATH.") sys.exit(1) + chart_path = str(Path(args.chart).absolute()) + + # Parse the contents of the Chart.yaml to upgrade. + try: + old_chart = read_chart_yaml(chart_path) + except Exception: + print(f"Failed to parse the Chart.yaml in '{args.chart}' before the upgrade.") + print(traceback.format_exc()) + sys.exit(1) + + # Generate the manifest.yaml. try: - generate_updatecli_manifest(str(Path(args.chart).absolute()), args.exclude_dependency, - args.upgrade_strategy) + generate_updatecli_manifest(old_chart, chart_path, args.exclude_dependency, args.upgrade_strategy) except Exception: - print(f"Failed while processing the chart in '{args.chart}'.") + print("Unable to generate the manifest.yaml") print(traceback.format_exc()) sys.exit(1) + # Run the Updatecli binary and perform the chart upgrade. try: - run_updatecli_manifest(args.dry_run) + subprocess.check_output(f"updatecli {'diff' if args.dry_run else 'apply'} --config manifest.yaml".split(" ")) + if not args.save_manifest and os.path.exists("manifest.yaml"): + os.remove("manifest.yaml") except Exception: + if os.path.exists("manifest.yaml"): + os.remove("manifest.yaml") print("Error when executing updatecli.") print(traceback.format_exc()) sys.exit(1) + + # Parse the contents of the upgraded Chart.yaml. + try: + new_chart = read_chart_yaml(chart_path) + except Exception: + print(f"Failed to parse the Chart.yaml in '{args.chart}' after the upgrade.") + print(traceback.format_exc()) + sys.exit(1) + + is_major_bool = is_major(old_chart, new_chart) + is_updated_bool = is_updated(old_chart, new_chart) + + # Create the file containing the outputs if demanded by the user. Does not run + if not args.dry_run and args.output: + output_path = str(Path(args.output).absolute()) + try: + with open(output_path, "w") as output_file: + output_file.write(f"is-major={'true' if is_major_bool else 'false'}\n") + output_file.write(f"is-updated={'true' if is_updated_bool else 'false'}\n") + except Exception: + print(f"Failed to create file with the outputs.") + print(traceback.format_exc()) + sys.exit(1) + + # Update a *.adoc if a path is given + if not args.dry_run and args.update_readme and is_updated_bool: + readme_path = str(Path(args.update_readme).absolute()) + versions = read_versions(new_chart) + try: + update_asciidoc_attributes(readme_path, versions) + except FileNotFoundError: + print(f"Could not find the *.adoc file in '{args.update_readme}'.") + sys.exit(1) + except Exception: + error_exit(f"Failed to write versions to the *.adoc in '{args.update_readme}'.") + print(traceback.format_exc()) + sys.exit(1)