diff --git a/.github/scripts/helmReleaseDiff.mjs b/.github/scripts/helmReleaseDiff.mjs new file mode 100644 index 000000000..aede12c52 --- /dev/null +++ b/.github/scripts/helmReleaseDiff.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env zx +$.verbose = false + +/** + * * helmReleaseDiff.mjs + * * Runs `helm template` with your Helm values and then runs `dyff` across Flux HelmRelease manifests + * @param --current-release The source Flux HelmRelease to compare against the target + * @param --incoming-release The target Flux HelmRelease to compare against the source + * @param --kubernetes-dir The directory containing your Flux manifests including the HelmRepository manifests + * * Limitations: + * * Does not work with multiple HelmRelease maninfests in the same YAML document + */ +const CurrentRelease = argv['current-release'] +const IncomingRelease = argv['incoming-release'] +const KubernetesDir = argv['kubernetes-dir'] + +const dyff = await which('dyff') +const helm = await which('helm') +const kustomize = await which('kustomize') + +async function helmRelease(releaseFile) { + const helmRelease = await fs.readFile(releaseFile, 'utf8') + const doc = YAML.parseAllDocuments(helmRelease).map((item) => item.toJS()) + const release = doc.filter((item) => + item.apiVersion === 'helm.toolkit.fluxcd.io/v2beta1' + && item.kind === 'HelmRelease' + ) + return release[0] +} + +async function helmRepositoryUrl(kubernetesDir, releaseName) { + const files = await globby([`${kubernetesDir}/**/*.yaml`]) + for await (const file of files) { + const contents = await fs.readFile(file, 'utf8') + const doc = YAML.parseAllDocuments(contents).map((item) => item.toJS()) + if ('apiVersion' in doc[0] && doc[0].apiVersion === 'source.toolkit.fluxcd.io/v1beta2' + && 'kind' in doc[0] && doc[0].kind === 'HelmRepository' + && 'metadata' in doc[0] && 'name' in doc[0].metadata && doc[0].metadata.name === releaseName) + { + return doc[0].spec.url + } + } +} + +async function kustomizeBuild(releaseBaseDir, releaseName) { + const build = await $`${kustomize} build --load-restrictor=LoadRestrictionsNone ${releaseBaseDir}` + const docs = YAML.parseAllDocuments(build.stdout).map((item) => item.toJS()) + const release = docs.filter((item) => + item.apiVersion === 'helm.toolkit.fluxcd.io/v2beta1' + && item.kind === 'HelmRelease' + && item.metadata.name === releaseName + ) + return release[0] +} + +async function helmRepoAdd (registryName, registryUrl) { + await $`${helm} repo add ${registryName} ${registryUrl}` +} + +async function helmTemplate (releaseName, registryName, chartName, chartVersion, chartValues) { + const values = new YAML.Document() + values.contents = chartValues + const valuesFile = await $`mktemp` + await fs.writeFile(valuesFile.stdout.trim(), values.toString()) + + const manifestsFile = await $`mktemp` + const manifests = await $`${helm} template --kube-version 1.24.8 --release-name ${releaseName} --include-crds=false ${registryName}/${chartName} --version ${chartVersion} --values ${valuesFile.stdout.trim()}` + + // Remove docs that are CustomResourceDefinition and keys which contain generated fields + let documents = YAML.parseAllDocuments(manifests.stdout.trim()) + documents = documents.filter(doc => doc.get('kind') !== 'CustomResourceDefinition') + documents.forEach(doc => { + const del = (path) => doc.hasIn(path) ? doc.deleteIn(path) : false + del(['metadata', 'labels']) + del(['spec', 'template', 'metadata', 'annotations']) + del(['spec', 'template', 'metadata', 'labels']) + }) + + await fs.writeFile(manifestsFile.stdout.trim(), documents.map(doc => doc.toString({directives: true})).join('\n')) + return manifestsFile.stdout.trim() +} + +// Generate current template from Helm values +const currentRelease = await helmRelease(CurrentRelease) +const currentBuild = await kustomizeBuild(path.dirname(CurrentRelease), currentRelease.metadata.name) +const currentRepositoryUrl = await helmRepositoryUrl(KubernetesDir, currentBuild.spec.chart.spec.sourceRef.name) +await helmRepoAdd(currentBuild.spec.chart.spec.sourceRef.name, currentRepositoryUrl) +const currentManifests = await helmTemplate( + currentBuild.metadata.name, + currentBuild.spec.chart.spec.sourceRef.name, + currentBuild.spec.chart.spec.chart, + currentBuild.spec.chart.spec.version, + currentBuild.spec.values +) + +// Generate incoming template from Helm values +const incomingRelease = await helmRelease(IncomingRelease) +const incomingBuild = await kustomizeBuild(path.dirname(IncomingRelease), incomingRelease.metadata.name) +const incomingRepositoryUrl = await helmRepositoryUrl(KubernetesDir, incomingBuild.spec.chart.spec.sourceRef.name) +await helmRepoAdd(incomingBuild.spec.chart.spec.sourceRef.name, incomingRepositoryUrl) +const incomingManifests = await helmTemplate( + incomingBuild.metadata.name, + incomingBuild.spec.chart.spec.sourceRef.name, + incomingBuild.spec.chart.spec.chart, + incomingBuild.spec.chart.spec.version, + incomingBuild.spec.values +) + +// Print diff using dyff +const diff = await $`${dyff} --color=off --truecolor=off between --omit-header --ignore-order-changes --detect-kubernetes=true --output=human ${currentManifests} ${incomingManifests}` +echo(diff.stdout.trim()) diff --git a/.github/workflows/helmrelease-diff.yaml b/.github/workflows/helmrelease-diff.yaml new file mode 100644 index 000000000..65088f2af --- /dev/null +++ b/.github/workflows/helmrelease-diff.yaml @@ -0,0 +1,89 @@ +--- +name: HelmRelease Diff + +on: # yamllint disable-line rule:truthy + pull_request: + branches: [master] + paths: ['**.yaml'] + +env: + KUBERNETES_DIR: ./ + +jobs: + changed-files: + name: Detect File Changes + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@b2d17f51244a144849c6b37a3a6791b98a51d86f # v35.9.2 + with: + json: true + files: | + cluster/**/helm-release.yaml + + - id: set-matrix + run: echo "matrix={\"file\":${{ steps.changed-files.outputs.all_changed_files }}}" >> "${GITHUB_OUTPUT}" + + diff: + name: Diff on Helm Releases + runs-on: ubuntu-latest + needs: [changed-files] + strategy: + matrix: ${{ fromJSON(needs.changed-files.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + - name: Checkout default branch + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + with: + ref: ${{ github.event.repository.default_branch }} + path: default_branch + + - name: Setup Homebrew + uses: Homebrew/actions/setup-homebrew@master + + - name: Setup Tools + run: | + brew install helm homeport/tap/dyff kustomize yq + + - name: Diff + id: diff + run: | + diff=$(npx zx ./.github/scripts/helmReleaseDiff.mjs \ + --current-release "default_branch/${{ matrix.file }}" \ + --incoming-release "${{ matrix.file }}" \ + --kubernetes-dir ${{ env.KUBERNETES_DIR }} \ + --diff-tool "diff") + # shellcheck disable=SC2129 + echo "diff<> "${GITHUB_OUTPUT}" + echo "${diff}" >> "${GITHUB_OUTPUT}" + echo "EOF" >> "${GITHUB_OUTPUT}" + + - name: Find Comment + if: ${{ always() && steps.diff.outputs.diff != '' }} + uses: peter-evans/find-comment@a54c31d7fa095754bfef525c0c8e5e5674c4b4b1 # v2.4.0 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: 'Helm Release Diff: ${{ matrix.file }}' + + - name: Create or update comment + if: ${{ always() && steps.diff.outputs.diff != '' }} + uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + Helm Release Diff: `${{ matrix.file }}` + + ```diff + ${{ steps.diff.outputs.diff }} + ``` + edit-mode: replace