Skip to content

Commit

Permalink
feat: add support to generate outputs and update README.adoc
Browse files Browse the repository at this point in the history
  • Loading branch information
lentidas committed Jul 13, 2023
1 parent 6c021cf commit 87c5777
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 49 deletions.
3 changes: 0 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ':<DEPENDENCY_NAME>-chart-version:
<DEPENDENCY_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
Expand All @@ -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 }}
28 changes: 15 additions & 13 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
194 changes: 164 additions & 30 deletions helm_dependency_bumper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,75 @@

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": {},
"conditions": {},
"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 = {
Expand All @@ -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)

0 comments on commit 87c5777

Please sign in to comment.