From 5d5fe0bdd6ea7b32efee882ee4ae8feca49025e6 Mon Sep 17 00:00:00 2001 From: foxhound Date: Thu, 17 Sep 2026 13:40:34 +0300 Subject: [PATCH 01/14] add test logic --- .github/workflows/test.yml | 57 +++++ .gitignore | 1 + Makefile | 75 ++++++ test/capabilities.bats | 94 ++++++++ test/discover.bats | 96 ++++++++ test/docker-smoke.sh | 37 +++ test/env.bats | 129 +++++++++++ test/fixtures/chart-probe/Chart.yaml | 4 + .../fixtures/chart-probe/templates/probe.yaml | 11 + test/fixtures/chart-probe/values.yaml | 1 + test/generate.bats | 181 +++++++++++++++ test/helpers.bash | 89 ++++++++ test/init.bats | 216 ++++++++++++++++++ test/parameters.bats | 44 ++++ 14 files changed, 1035 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 Makefile create mode 100644 test/capabilities.bats create mode 100644 test/discover.bats create mode 100644 test/docker-smoke.sh create mode 100644 test/env.bats create mode 100644 test/fixtures/chart-probe/Chart.yaml create mode 100644 test/fixtures/chart-probe/templates/probe.yaml create mode 100644 test/fixtures/chart-probe/values.yaml create mode 100644 test/generate.bats create mode 100644 test/helpers.bash create mode 100644 test/init.bats create mode 100644 test/parameters.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..93920de --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,57 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Shellcheck + # shellcheck is preinstalled on ubuntu-latest runners + run: | + shellcheck --version + make lint + + bats: + name: bats (${{ matrix.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # Versions pinned in docker/Dockerfile (what the image ships). + - name: image versions + make_args: "" + # Helm 3 is still usable via HELM_BINARY until its EOL (2026-11-11). + - name: helm v3 + make_args: HELM_VERSION=v3.19.4 + steps: + - uses: actions/checkout@v4 + + - name: Cache tools + uses: actions/cache@v4 + with: + path: .tools + key: tools-${{ runner.os }}-${{ matrix.name }}-${{ hashFiles('docker/Dockerfile', 'Makefile') }} + + - name: Show tool versions under test + run: make versions ${{ matrix.make_args }} + + - name: Run bats tests + run: make test ${{ matrix.make_args }} + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image and run smoke test + run: make test-docker diff --git a/.gitignore b/.gitignore index 485dee6..bbe1cef 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea +.tools/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfda731 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +SHELL := /bin/bash + +TOOLS_DIR := $(CURDIR)/.tools +BATS_DIR := $(TOOLS_DIR)/bats +DOCKERFILE := docker/Dockerfile +IMAGE ?= argocd-helmfile-plugin:test + +# Tool versions under test are taken from the Dockerfile so tests always +# run against what the image ships. Override on the command line if needed. +HELM_VERSION ?= $(shell sed -n 's/^ARG HELM_VERSION="\(.*\)"/\1/p' $(DOCKERFILE)) +HELMFILE_VERSION ?= $(shell sed -n 's/^ARG HELMFILE_VERSION="\(.*\)"/\1/p' $(DOCKERFILE)) + +BATS_CORE_VERSION := v1.14.0 +BATS_SUPPORT_VERSION := v0.3.0 +BATS_ASSERT_VERSION := v2.2.4 + +GO_ARCH := $(shell uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/') + +HELM_DIR := $(TOOLS_DIR)/helm/$(HELM_VERSION) +HELMFILE_DIR := $(TOOLS_DIR)/helmfile/$(HELMFILE_VERSION) + +export PATH := $(HELM_DIR):$(HELMFILE_DIR):$(PATH) + +.PHONY: help tools lint test test-docker clean versions + +help: + @echo "make tools - download helm, helmfile and bats into $(TOOLS_DIR)" + @echo "make lint - run shellcheck" + @echo "make test - run bats tests (downloads tools if needed)" + @echo "make test-docker - build the image and run a smoke test inside it" + @echo "make clean - remove $(TOOLS_DIR)" + +versions: + @echo "helm: $(HELM_VERSION)" + @echo "helmfile: $(HELMFILE_VERSION)" + +$(HELM_DIR)/helm: + @mkdir -p $(HELM_DIR) + wget -qO- "https://get.helm.sh/helm-$(HELM_VERSION)-linux-$(GO_ARCH).tar.gz" \ + | tar zx --strip-components=1 -C $(HELM_DIR) linux-$(GO_ARCH)/helm + +$(HELMFILE_DIR)/helmfile: + @mkdir -p $(HELMFILE_DIR) + wget -qO- "https://github.com/helmfile/helmfile/releases/download/v$(HELMFILE_VERSION)/helmfile_$(HELMFILE_VERSION)_linux_$(GO_ARCH).tar.gz" \ + | tar zx -C $(HELMFILE_DIR) helmfile + +$(BATS_DIR)/.installed: + @mkdir -p $(BATS_DIR) + git clone -q --depth 1 --branch $(BATS_CORE_VERSION) https://github.com/bats-core/bats-core $(BATS_DIR)/bats-core + git clone -q --depth 1 --branch $(BATS_SUPPORT_VERSION) https://github.com/bats-core/bats-support $(BATS_DIR)/bats-support + git clone -q --depth 1 --branch $(BATS_ASSERT_VERSION) https://github.com/bats-core/bats-assert $(BATS_DIR)/bats-assert + @touch $@ + +tools: $(HELM_DIR)/helm $(HELMFILE_DIR)/helmfile $(BATS_DIR)/.installed + +# The plugin is checked at error severity for now; it is raised once the +# script cleanup lands. For .bats files: +# SC2030/SC2031 each @test runs in a subshell by design +# SC2016 single-quoted $${VAR} is intentional (tests variable expansion) +lint: + shellcheck --severity=error src/*.sh + shellcheck test/*.bash test/*.sh + shellcheck -s bash -e SC2030,SC2031,SC2016 test/*.bats + +test: tools + @helm version --short + @helmfile --version + BATS_LIB_PATH=$(BATS_DIR) $(BATS_DIR)/bats-core/bin/bats --print-output-on-failure test/ + +test-docker: + docker build -f $(DOCKERFILE) -t $(IMAGE) . + docker run --rm -v $(CURDIR)/test:/test:ro --entrypoint /bin/bash $(IMAGE) /test/docker-smoke.sh + +clean: + rm -rf $(TOOLS_DIR) diff --git a/test/capabilities.bats b/test/capabilities.bats new file mode 100644 index 0000000..4c4145f --- /dev/null +++ b/test/capabilities.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# Kubernetes capabilities: KUBE_VERSION and KUBE_API_VERSIONS from Argo CD +# must reach helm template (.Capabilities.KubeVersion / .APIVersions). + +setup() { + load helpers + common_setup + write_helmfile helmfile.yaml + HELM_MAJOR="$(helm version --template '{{.Version}}' | sed -E 's/^v([0-9]+).*/\1/')" +} + +skip_if_helm4_bug() { + if [[ "${HELM_MAJOR}" -ge 4 ]]; then + true "known bug: capabilities flags are only passed for Helm 3 (fix planned)" + fi +} + +@test "capabilities: helm and helmfile versions are logged to stderr" { + run_plugin init + assert_success + assert_regex "${stderr}" "helm version v[0-9]+\." + assert_regex "${stderr}" "helmfile version" +} + +@test "capabilities: renders without KUBE_VERSION and KUBE_API_VERSIONS" { + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value hasProbeApi)" "false" +} + +@test "capabilities: KUBE_VERSION is passed to helm" { + skip_if_helm4_bug + export KUBE_VERSION="1.29" + plugin_init + run_plugin generate + assert_success + # helm keeps the two-part form: --kube-version 1.29 renders as v1.29 + assert_equal "$(probe_value kubeVersion)" "v1.29" +} + +@test "capabilities: KUBE_API_VERSIONS is passed to helm" { + skip_if_helm4_bug + export KUBE_API_VERSIONS="v1,apps/v1,probe.example.com/v1" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value hasProbeApi)" "true" +} + +@test "capabilities: KUBE_API_VERSIONS without the probe API is honoured" { + skip_if_helm4_bug + export KUBE_API_VERSIONS="v1,apps/v1" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value hasProbeApi)" "false" +} + +# KUBE_VERSION normalization. Argo CD passes the cluster's version, which may +# carry a leading "v" or vendor suffixes (argoproj/argo-cd#8249). +kube_version_case() { + local input="$1" expected="$2" + export KUBE_VERSION="${input}" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value kubeVersion)" "${expected}" +} + +@test "capabilities: KUBE_VERSION 1.29.3 -> v1.29.3" { + skip_if_helm4_bug + kube_version_case "1.29.3" "v1.29.3" +} + +@test "capabilities: KUBE_VERSION v1.29.3 -> v1.29.3" { + skip_if_helm4_bug + kube_version_case "v1.29.3" "v1.29.3" +} + +@test "capabilities: KUBE_VERSION 1.29.0+k3s1 -> v1.29.0" { + true "known bug: sanitizer produces 1.29.031 (fix planned)" + kube_version_case "1.29.0+k3s1" "v1.29.0" +} + +@test "capabilities: KUBE_VERSION 1.29.0-eks-5e0fdde -> v1.29.0" { + true "known bug: sanitizer produces 1.29.050 (fix planned)" + kube_version_case "1.29.0-eks-5e0fdde" "v1.29.0" +} + +@test "capabilities: KUBE_VERSION 1.29+ -> v1.29" { + skip_if_helm4_bug + kube_version_case "1.29+" "v1.29" +} diff --git a/test/discover.bats b/test/discover.bats new file mode 100644 index 0000000..e91004e --- /dev/null +++ b/test/discover.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats +# discover phase: decides whether Argo CD should use this plugin for a source. + +setup() { + load helpers + common_setup +} + +@test "discover: empty directory does not match" { + run_plugin discover + assert_failure +} + +@test "discover: helmfile.yaml matches" { + write_helmfile helmfile.yaml + run_plugin discover + assert_success +} + +@test "discover: helmfile.yaml.gotmpl matches" { + write_helmfile helmfile.yaml.gotmpl + run_plugin discover + assert_success +} + +@test "discover: helmfile.d directory matches" { + write_helmfile helmfile.d/10-probe.yaml + run_plugin discover + assert_success +} + +@test "discover: helmfile.yaml in a subdirectory only does not match" { + write_helmfile nested/helmfile.yaml + run_plugin discover + assert_failure +} + +@test "discover: HELMFILE_HELMFILE set matches without files" { + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + run_plugin discover + assert_success +} + +@test "discover: --file in HELMFILE_GLOBAL_OPTIONS matches without files" { + export ARGOCD_ENV_HELMFILE_GLOBAL_OPTIONS="--file custom/helmfile.yaml" + run_plugin discover + assert_success +} + +@test "discover: -f in HELMFILE_GLOBAL_OPTIONS matches without files" { + export ARGOCD_ENV_HELMFILE_GLOBAL_OPTIONS="-f custom/helmfile.yaml" + run_plugin discover + assert_success +} + +@test "discover: PARAM_ values are honoured like ARGOCD_ENV_ values" { + export PARAM_HELMFILE_GLOBAL_OPTIONS="--file custom/helmfile.yaml" + run_plugin discover + assert_success +} + +@test "discover: option merely containing '-f' does not match" { + true "known bug: *-f* glob matches e.g. --kube-context=prod-frontend (fix planned)" + export ARGOCD_ENV_HELMFILE_GLOBAL_OPTIONS="--kube-context=prod-frontend" + run_plugin discover + assert_failure +} + +@test "discover: diagnostics do not go to stdout on no match" { + true "known issue: 'no valid helmfile content discovered' is printed to stdout (fix planned)" + run_plugin discover + assert_failure + assert_output "" +} + +# HELMFILE_DISCOVERY_RESPONSE also exercises truthy_test. +@test "discover: forced response is truthy for true/1/yes (any case)" { + local v + for v in true TRUE True 1 yes YES Yes; do + export ARGOCD_ENV_HELMFILE_DISCOVERY_RESPONSE="${v}" + run_plugin discover + assert_success + assert_output --partial "enabled" + done +} + +@test "discover: forced response is falsy for other values even if files exist" { + write_helmfile helmfile.yaml + local v + for v in false FALSE 0 no off random 2; do + export ARGOCD_ENV_HELMFILE_DISCOVERY_RESPONSE="${v}" + run_plugin discover + assert_failure + assert_output --partial "disabled" + done +} diff --git a/test/docker-smoke.sh b/test/docker-smoke.sh new file mode 100644 index 0000000..4ed1219 --- /dev/null +++ b/test/docker-smoke.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Smoke test executed inside the built image (make test-docker). +# Checks that the shipped binaries and the plugin work together. +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +cp -r /test/fixtures/chart-probe "${work}/chart" +cd "${work}" + +cat >helmfile.yaml </dev/null || fail "${plugin} not on PATH" + +"${plugin}" discover >/dev/null || fail "discover did not match helmfile.yaml" +"${plugin}" parameters | jq -e 'length > 0' >/dev/null || fail "parameters is not valid JSON" +"${plugin}" init >/dev/null || fail "init failed" +out="$("${plugin}" generate)" || fail "generate failed" +grep -q 'releaseNamespace: "smoke-ns"' <<<"${out}" || fail "unexpected generate output: ${out}" + +# Plugins shipped in the image must be visible to helm. +plugins="$(helm plugin list)" +for p in diff helm-git secrets; do + grep -q "^${p}\b" <<<"${plugins}" || fail "helm plugin ${p} missing" +done + +echo "OK: docker smoke test passed" diff --git a/test/env.bats b/test/env.bats new file mode 100644 index 0000000..d22a6cd --- /dev/null +++ b/test/env.bats @@ -0,0 +1,129 @@ +#!/usr/bin/env bats +# Environment handling: ARGOCD_ENV_/PARAM_ export, binaries, HOME isolation. + +setup() { + load helpers + common_setup +} + +# Create a wrapper for a real binary that records each call. +# Usage: make_wrapper +make_wrapper() { + local tool="$1" dir="$2" real + real="$(command -v "${tool}")" + mkdir -p "${dir}" + cat >"${dir}/${tool}" <>"${BATS_TEST_TMPDIR}/${tool}-calls.log" +exec "${real}" "\$@" +SH + chmod +x "${dir}/${tool}" +} + +@test "env: ARGOCD_ENV_ variables are available unprefixed to helmfile" { + cat >helmfile.yaml.gotmpl <helmfile.yaml.gotmpl <helmfile.yaml.gotmpl <helmfile.yaml.gotmpl <helmfile.yaml <helmfile.yaml.gotmpl <helmfile.yaml + run_plugin generate + assert_failure +} + +@test "generate: HELMFILE_ENV_FILE is sourced" { + write_helmfile helmfile.yaml + cat >custom.env <<'ENV' +HELM_TEMPLATE_OPTIONS="--set marker=from-env-file" +ENV + export ARGOCD_ENV_HELMFILE_ENV_FILE="custom.env" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value marker)" "from-env-file" +} + +@test "generate: default env file .argo-cd-helmfile-env is sourced" { + write_helmfile helmfile.yaml + cat >.argo-cd-helmfile-env <<'ENV' +HELM_TEMPLATE_OPTIONS="--set marker=from-default-env-file" +ENV + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value marker)" "from-default-env-file" +} + +@test "generate: variables in HELM_TEMPLATE_OPTIONS are expanded" { + write_helmfile helmfile.yaml + export PROBE_MARKER="from-expansion" + export ARGOCD_ENV_HELM_TEMPLATE_OPTIONS='--set marker=${PROBE_MARKER}' + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value marker)" "from-expansion" +} diff --git a/test/helpers.bash b/test/helpers.bash new file mode 100644 index 0000000..85d2891 --- /dev/null +++ b/test/helpers.bash @@ -0,0 +1,89 @@ +# shellcheck shell=bash +# Variables set here are used by the .bats files; $output/$stderr are set by bats. +# shellcheck disable=SC2034,SC2154 +# Shared setup for all bats tests. +# +# Every test gets its own work directory (a fake Argo CD source checkout) and +# its own HELM_HOME, so tests never share caches, repos or helmfile state. + +bats_require_minimum_version 1.5.0 + +bats_load_library bats-support +bats_load_library bats-assert + +REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" +PLUGIN="${REPO_ROOT}/src/argocd-helmfile-plugin.sh" +FIXTURES="${BATS_TEST_DIRNAME}/fixtures" + +# Name of the directory the plugin generates for HELMFILE_HELMFILE. +INJECTED_DIR=".__argocd-helmfile-plugin.sh__helmfile.d" + +common_setup() { + WORK="${BATS_TEST_TMPDIR}/src" + mkdir -p "${WORK}" + cp -r "${FIXTURES}/chart-probe" "${WORK}/chart" + cd "${WORK}" || return 1 + + # Standard Argo CD build environment. + export ARGOCD_APP_NAME="test-app" + export ARGOCD_APP_NAMESPACE="test-ns" + export ARGOCD_APP_REVISION="0000000000000000000000000000000000000000" + export ARGOCD_APP_SOURCE_PATH="." + export ARGOCD_APP_SOURCE_REPO_URL="https://example.invalid/repo.git" + export ARGOCD_APP_SOURCE_TARGET_REVISION="main" + + # Isolate helm/helmfile state per test. + export HELM_HOME="${BATS_TEST_TMPDIR}/home" + export HELM_CACHE_HOME="${BATS_TEST_TMPDIR}/helm/cache" + export HELM_CONFIG_HOME="${BATS_TEST_TMPDIR}/helm/config" + export HELM_DATA_HOME="${BATS_TEST_TMPDIR}/helm/data" + + # Make sure nothing from the developer's shell leaks into the plugin. + unset DEBUG KUBE_VERSION KUBE_API_VERSIONS \ + HELM_BINARY HELMFILE_BINARY \ + HELM_TEMPLATE_OPTIONS HELMFILE_TEMPLATE_OPTIONS HELMFILE_GLOBAL_OPTIONS \ + HELMFILE_HELMFILE HELMFILE_HELMFILE_STRATEGY HELMFILE_INIT_SCRIPT_FILE \ + HELMFILE_ENV_FILE HELMFILE_CACHE_CLEANUP HELMFILE_REPO_CACHE_TIMEOUT \ + HELMFILE_USE_CONTEXT_NAMESPACE HELMFILE_DISCOVERY_RESPONSE + # No cluster is available in tests. + export KUBECONFIG="${BATS_TEST_TMPDIR}/no-kubeconfig" +} + +# Write a helmfile.yaml with a single release pointing at the probe chart. +# Usage: write_helmfile [file] [release-name] +write_helmfile() { + local file="${1:-helmfile.yaml}" name="${2:-probe}" + mkdir -p "$(dirname "${file}")" + cat >"${file}" < [release-name] +probe_value() { + local key="$1" name="${2:-probe}" + printf '%s\n' "${output}" | + awk -v name="${name}" -v key="${key}" ' + /^---/ { in_doc = 0 } + $1 == "name:" && $2 == name { in_doc = 1 } + in_doc && $1 == key ":" { v = $2; gsub(/"/, "", v); print v; exit } + ' +} diff --git a/test/init.bats b/test/init.bats new file mode 100644 index 0000000..ff5388d --- /dev/null +++ b/test/init.bats @@ -0,0 +1,216 @@ +#!/usr/bin/env bats +# init phase: runs before every generate. Prepares helmfile input and repos. + +setup() { + load helpers + common_setup +} + +# The INCLUDE branch counts sources with `[[ -f x ]] && ((count++))`. +# ((0++)) returns 1, which makes `set -e` abort init silently as soon as any +# helmfile source exists, so INCLUDE is currently unusable. +skip_include_bug() { + true "known bug: INCLUDE aborts on ((count++)) under set -e (fix planned)" +} + +@test "init: succeeds for a plain helmfile.yaml" { + write_helmfile helmfile.yaml + run_plugin init + assert_success + assert_regex "${stderr}" "starting init" +} + +@test "init: fails without a phase argument" { + run_plugin + assert_failure + assert_regex "${stderr}" "invalid invocation" +} + +@test "init: fails for an unknown phase" { + run_plugin bogus + assert_failure + assert_regex "${stderr}" "invalid invocation" +} + +# --- repo cache ------------------------------------------------------------- + +@test "init: repos update runs every time without HELMFILE_REPO_CACHE_TIMEOUT" { + write_helmfile helmfile.yaml + plugin_init + run_plugin init + assert_success + refute_regex "${stderr}" "skipping repos update due to cache" +} + +@test "init: repos update is skipped within HELMFILE_REPO_CACHE_TIMEOUT" { + write_helmfile helmfile.yaml + export ARGOCD_ENV_HELMFILE_REPO_CACHE_TIMEOUT="300" + plugin_init + refute_regex "${stderr}" "skipping repos update due to cache" + run_plugin init + assert_success + assert_regex "${stderr}" "skipping repos update due to cache" +} + +@test "init: a new revision busts the repo cache" { + write_helmfile helmfile.yaml + export ARGOCD_ENV_HELMFILE_REPO_CACHE_TIMEOUT="300" + plugin_init + export ARGOCD_APP_REVISION="1111111111111111111111111111111111111111" + run_plugin init + assert_success + refute_regex "${stderr}" "skipping repos update due to cache" +} + +@test "init: HELMFILE_CACHE_CLEANUP=true succeeds" { + write_helmfile helmfile.yaml + export ARGOCD_ENV_HELMFILE_CACHE_CLEANUP="true" + run_plugin init + assert_success +} + +# --- init script ------------------------------------------------------------ + +@test "init: HELMFILE_INIT_SCRIPT_FILE is executed in the source directory" { + write_helmfile helmfile.yaml + cat >init.sh <<'SH' +pwd >init-ran.txt +SH + export ARGOCD_ENV_HELMFILE_INIT_SCRIPT_FILE="init.sh" + plugin_init + assert [ -f init-ran.txt ] + assert_equal "$(cat init-ran.txt)" "${WORK}" +} + +@test "init: HELMFILE_INIT_SCRIPT_FILE path is variable-expanded" { + write_helmfile helmfile.yaml + mkdir scripts + printf 'touch init-ran.txt\n' >scripts/init.sh + export PROBE_SCRIPTS_DIR="scripts" + export ARGOCD_ENV_HELMFILE_INIT_SCRIPT_FILE='${PROBE_SCRIPTS_DIR}/init.sh' + plugin_init + assert [ -f init-ran.txt ] +} + +@test "init: a failing HELMFILE_INIT_SCRIPT_FILE fails init" { + write_helmfile helmfile.yaml + printf 'exit 3\n' >init.sh + export ARGOCD_ENV_HELMFILE_INIT_SCRIPT_FILE="init.sh" + run_plugin init + assert_failure +} + +# --- HELMFILE_HELMFILE ------------------------------------------------------ + +@test "init: HELMFILE_HELMFILE defaults to REPLACE and ignores repo helmfile" { + write_helmfile helmfile.yaml from-repo + write_helmfile "${BATS_TEST_TMPDIR}/injected.yaml" from-param + ARGOCD_ENV_HELMFILE_HELMFILE="$(cat "${BATS_TEST_TMPDIR}/injected.yaml")" + export ARGOCD_ENV_HELMFILE_HELMFILE + plugin_init + run ls -A "${INJECTED_DIR}" + assert_output "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ__argocd__helmfile__.yaml" + + run_plugin generate + assert_success + assert_equal "$(probe_value releaseName from-param)" "from-param" + assert_equal "$(probe_value releaseName from-repo)" "" +} + +@test "init: HELMFILE_HELMFILE with INCLUDE renders repo helmfile.yaml and param" { + skip_include_bug + write_helmfile helmfile.yaml from-repo + write_helmfile "${BATS_TEST_TMPDIR}/injected.yaml" from-param + ARGOCD_ENV_HELMFILE_HELMFILE="$(cat "${BATS_TEST_TMPDIR}/injected.yaml")" + export ARGOCD_ENV_HELMFILE_HELMFILE + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + plugin_init + assert [ -f "${INJECTED_DIR}/helmfile.yaml" ] + + run_plugin generate + assert_success + assert_equal "$(probe_value releaseName from-param)" "from-param" + assert_equal "$(probe_value releaseName from-repo)" "from-repo" +} + +@test "init: INCLUDE copies helmfile.yaml.gotmpl" { + skip_include_bug + write_helmfile helmfile.yaml.gotmpl from-repo + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + plugin_init + assert [ -f "${INJECTED_DIR}/helmfile.yaml.gotmpl" ] +} + +@test "init: INCLUDE copies the contents of helmfile.d" { + skip_include_bug + write_helmfile helmfile.d/10-one.yaml one + write_helmfile helmfile.d/20-two.yaml two + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + plugin_init + assert [ -f "${INJECTED_DIR}/10-one.yaml" ] + assert [ -f "${INJECTED_DIR}/20-two.yaml" ] + + run_plugin generate + assert_success + assert_equal "$(probe_value releaseName one)" "one" + assert_equal "$(probe_value releaseName two)" "two" +} + +@test "init: INCLUDE warns when more than one helmfile source exists" { + skip_include_bug + # Current behaviour is a warning only; planned to become a hard failure. + write_helmfile helmfile.yaml + write_helmfile helmfile.d/10-one.yaml one + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + run_plugin init + assert_regex "${stderr}" "but not more than one" +} + +@test "init: invalid HELMFILE_HELMFILE_STRATEGY fails" { + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="MERGE" + run_plugin init + assert_failure + assert_regex "${stderr}" "invalid .HELMFILE_HELMFILE_STRATEGY" +} + +@test "init: re-running init removes stale injected files" { + skip_include_bug + write_helmfile helmfile.yaml + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + plugin_init + assert [ -f "${INJECTED_DIR}/helmfile.yaml" ] + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="REPLACE" + plugin_init + refute [ -f "${INJECTED_DIR}/helmfile.yaml" ] +} + +@test "init: INCLUDE keeps relative chart paths working" { + true "known bug: files are copied into ${INJECTED_DIR}, relative paths break (fix planned)" + cat >helmfile.yaml <<'YAML' +releases: + - name: probe + chart: ./chart +YAML + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" + export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value releaseName)" "probe" +} + +@test "init: templated HELMFILE_HELMFILE is rendered" { + true "known bug: injected file is written as .yaml, helmfile v1 only templates .gotmpl (fix planned)" + export ARGOCD_ENV_HELMFILE_HELMFILE="releases: + - name: {{ \"probe\" }} + chart: ${WORK}/chart" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value releaseName)" "probe" +} diff --git a/test/parameters.bats b/test/parameters.bats new file mode 100644 index 0000000..6d3bd71 --- /dev/null +++ b/test/parameters.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats +# parameters phase: dynamic parameter announcement for the Argo CD UI. + +setup() { + load helpers + common_setup +} + +@test "parameters: prints valid JSON array on stdout" { + run_plugin parameters + assert_success + run jq -e 'type == "array" and length > 0' <<<"${output}" + assert_success +} + +@test "parameters: every entry has name, title and tooltip" { + run_plugin parameters + assert_success + run jq -e 'all(.[]; has("name") and has("title") and has("tooltip"))' <<<"${output}" + assert_success +} + +@test "parameters: announces the expected parameter names" { + run_plugin parameters + assert_success + run jq -r '.[].name' <<<"${output}" + assert_success + assert_output "$(printf '%s\n' \ + HELM_TEMPLATE_OPTIONS \ + HELMFILE_TEMPLATE_OPTIONS \ + HELMFILE_GLOBAL_OPTIONS \ + HELMFILE_HELMFILE \ + HELMFILE_HELMFILE_STRATEGY \ + HELMFILE_INIT_SCRIPT_FILE \ + HELMFILE_CACHE_CLEANUP \ + HELMFILE_USE_CONTEXT_NAMESPACE)" +} + +@test "parameters: boolean parameters are typed as boolean" { + run_plugin parameters + assert_success + run jq -r '.[] | select(.itemType == "boolean") | .name' <<<"${output}" + assert_output "$(printf '%s\n' HELMFILE_CACHE_CLEANUP HELMFILE_USE_CONTEXT_NAMESPACE)" +} From 032a3870487db856a610cf8b835e04b439a676b8 Mon Sep 17 00:00:00 2001 From: s-skorobohatko Date: Thu, 17 Sep 2026 13:42:54 +0300 Subject: [PATCH 02/14] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8120ba9..5a24dd8 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ # Intro + Support for `helmfile` with `argo-cd`. `argo-cd` already supports `helm` in 2 distinct ways, why is this useful? From eafa4c38c7a180c93a10a2715fff558294dbac09 Mon Sep 17 00:00:00 2001 From: s-skorobohatko Date: Thu, 17 Sep 2026 13:56:26 +0300 Subject: [PATCH 03/14] remove helm 2 --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 23 ++++++ Makefile | 6 ++ README.md | 37 ++++++++- src/argocd-helmfile-plugin.sh | 115 +++++++++++++++----------- test/capabilities.bats | 13 +-- test/discover.bats | 4 +- test/docker-smoke.sh | 2 +- test/env.bats | 82 +++++++++++++------ test/helpers.bash | 41 +++++++++- test/init.bats | 15 +--- test/versions.bats | 146 ++++++++++++++++++++++++++++++++++ 12 files changed, 385 insertions(+), 101 deletions(-) create mode 100644 test/versions.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93920de..92f123a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,4 +54,4 @@ jobs: - uses: actions/checkout@v4 - name: Build image and run smoke test - run: make test-docker + run: make test-docker \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b5eb4ef..6ccb863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.1.0/) and this project adheres to Semantic Versioning (https://semver.org/). --- +## [Unreleased] +### Removed +- Remaining Helm 2 code: `helm init --client-only`, Helm 2 `--kube-version` handling and comments. +- `HELMFILE_HELM3` export (no-op since helmfile v1). + +### Changed +- Helm version is detected with `helm version --template '{{.Version}}'` instead of parsing `--short` output. +- `init` and `generate` fail with a clear error for Helm < 3, helmfile < 1 or unparsable versions. +- `discover` and `parameters` no longer run `helm`/`helmfile`. + +### Deprecated +- `HELM_HOME`: use `PLUGIN_APP_HOME`. `HELM_HOME` is still accepted with a warning and is exported with the same value. + +### Fixed +- `HELMFILE_HELMFILE_STRATEGY=INCLUDE` aborted `init` silently when any helmfile source existed (`((count++))` under `set -e`). + +### Added +- `PLUGIN_APP_HOME` environment variable. +- bats test suite (`test/`) covering the `discover`, `parameters`, `init` and `generate` phases, environment handling and Kubernetes capabilities. +- Docker image smoke test (`test/docker-smoke.sh`). +- `Makefile` with `tools`, `lint`, `test` and `test-docker` targets. +- GitHub Actions workflow `Test` running shellcheck, bats and the Docker smoke test on pushes to `main` and pull requests. + ## [1.3.1] - 2026-05-27 ### Fixed - Fixed plugin installation and compatibility issues for Helm v4, ensuring proper support for CLI plugins including helm-secrets as described in the updated installation guide: https://github.com/jkroepke/helm-secrets/wiki/Installation diff --git a/Makefile b/Makefile index cfda731..d98efb7 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +# Local developer entrypoints. CI calls the same targets. + SHELL := /bin/bash TOOLS_DIR := $(CURDIR)/.tools @@ -16,6 +18,7 @@ BATS_ASSERT_VERSION := v2.2.4 GO_ARCH := $(shell uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/') +# Each version lives in its own directory so switching versions never mixes binaries. HELM_DIR := $(TOOLS_DIR)/helm/$(HELM_VERSION) HELMFILE_DIR := $(TOOLS_DIR)/helmfile/$(HELMFILE_VERSION) @@ -61,6 +64,9 @@ lint: shellcheck --severity=error src/*.sh shellcheck test/*.bash test/*.sh shellcheck -s bash -e SC2030,SC2031,SC2016 test/*.bats + @# Helm 2 support was removed, make sure it does not come back. + @if grep -nE 'init --client-only|HELMFILE_HELM3|helm_major_version\} -eq 2' src/*.sh; then \ + echo "Helm 2 code found in src/"; exit 1; fi test: tools @helm version --short diff --git a/README.md b/README.md index 5a24dd8..62b3e03 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ # Intro - Support for `helmfile` with `argo-cd`. `argo-cd` already supports `helm` in 2 distinct ways, why is this useful? @@ -31,6 +30,14 @@ Consider these implications for your environment and act appropriately. - https://github.com/helmfile/helmfile/pull/1 (can disable `exec` using env vars) - the execution pod/context is the `argocd-repo-server` +# Requirements + +- `helm` >= 3 (Helm 4 recommended; Helm 2 is not supported) +- `helmfile` >= 1 + +The plugin checks both versions in the `init` and `generate` phases and fails +with a clear message if they are not supported. + # Installation - https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/ @@ -102,6 +109,11 @@ optional): `HELMFILE_HELMFILE` should the same release name be declared in multiple files - `HELMFILE_CACHE_CLEANUP` - run helmfile cache cleanup on init +- `PLUGIN_APP_HOME` - per-application directory used as `HOME` while running + `helm`/`helmfile`, so applications do not share repositories, registry + logins or caches. Defaults to `/tmp/__argocd-helmfile-plugin.sh__/apps/${ARGOCD_APP_NAME}` +- `HELM_HOME` - **deprecated** alias for `PLUGIN_APP_HOME` (Helm itself ignores + it since v3). Still accepted with a warning; `PLUGIN_APP_HOME` wins if both are set Of the above `ENV` variables, the following do variable expansion on the value: @@ -109,6 +121,9 @@ Of the above `ENV` variables, the following do variable expansion on the value: - `HELMFILE_TEMPLATE_OPTIONS` - `HELM_TEMPLATE_OPTIONS` - `HELMFILE_INIT_SCRIPT_FILE` +- `PLUGIN_APP_HOME` (and deprecated `HELM_HOME`) +- `HELM_CACHE_HOME` +- `HELM_CONFIG_HOME` - `HELM_DATA_HOME` Meaning, you can do things like: @@ -172,6 +187,26 @@ etc. The value can be a relative or absolute path and the file itself can be injected using an `initContainers` or stored in the application git repository. ## Development + +### Tests + +Tests use [bats-core](https://github.com/bats-core/bats-core) and run the plugin +against the real `helm` and `helmfile` binaries, using the versions pinned in +`docker/Dockerfile`. No cluster or network access to chart repositories is needed. + +```bash +make test # downloads helm, helmfile and bats into .tools/, then runs test/*.bats +make lint # shellcheck +make test-docker # builds the image and runs test/docker-smoke.sh inside it +``` + +Requirements: `bash`, `git`, `wget`, `jq`, `make`, `shellcheck` (and `docker` for `test-docker`). +Override tool versions with e.g. `make test HELM_VERSION=v3.19.4`. + +Tests for known bugs are marked with `skip "known bug: ..."`. Remove the skip +together with the fix. + +### Contributing ```declarative # Create fork. # Add the original repository as a new remote called "upstream" (only once, if not done before) diff --git a/src/argocd-helmfile-plugin.sh b/src/argocd-helmfile-plugin.sh index 0b3ba78..72508e1 100755 --- a/src/argocd-helmfile-plugin.sh +++ b/src/argocd-helmfile-plugin.sh @@ -14,7 +14,8 @@ # HELMFILE_REPO_CACHE_TIMEOUT - seconds to cache the repo update process # HELMFILE_USE_CONTEXT_NAMESPACE - do not set helmfile namespace to ARGOCD_APP_NAMESPACE (for multi-namespace apps) # HELMFILE_DISCOVERY_RESPONSE - truthy value for forced response -# HELM_HOME - perform variable expansion +# PLUGIN_APP_HOME - per-application HOME directory, perform variable expansion +# HELM_HOME - deprecated alias for PLUGIN_APP_HOME # HELM_CACHE_HOME - perform variable expansion # HELM_CONFIG_HOME - perform variable expansion # HELM_DATA_HOME - perform variable expansion @@ -223,20 +224,26 @@ if [[ "${HELM_DATA_HOME}" ]]; then export HELM_DATA_HOME=$(variable_expansion "${HELM_DATA_HOME}") fi -# setup the env -# HELM_HOME is deprecated with helm-v3, uses XDG dirs -if [[ "${HELM_HOME}" ]]; then - export HELM_HOME=$(variable_expansion "${HELM_HOME}") -else - export HELM_HOME="/tmp/__${SCRIPT_NAME}__/apps/${ARGOCD_APP_NAME}" +# per-application home directory, later used as HOME so apps do NOT share +# helm repositories, registry logins, caches, etc. +# HELM_HOME is accepted as a deprecated alias (helm ignores it since v3). +if [[ -z "${PLUGIN_APP_HOME}" && "${HELM_HOME}" ]]; then + echoerr "WARNING: HELM_HOME is deprecated, use PLUGIN_APP_HOME instead" + PLUGIN_APP_HOME="${HELM_HOME}" fi -# ensure dir(s) -# rm -rf "${HELM_HOME}" -if [[ ! -d "${HELM_HOME}" ]]; then - mkdir -p "${HELM_HOME}" +if [[ "${PLUGIN_APP_HOME}" ]]; then + PLUGIN_APP_HOME=$(variable_expansion "${PLUGIN_APP_HOME}") +else + PLUGIN_APP_HOME="/tmp/__${SCRIPT_NAME}__/apps/${ARGOCD_APP_NAME}" fi +# HELM_HOME is kept in sync for init scripts that still reference it +export PLUGIN_APP_HOME +export HELM_HOME="${PLUGIN_APP_HOME}" + +mkdir -p "${PLUGIN_APP_HOME}" + export HELMFILE_HELMFILE_HELMFILED="${PWD}/.__${SCRIPT_NAME}__helmfile.d" phase=$1 @@ -258,8 +265,50 @@ else helmfile="$(which helmfile)" fi -echoerr "helm version $(${helm} version --short)" -echoerr "$(${helmfile} --version)" +# detect and validate tool versions, only needed for phases that run them +check_tool_versions() { + local helm_version helmfile_version + + if ! helm_version=$(${helm} version --template '{{.Version}}' 2>/dev/null); then + echoerr "failed to run '${helm} version', helm >= 3 is required" + exit 1 + fi + echoerr "helm version ${helm_version}" + + if [[ ! "${helm_version}" =~ ^v([0-9]+)\.([0-9]+)\. ]]; then + echoerr "unable to parse helm version '${helm_version}', helm >= 3 is required" + exit 1 + fi + helm_major_version="${BASH_REMATCH[1]}" + helm_minor_version="${BASH_REMATCH[2]}" + + if [[ "${helm_major_version}" -lt 3 ]]; then + echoerr "helm ${helm_version} is not supported, helm >= 3 is required" + exit 1 + fi + + if ! helmfile_version=$(${helmfile} --version 2>/dev/null); then + echoerr "failed to run '${helmfile} --version', helmfile >= 1 is required" + exit 1 + fi + echoerr "${helmfile_version}" + + if [[ ! "${helmfile_version}" =~ version\ v?([0-9]+)\. ]]; then + echoerr "unable to parse helmfile version '${helmfile_version}', helmfile >= 1 is required" + exit 1 + fi + + if [[ "${BASH_REMATCH[1]}" -lt 1 ]]; then + echoerr "${helmfile_version} is not supported, helmfile >= 1 is required" + exit 1 + fi +} + +case "${phase}" in + "init" | "generate") + check_tool_versions + ;; +esac helmfile="${helmfile} --helm-binary ${helm} --no-color --allow-no-matching-release" @@ -280,23 +329,12 @@ fi # TODO: parse helmfile here to detect the operative -f or --file -# these should work for both v2 and v3 -helm_full_version=$(${helm} version --short | cut -d " " -f2) -helm_major_version=$(echo "${helm_full_version%+*}" | cut -d "." -f1 | sed 's/[^0-9]//g') -helm_minor_version=$(echo "${helm_full_version%+*}" | cut -d "." -f2 | sed 's/[^0-9]//g') -helm_patch_version=$(echo "${helm_full_version%+*}" | cut -d "." -f3 | sed 's/[^0-9]//g') - -if [[ ${helm_major_version} -eq 3 ]]; then - # https://github.com/roboll/helmfile/issues/1015#issuecomment-563488649 - export HELMFILE_HELM3="1" -fi - # fix scenarios where KUBE_VERSION is improperly set with trailing + # https://github.com/argoproj/argo-cd/issues/8249 KUBE_VERSION=$(echo "${KUBE_VERSION}" | sed 's/[^0-9\.]*//g') # set home variable to ensure apps do NOT overlap settings/repos/etc -export HOME="${HELM_HOME}" +export HOME="${PLUGIN_APP_HOME}" echoerr "starting ${phase}" @@ -315,9 +353,10 @@ case $phase in count=0 - [[ -f "helmfile.yaml" ]] && ((count++)) - [[ -f "helmfile.yaml.gotmpl" ]] && ((count++)) - [[ -d "helmfile.d" ]] && ((count++)) + # NOTE: ((count++)) returns 1 when count is 0 and aborts under set -e + [[ -f "helmfile.yaml" ]] && count=$((count + 1)) + [[ -f "helmfile.yaml.gotmpl" ]] && count=$((count + 1)) + [[ -d "helmfile.d" ]] && count=$((count + 1)) if [[ $count -gt 1 ]]; then echoerr "You can have either helmfile.yaml, helmfile.yaml.gotmpl, or helmfile.d, but not more than one" @@ -347,10 +386,6 @@ case $phase in echo "${HELMFILE_HELMFILE}" >"${HELMFILE_HELMFILE_HELMFILED}/ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ__argocd__helmfile__.yaml" fi - if [[ ${helm_major_version} -eq 2 ]]; then - ${helm} init --client-only - fi - if [ ! -z "${HELMFILE_INIT_SCRIPT_FILE}" ]; then HELMFILE_INIT_SCRIPT_FILE=$(realpath "${HELMFILE_INIT_SCRIPT_FILE}") bash "${HELMFILE_INIT_SCRIPT_FILE}" @@ -383,21 +418,11 @@ case $phase in # The name of a release can be used as a label. --selector name=myrelease # --allow-no-matching-release Do not exit with an error code if the provided selector has no matching releases. - # apply custom args passed from helmfile down to helm - # https://github.com/helm/helm/pull/7054/files (--is-upgrade added to v3) + # apply custom args passed from helmfile down to helm template # --args --kube-version=1.16,--api-versions=foo # - # v2 - # --is-upgrade set .Release.IsUpgrade instead of .Release.IsInstall - # --kube-version string kubernetes version used as Capabilities.KubeVersion.Major/Minor (default "1.9") - # v3 + # --kube-version string Kubernetes version used for Capabilities.KubeVersion # -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions - # --no-hooks prevent hooks from running during install - # --skip-crds if set, no CRDs will be installed. By default, CRDs are installed if not already present - - if [[ ${helm_major_version} -eq 2 && "${KUBE_VERSION}" ]]; then - INTERNAL_HELM_TEMPLATE_OPTIONS="${INTERNAL_HELM_TEMPLATE_OPTIONS} --kube-version=${KUBE_VERSION}" - fi # support added for --kube-version in 3.6 # https://github.com/helm/helm/pull/9040 @@ -571,4 +596,4 @@ EOF ;; esac -echoerr "finishing ${phase}" +echoerr "finishing ${phase}" \ No newline at end of file diff --git a/test/capabilities.bats b/test/capabilities.bats index 4c4145f..4271698 100644 --- a/test/capabilities.bats +++ b/test/capabilities.bats @@ -11,17 +11,10 @@ setup() { skip_if_helm4_bug() { if [[ "${HELM_MAJOR}" -ge 4 ]]; then - true "known bug: capabilities flags are only passed for Helm 3 (fix planned)" + skip "known bug: capabilities flags are only passed for Helm 3 (fix planned)" fi } -@test "capabilities: helm and helmfile versions are logged to stderr" { - run_plugin init - assert_success - assert_regex "${stderr}" "helm version v[0-9]+\." - assert_regex "${stderr}" "helmfile version" -} - @test "capabilities: renders without KUBE_VERSION and KUBE_API_VERSIONS" { plugin_init run_plugin generate @@ -79,12 +72,12 @@ kube_version_case() { } @test "capabilities: KUBE_VERSION 1.29.0+k3s1 -> v1.29.0" { - true "known bug: sanitizer produces 1.29.031 (fix planned)" + skip "known bug: sanitizer produces 1.29.031 (fix planned)" kube_version_case "1.29.0+k3s1" "v1.29.0" } @test "capabilities: KUBE_VERSION 1.29.0-eks-5e0fdde -> v1.29.0" { - true "known bug: sanitizer produces 1.29.050 (fix planned)" + skip "known bug: sanitizer produces 1.29.050 (fix planned)" kube_version_case "1.29.0-eks-5e0fdde" "v1.29.0" } diff --git a/test/discover.bats b/test/discover.bats index e91004e..75e9cbe 100644 --- a/test/discover.bats +++ b/test/discover.bats @@ -60,14 +60,14 @@ setup() { } @test "discover: option merely containing '-f' does not match" { - true "known bug: *-f* glob matches e.g. --kube-context=prod-frontend (fix planned)" + skip "known bug: *-f* glob matches e.g. --kube-context=prod-frontend (fix planned)" export ARGOCD_ENV_HELMFILE_GLOBAL_OPTIONS="--kube-context=prod-frontend" run_plugin discover assert_failure } @test "discover: diagnostics do not go to stdout on no match" { - true "known issue: 'no valid helmfile content discovered' is printed to stdout (fix planned)" + skip "known issue: 'no valid helmfile content discovered' is printed to stdout (fix planned)" run_plugin discover assert_failure assert_output "" diff --git a/test/docker-smoke.sh b/test/docker-smoke.sh index 4ed1219..ca88d90 100644 --- a/test/docker-smoke.sh +++ b/test/docker-smoke.sh @@ -17,7 +17,7 @@ releases: YAML export ARGOCD_APP_NAME="smoke" ARGOCD_APP_NAMESPACE="smoke-ns" ARGOCD_APP_REVISION="smoke" -export HELM_HOME="${work}/home" +export PLUGIN_APP_HOME="${work}/home" plugin="argocd-helmfile-plugin.sh" command -v "${plugin}" >/dev/null || fail "${plugin} not on PATH" diff --git a/test/env.bats b/test/env.bats index d22a6cd..0196532 100644 --- a/test/env.bats +++ b/test/env.bats @@ -6,20 +6,6 @@ setup() { common_setup } -# Create a wrapper for a real binary that records each call. -# Usage: make_wrapper -make_wrapper() { - local tool="$1" dir="$2" real - real="$(command -v "${tool}")" - mkdir -p "${dir}" - cat >"${dir}/${tool}" <>"${BATS_TEST_TMPDIR}/${tool}-calls.log" -exec "${real}" "\$@" -SH - chmod +x "${dir}/${tool}" -} - @test "env: ARGOCD_ENV_ variables are available unprefixed to helmfile" { cat >helmfile.yaml.gotmpl <helmfile.yaml.gotmpl <init.sh <<'SH' +echo "${HELM_HOME}" >homes.txt +echo "${PLUGIN_APP_HOME}" >>homes.txt +echo "${HOME}" >>homes.txt +SH + export ARGOCD_ENV_HELMFILE_INIT_SCRIPT_FILE="init.sh" + plugin_init + run cat homes.txt + assert_line --index 0 "${PLUGIN_APP_HOME}" + assert_line --index 1 "${PLUGIN_APP_HOME}" + assert_line --index 2 "${PLUGIN_APP_HOME}" } -@test "env: default HELM_HOME is per application" { +@test "env: default PLUGIN_APP_HOME is per application" { write_helmfile helmfile.yaml - unset HELM_HOME + unset PLUGIN_APP_HOME export ARGOCD_APP_NAME="bats-$$-${BATS_TEST_NUMBER}" local expected="/tmp/__argocd-helmfile-plugin.sh__/apps/${ARGOCD_APP_NAME}" plugin_init @@ -99,8 +129,8 @@ YAML @test "env: HELM_BINARY is used for helm calls" { write_helmfile helmfile.yaml - make_wrapper helm "${BATS_TEST_TMPDIR}/wrap" - export HELM_BINARY="${BATS_TEST_TMPDIR}/wrap/helm" + HELM_BINARY="$(make_call_logger helm)" + export HELM_BINARY plugin_init run_plugin generate assert_success @@ -110,8 +140,8 @@ YAML @test "env: HELMFILE_BINARY is used for helmfile calls" { write_helmfile helmfile.yaml - make_wrapper helmfile "${BATS_TEST_TMPDIR}/wrap" - export HELMFILE_BINARY="${BATS_TEST_TMPDIR}/wrap/helmfile" + HELMFILE_BINARY="$(make_call_logger helmfile)" + export HELMFILE_BINARY plugin_init run_plugin generate assert_success @@ -121,7 +151,7 @@ YAML @test "env: PATH from ARGOCD_ENV_ is variable-expanded" { write_helmfile helmfile.yaml - make_wrapper helm "${BATS_TEST_TMPDIR}/wrap" + make_call_logger helm >/dev/null export PROBE_WRAP="${BATS_TEST_TMPDIR}/wrap" export ARGOCD_ENV_PATH="\${PROBE_WRAP}:${PATH}" plugin_init diff --git a/test/helpers.bash b/test/helpers.bash index 85d2891..b3d4459 100644 --- a/test/helpers.bash +++ b/test/helpers.bash @@ -4,7 +4,7 @@ # Shared setup for all bats tests. # # Every test gets its own work directory (a fake Argo CD source checkout) and -# its own HELM_HOME, so tests never share caches, repos or helmfile state. +# its own PLUGIN_APP_HOME, so tests never share caches, repos or helmfile state. bats_require_minimum_version 1.5.0 @@ -33,7 +33,8 @@ common_setup() { export ARGOCD_APP_SOURCE_TARGET_REVISION="main" # Isolate helm/helmfile state per test. - export HELM_HOME="${BATS_TEST_TMPDIR}/home" + unset HELM_HOME + export PLUGIN_APP_HOME="${BATS_TEST_TMPDIR}/home" export HELM_CACHE_HOME="${BATS_TEST_TMPDIR}/helm/cache" export HELM_CONFIG_HOME="${BATS_TEST_TMPDIR}/helm/config" export HELM_DATA_HOME="${BATS_TEST_TMPDIR}/helm/data" @@ -76,6 +77,42 @@ plugin_init() { assert_success } +# Create a fake binary that answers version queries with fixed output and +# passes everything else to the real binary. Prints the path of the fake. +# Usage: make_fake_version +make_fake_version() { + local tool="$1" version_output="$2" real dir + real="$(command -v "${tool}")" + dir="${BATS_TEST_TMPDIR}/fake-${tool}" + mkdir -p "${dir}" + cat >"${dir}/${tool}" <-calls.log. Prints the path of the wrapper. +# Usage: make_call_logger +make_call_logger() { + local tool="$1" real dir + real="$(command -v "${tool}")" + dir="${BATS_TEST_TMPDIR}/wrap" + mkdir -p "${dir}" + cat >"${dir}/${tool}" <>"${BATS_TEST_TMPDIR}/${tool}-calls.log" +exec "${real}" "\$@" +SH + chmod +x "${dir}/${tool}" + echo "${dir}/${tool}" +} + # Print the value of a key from the rendered probe ConfigMap(s). # Usage: probe_value [release-name] probe_value() { diff --git a/test/init.bats b/test/init.bats index ff5388d..a0cb88b 100644 --- a/test/init.bats +++ b/test/init.bats @@ -6,12 +6,6 @@ setup() { common_setup } -# The INCLUDE branch counts sources with `[[ -f x ]] && ((count++))`. -# ((0++)) returns 1, which makes `set -e` abort init silently as soon as any -# helmfile source exists, so INCLUDE is currently unusable. -skip_include_bug() { - true "known bug: INCLUDE aborts on ((count++)) under set -e (fix planned)" -} @test "init: succeeds for a plain helmfile.yaml" { write_helmfile helmfile.yaml @@ -118,7 +112,6 @@ SH } @test "init: HELMFILE_HELMFILE with INCLUDE renders repo helmfile.yaml and param" { - skip_include_bug write_helmfile helmfile.yaml from-repo write_helmfile "${BATS_TEST_TMPDIR}/injected.yaml" from-param ARGOCD_ENV_HELMFILE_HELMFILE="$(cat "${BATS_TEST_TMPDIR}/injected.yaml")" @@ -134,7 +127,6 @@ SH } @test "init: INCLUDE copies helmfile.yaml.gotmpl" { - skip_include_bug write_helmfile helmfile.yaml.gotmpl from-repo export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" @@ -143,7 +135,6 @@ SH } @test "init: INCLUDE copies the contents of helmfile.d" { - skip_include_bug write_helmfile helmfile.d/10-one.yaml one write_helmfile helmfile.d/20-two.yaml two export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" @@ -159,7 +150,6 @@ SH } @test "init: INCLUDE warns when more than one helmfile source exists" { - skip_include_bug # Current behaviour is a warning only; planned to become a hard failure. write_helmfile helmfile.yaml write_helmfile helmfile.d/10-one.yaml one @@ -178,7 +168,6 @@ SH } @test "init: re-running init removes stale injected files" { - skip_include_bug write_helmfile helmfile.yaml export ARGOCD_ENV_HELMFILE_HELMFILE="releases: []" export ARGOCD_ENV_HELMFILE_HELMFILE_STRATEGY="INCLUDE" @@ -190,7 +179,7 @@ SH } @test "init: INCLUDE keeps relative chart paths working" { - true "known bug: files are copied into ${INJECTED_DIR}, relative paths break (fix planned)" + skip "known bug: files are copied into ${INJECTED_DIR}, relative paths break (fix planned)" cat >helmfile.yaml <<'YAML' releases: - name: probe @@ -205,7 +194,7 @@ YAML } @test "init: templated HELMFILE_HELMFILE is rendered" { - true "known bug: injected file is written as .yaml, helmfile v1 only templates .gotmpl (fix planned)" + skip "known bug: injected file is written as .yaml, helmfile v1 only templates .gotmpl (fix planned)" export ARGOCD_ENV_HELMFILE_HELMFILE="releases: - name: {{ \"probe\" }} chart: ${WORK}/chart" diff --git a/test/versions.bats b/test/versions.bats new file mode 100644 index 0000000..26563f5 --- /dev/null +++ b/test/versions.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Tool version detection: Helm 2 is not supported, Helm >= 3 and +# helmfile >= 1 are required. Versions are only checked in init/generate. + +setup() { + load helpers + common_setup + write_helmfile helmfile.yaml +} + +@test "versions: helm and helmfile versions are logged to stderr" { + run_plugin init + assert_success + assert_regex "${stderr}" "helm version v[0-9]+\.[0-9]+\.[0-9]+" + assert_regex "${stderr}" "helmfile version v?[0-9]+\.[0-9]+" + refute_output --partial "version" +} + +@test "versions: current helm is accepted in init and generate" { + plugin_init + run_plugin generate + assert_success +} + +@test "versions: helm 2 is rejected" { + HELM_BINARY="$(make_fake_version helm "v2.17.0")" + export HELM_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "helm v2.17.0 is not supported, helm >= 3 is required" +} + +@test "versions: helm 2 is rejected in generate" { + HELM_BINARY="$(make_fake_version helm "v2.17.0")" + export HELM_BINARY + run_plugin generate + assert_failure + assert_output "" + assert_regex "${stderr}" "helm >= 3 is required" +} + +@test "versions: helm 3 and 4 versions are accepted" { + local v + for v in v3.6.0 v3.19.4 v4.0.0 v4.2.3; do + HELM_BINARY="$(make_fake_version helm "${v}")" + export HELM_BINARY + run_plugin init + assert_success + assert_regex "${stderr}" "helm version ${v}" + done +} + +@test "versions: unparsable helm version is rejected" { + HELM_BINARY="$(make_fake_version helm "")" + export HELM_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "unable to parse helm version" +} + +@test "versions: failing helm binary is rejected" { + export HELM_BINARY="${BATS_TEST_TMPDIR}/does-not-exist/helm" + run_plugin init + assert_failure + assert_regex "${stderr}" "failed to run .* version" +} + +@test "versions: helmfile 0.x is rejected" { + HELMFILE_BINARY="$(make_fake_version helmfile "helmfile version v0.171.0")" + export HELMFILE_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "helmfile >= 1 is required" +} + +@test "versions: helmfile 1.x is accepted with and without v prefix" { + local v + for v in "helmfile version 1.5.2" "helmfile version v1.0.0"; do + HELMFILE_BINARY="$(make_fake_version helmfile "${v}")" + export HELMFILE_BINARY + run_plugin init + assert_success + done +} + +@test "versions: unparsable helmfile version is rejected" { + HELMFILE_BINARY="$(make_fake_version helmfile "something else")" + export HELMFILE_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "unable to parse helmfile version" +} + +@test "versions: discover and parameters do not run helm or helmfile" { + HELM_BINARY="$(make_call_logger helm)" + HELMFILE_BINARY="$(make_call_logger helmfile)" + export HELM_BINARY HELMFILE_BINARY + run_plugin discover + assert_success + run_plugin parameters + assert_success + refute [ -e "${BATS_TEST_TMPDIR}/helm-calls.log" ] + refute [ -e "${BATS_TEST_TMPDIR}/helmfile-calls.log" ] +} + +@test "versions: discover and parameters work even with helm 2" { + HELM_BINARY="$(make_fake_version helm "v2.17.0")" + export HELM_BINARY + run_plugin discover + assert_success + run_plugin parameters + assert_success +} + +@test "versions: plugin queries the helm version once per phase" { + HELM_BINARY="$(make_call_logger helm)" + export HELM_BINARY + plugin_init + # helmfile runs its own "helm version --short"; only count the plugin's query + run grep -c '^helm version --template' "${BATS_TEST_TMPDIR}/helm-calls.log" + assert_output "1" +} + +@test "versions: helm init is never called" { + HELM_BINARY="$(make_call_logger helm)" + export HELM_BINARY + plugin_init + run grep '^helm init' "${BATS_TEST_TMPDIR}/helm-calls.log" + assert_failure +} + +@test "versions: HELMFILE_HELM3 is not exported" { + cat >helmfile.yaml.gotmpl < Date: Thu, 17 Sep 2026 14:41:15 +0300 Subject: [PATCH 04/14] helm 4 compatability --- .github/workflows/test.yml | 7 ++- CHANGELOG.md | 4 ++ README.md | 27 ++++++++-- docker/Dockerfile | 6 ++- src/argocd-helmfile-plugin.sh | 95 ++++++++++++++++++++++------------- test/capabilities.bats | 95 ++++++++++++++++++++++++++++------- test/versions.bats | 49 ++++++++++++++++-- 7 files changed, 221 insertions(+), 62 deletions(-) mode change 100755 => 100644 src/argocd-helmfile-plugin.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92f123a..131659c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,9 +30,14 @@ jobs: # Versions pinned in docker/Dockerfile (what the image ships). - name: image versions make_args: "" + # Oldest helmfile that supports Helm 4. + - name: minimum helm 4 + make_args: HELM_VERSION=v4.0.0 HELMFILE_VERSION=1.2.0 # Helm 3 is still usable via HELM_BINARY until its EOL (2026-11-11). - name: helm v3 make_args: HELM_VERSION=v3.19.4 + - name: helm v3, minimum helmfile + make_args: HELM_VERSION=v3.19.4 HELMFILE_VERSION=1.0.0 steps: - uses: actions/checkout@v4 @@ -54,4 +59,4 @@ jobs: - uses: actions/checkout@v4 - name: Build image and run smoke test - run: make test-docker \ No newline at end of file + run: make test-docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ccb863..727e69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,15 @@ and this project adheres to Semantic Versioning (https://semver.org/). - Helm version is detected with `helm version --template '{{.Version}}'` instead of parsing `--short` output. - `init` and `generate` fail with a clear error for Helm < 3, helmfile < 1 or unparsable versions. - `discover` and `parameters` no longer run `helm`/`helmfile`. +- Minimum versions: helm >= 3.6, helmfile >= 1 (>= 1.2 with Helm 4). +- `KUBE_VERSION` is passed with helmfile's `--kube-version` flag; `KUBE_API_VERSIONS` as one comma-separated `--api-versions`. `--args` is only passed when needed. ### Deprecated - `HELM_HOME`: use `PLUGIN_APP_HOME`. `HELM_HOME` is still accepted with a warning and is exported with the same value. ### Fixed +- Helm 4: `KUBE_VERSION` and `KUBE_API_VERSIONS` were ignored, so charts rendered with Helm's default capabilities instead of the destination cluster's. +- `KUBE_VERSION` with vendor suffixes was corrupted (`1.29.0+k3s1` became `1.29.031`); it is now normalized, invalid values are ignored with a warning. - `HELMFILE_HELMFILE_STRATEGY=INCLUDE` aborted `init` silently when any helmfile source existed (`((count++))` under `set -e`). ### Added diff --git a/README.md b/README.md index 62b3e03..06eced4 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,33 @@ Consider these implications for your environment and act appropriately. # Requirements -- `helm` >= 3 (Helm 4 recommended; Helm 2 is not supported) -- `helmfile` >= 1 +- `helm` >= 3.6 (Helm 4 recommended; Helm 2 is not supported) +- `helmfile` >= 1, and >= 1.2 when used with Helm 4 The plugin checks both versions in the `init` and `generate` phases and fails with a clear message if they are not supported. +## Kubernetes capabilities + +Argo CD passes the destination cluster's version and APIs as `KUBE_VERSION` and +`KUBE_API_VERSIONS`. The plugin passes them to `helm template`, so charts can use +`.Capabilities.KubeVersion` and `.Capabilities.APIVersions.Has`: + +- `KUBE_VERSION` is normalized first: a leading `v` and anything after the first + `+` or `-` are removed (`v1.29.0+k3s1` → `1.29.0`, `1.29.0-eks-5e0fdde` → `1.29.0`). + Values that are still not `.[.]` are ignored with a warning. +- `KUBE_API_VERSIONS` is passed as `--api-versions`. + +## Helm 4 notes + +- Post-renderers are Helm plugins in Helm 4. `--post-renderer` in + `HELM_TEMPLATE_OPTIONS` or `postRenderer:` in helmfile must name an installed + plugin, not an executable path. +- `helm registry login` takes a domain name only (no path). Check + `HELMFILE_INIT_SCRIPT_FILE` scripts that log in to OCI registries. +- Plugins installed via `HELMFILE_INIT_SCRIPT_FILE` need `--verify=false` unless + they are signed and their key is available. + # Installation - https://argo-cd.readthedocs.io/en/stable/operator-manual/config-management-plugins/ @@ -163,7 +184,7 @@ prevents the plugin(s) from being downloaded over and over each run. - mountPath: /helm/data name: helm-data-home - [[ ! -d "${HELM_DATA_HOME}/plugins/helm-secrets" ]] && /custom-tools/helm-v3 plugin install https://github.com/jkroepke/helm-secrets --version ${HELM_SECRETS_VERSION} + [[ ! -d "${HELM_DATA_HOME}/plugins/helm-secrets" ]] && /custom-tools/helm plugin install https://github.com/jkroepke/helm-secrets --version ${HELM_SECRETS_VERSION} --verify=false chown -R 999:999 "${HELM_DATA_HOME}" # lastly, in your app definition diff --git a/docker/Dockerfile b/docker/Dockerfile index 3617d92..d2ddbbb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -31,6 +31,7 @@ RUN groupadd -g $ARGOCD_USER_ID argocd && \ # Binary versions # https://github.com/helm/helm/releases # Supported Kubernetes Versions 1.35.x - 1.32.x (https://helm.sh/docs/topics/version_skew/) +# Plugin requires helm >= 3.6; with helm 4, helmfile >= 1.2 ARG HELM_VERSION="v4.2.3" # https://github.com/helmfile/helmfile/releases ARG HELMFILE_VERSION="1.5.2" @@ -94,7 +95,10 @@ ARG HELM_GIT_VERSION="1.5.2" # https://github.com/jkroepke/helm-secrets/releases ARG HELM_SECRETS_VERSION="4.7.6" -# TODO... HELM 4+ WARNING: Skipping plugin signature verification. Use --verify=false to skip verification +# Helm 4 verifies plugin signatures by default: +# - helm-diff and helm-git are installed from git, which cannot be verified +# - helm-secrets tarballs are signed, but need the publisher key in a gpg keyring +# so verification is skipped explicitly; versions are pinned above. RUN \ helm plugin install https://github.com/databus23/helm-diff --version ${HELM_DIFF_VERSION} --verify=false && \ helm plugin install https://github.com/aslafy-z/helm-git --version ${HELM_GIT_VERSION} --verify=false && \ diff --git a/src/argocd-helmfile-plugin.sh b/src/argocd-helmfile-plugin.sh old mode 100755 new mode 100644 index 72508e1..1d880d4 --- a/src/argocd-helmfile-plugin.sh +++ b/src/argocd-helmfile-plugin.sh @@ -46,8 +46,8 @@ # init is called before every manifest generation # it can be used to download dependencies, etc, etc -# does not have "v" in front -# KUBE_VERSION="." +# set by Argo CD from the destination cluster, see normalize_kube_version() +# KUBE_VERSION=".[.]" (may carry a "v" prefix or vendor suffix) # KUBE_API_VERSIONS="v1,apps/v1,..." # error/exit on any failure @@ -105,6 +105,19 @@ truthy_test() { return 1 } +# Argo CD passes the cluster version as reported by the API server, which may +# carry a leading "v", a trailing "+" or a vendor suffix: +# 1.29, 1.29+, v1.29.3, 1.29.0+k3s1, 1.29.0-eks-5e0fdde +# https://github.com/argoproj/argo-cd/issues/8249 +# Prints ".[.]", or nothing if the value is not usable. +normalize_kube_version() { + local version="${1#v}" + version="${version%%[+-]*}" + if [[ "${version}" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + echo "${version}" + fi +} + cache_set_time() { local key="${1}" touch "${HOME}/${key}" @@ -269,21 +282,24 @@ fi check_tool_versions() { local helm_version helmfile_version + local helm_major helm_minor helmfile_major helmfile_minor + if ! helm_version=$(${helm} version --template '{{.Version}}' 2>/dev/null); then - echoerr "failed to run '${helm} version', helm >= 3 is required" + echoerr "failed to run '${helm} version', helm >= 3.6 is required" exit 1 fi echoerr "helm version ${helm_version}" if [[ ! "${helm_version}" =~ ^v([0-9]+)\.([0-9]+)\. ]]; then - echoerr "unable to parse helm version '${helm_version}', helm >= 3 is required" + echoerr "unable to parse helm version '${helm_version}', helm >= 3.6 is required" exit 1 fi - helm_major_version="${BASH_REMATCH[1]}" - helm_minor_version="${BASH_REMATCH[2]}" + helm_major="${BASH_REMATCH[1]}" + helm_minor="${BASH_REMATCH[2]}" - if [[ "${helm_major_version}" -lt 3 ]]; then - echoerr "helm ${helm_version} is not supported, helm >= 3 is required" + # 3.6 added "helm template --kube-version" + if ((helm_major < 3 || (helm_major == 3 && helm_minor < 6))); then + echoerr "helm ${helm_version} is not supported, helm >= 3.6 is required" exit 1 fi @@ -293,15 +309,24 @@ check_tool_versions() { fi echoerr "${helmfile_version}" - if [[ ! "${helmfile_version}" =~ version\ v?([0-9]+)\. ]]; then + if [[ ! "${helmfile_version}" =~ version\ v?([0-9]+)\.([0-9]+)\. ]]; then echoerr "unable to parse helmfile version '${helmfile_version}', helmfile >= 1 is required" exit 1 fi + helmfile_major="${BASH_REMATCH[1]}" + helmfile_minor="${BASH_REMATCH[2]}" - if [[ "${BASH_REMATCH[1]}" -lt 1 ]]; then + if ((helmfile_major < 1)); then echoerr "${helmfile_version} is not supported, helmfile >= 1 is required" exit 1 fi + + # older helmfile runs "helm version --client", which Helm 4 removed + # https://github.com/helmfile/helmfile/issues/2268 + if ((helm_major >= 4 && helmfile_major == 1 && helmfile_minor < 2)); then + echoerr "${helmfile_version} does not support helm ${helm_version}, helmfile >= 1.2 is required for helm 4" + exit 1 + fi } case "${phase}" in @@ -329,10 +354,6 @@ fi # TODO: parse helmfile here to detect the operative -f or --file -# fix scenarios where KUBE_VERSION is improperly set with trailing + -# https://github.com/argoproj/argo-cd/issues/8249 -KUBE_VERSION=$(echo "${KUBE_VERSION}" | sed 's/[^0-9\.]*//g') - # set home variable to ensure apps do NOT overlap settings/repos/etc export HOME="${PLUGIN_APP_HOME}" @@ -406,9 +427,6 @@ case $phase in ;; "generate") - INTERNAL_HELMFILE_TEMPLATE_OPTIONS= - INTERNAL_HELM_TEMPLATE_OPTIONS= - # helmfile args # --environment default, -e default specify the environment name. defaults to default # --namespace value, -n value Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }} @@ -418,31 +436,38 @@ case $phase in # The name of a release can be used as a label. --selector name=myrelease # --allow-no-matching-release Do not exit with an error code if the provided selector has no matching releases. - # apply custom args passed from helmfile down to helm template - # --args --kube-version=1.16,--api-versions=foo - # - # --kube-version string Kubernetes version used for Capabilities.KubeVersion - # -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions + # options for "helmfile template" + helmfile_template_args=(--skip-deps) + # options passed by helmfile to "helm template" (--args, split on spaces) + helm_template_args=() + + # Capabilities.KubeVersion + kube_version=$(normalize_kube_version "${KUBE_VERSION}") + if [[ "${kube_version}" ]]; then + helmfile_template_args+=(--kube-version "${kube_version}") + elif [[ "${KUBE_VERSION}" ]]; then + echoerr "WARNING: ignoring invalid KUBE_VERSION '${KUBE_VERSION}'" + fi + + # Capabilities.APIVersions, helm accepts a comma-separated list + if [[ "${KUBE_API_VERSIONS}" ]]; then + helm_template_args+=("--api-versions=${KUBE_API_VERSIONS}") + fi - # support added for --kube-version in 3.6 - # https://github.com/helm/helm/pull/9040 - if [[ ${helm_major_version} -eq 3 && ${helm_minor_version} -ge 6 && "${KUBE_VERSION}" ]]; then - INTERNAL_HELM_TEMPLATE_OPTIONS="${INTERNAL_HELM_TEMPLATE_OPTIONS} --kube-version=${KUBE_VERSION}" + if [[ "${HELM_TEMPLATE_OPTIONS}" ]]; then + helm_template_args+=("${HELM_TEMPLATE_OPTIONS}") fi - if [[ ${helm_major_version} -eq 3 && "${KUBE_API_VERSIONS}" ]]; then - INTERNAL_HELM_API_VERSIONS="" - for v in ${KUBE_API_VERSIONS//,/ }; do - INTERNAL_HELM_API_VERSIONS="${INTERNAL_HELM_API_VERSIONS} --api-versions=$v" - done - INTERNAL_HELM_TEMPLATE_OPTIONS="${INTERNAL_HELM_TEMPLATE_OPTIONS} ${INTERNAL_HELM_API_VERSIONS}" + if [[ ${#helm_template_args[@]} -gt 0 ]]; then + helmfile_template_args+=(--args "${helm_template_args[*]}") fi # TODO: support post process pipeline here + # HELMFILE_TEMPLATE_OPTIONS is intentionally unquoted (multiple options) + # shellcheck disable=SC2086 ${helmfile} \ template \ - --skip-deps ${INTERNAL_HELMFILE_TEMPLATE_OPTIONS} \ - --args "${INTERNAL_HELM_TEMPLATE_OPTIONS} ${HELM_TEMPLATE_OPTIONS}" \ + "${helmfile_template_args[@]}" \ ${HELMFILE_TEMPLATE_OPTIONS} ;; @@ -596,4 +621,4 @@ EOF ;; esac -echoerr "finishing ${phase}" \ No newline at end of file +echoerr "finishing ${phase}" diff --git a/test/capabilities.bats b/test/capabilities.bats index 4271698..93aeb42 100644 --- a/test/capabilities.bats +++ b/test/capabilities.bats @@ -6,13 +6,6 @@ setup() { load helpers common_setup write_helmfile helmfile.yaml - HELM_MAJOR="$(helm version --template '{{.Version}}' | sed -E 's/^v([0-9]+).*/\1/')" -} - -skip_if_helm4_bug() { - if [[ "${HELM_MAJOR}" -ge 4 ]]; then - skip "known bug: capabilities flags are only passed for Helm 3 (fix planned)" - fi } @test "capabilities: renders without KUBE_VERSION and KUBE_API_VERSIONS" { @@ -20,10 +13,10 @@ skip_if_helm4_bug() { run_plugin generate assert_success assert_equal "$(probe_value hasProbeApi)" "false" + refute_regex "${stderr}" "WARNING" } @test "capabilities: KUBE_VERSION is passed to helm" { - skip_if_helm4_bug export KUBE_VERSION="1.29" plugin_init run_plugin generate @@ -32,8 +25,17 @@ skip_if_helm4_bug() { assert_equal "$(probe_value kubeVersion)" "v1.29" } +@test "capabilities: KUBE_VERSION is passed with the native helmfile flag" { + HELMFILE_BINARY="$(make_call_logger helmfile)" + export HELMFILE_BINARY KUBE_VERSION="1.29.3" + plugin_init + run_plugin generate + assert_success + run grep -- ' template --skip-deps --kube-version 1.29.3' "${BATS_TEST_TMPDIR}/helmfile-calls.log" + assert_success +} + @test "capabilities: KUBE_API_VERSIONS is passed to helm" { - skip_if_helm4_bug export KUBE_API_VERSIONS="v1,apps/v1,probe.example.com/v1" plugin_init run_plugin generate @@ -41,8 +43,15 @@ skip_if_helm4_bug() { assert_equal "$(probe_value hasProbeApi)" "true" } +@test "capabilities: single KUBE_API_VERSIONS entry is passed to helm" { + export KUBE_API_VERSIONS="probe.example.com/v1" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value hasProbeApi)" "true" +} + @test "capabilities: KUBE_API_VERSIONS without the probe API is honoured" { - skip_if_helm4_bug export KUBE_API_VERSIONS="v1,apps/v1" plugin_init run_plugin generate @@ -50,6 +59,39 @@ skip_if_helm4_bug() { assert_equal "$(probe_value hasProbeApi)" "false" } +@test "capabilities: KUBE_VERSION, KUBE_API_VERSIONS and HELM_TEMPLATE_OPTIONS together" { + export KUBE_VERSION="v1.30.2+k3s1" + export KUBE_API_VERSIONS="v1,probe.example.com/v1" + export ARGOCD_ENV_HELM_TEMPLATE_OPTIONS="--set marker=combined" + plugin_init + run_plugin generate + assert_success + assert_equal "$(probe_value kubeVersion)" "v1.30.2" + assert_equal "$(probe_value hasProbeApi)" "true" + assert_equal "$(probe_value marker)" "combined" +} + +@test "capabilities: --args is not passed when there is nothing to pass" { + HELMFILE_BINARY="$(make_call_logger helmfile)" + export HELMFILE_BINARY + plugin_init + run_plugin generate + assert_success + run grep -- '--args' "${BATS_TEST_TMPDIR}/helmfile-calls.log" + assert_failure +} + +@test "capabilities: --args has no leading or double spaces" { + HELMFILE_BINARY="$(make_call_logger helmfile)" + export HELMFILE_BINARY KUBE_API_VERSIONS="v1" + export ARGOCD_ENV_HELM_TEMPLATE_OPTIONS="--set marker=x" + plugin_init + run_plugin generate + assert_success + run grep -- '--args --api-versions=v1 --set marker=x' "${BATS_TEST_TMPDIR}/helmfile-calls.log" + assert_success +} + # KUBE_VERSION normalization. Argo CD passes the cluster's version, which may # carry a leading "v" or vendor suffixes (argoproj/argo-cd#8249). kube_version_case() { @@ -59,29 +101,48 @@ kube_version_case() { run_plugin generate assert_success assert_equal "$(probe_value kubeVersion)" "${expected}" + refute_regex "${stderr}" "WARNING" } @test "capabilities: KUBE_VERSION 1.29.3 -> v1.29.3" { - skip_if_helm4_bug kube_version_case "1.29.3" "v1.29.3" } @test "capabilities: KUBE_VERSION v1.29.3 -> v1.29.3" { - skip_if_helm4_bug kube_version_case "v1.29.3" "v1.29.3" } +@test "capabilities: KUBE_VERSION 1.29+ -> v1.29" { + kube_version_case "1.29+" "v1.29" +} + @test "capabilities: KUBE_VERSION 1.29.0+k3s1 -> v1.29.0" { - skip "known bug: sanitizer produces 1.29.031 (fix planned)" kube_version_case "1.29.0+k3s1" "v1.29.0" } @test "capabilities: KUBE_VERSION 1.29.0-eks-5e0fdde -> v1.29.0" { - skip "known bug: sanitizer produces 1.29.050 (fix planned)" kube_version_case "1.29.0-eks-5e0fdde" "v1.29.0" } -@test "capabilities: KUBE_VERSION 1.29+ -> v1.29" { - skip_if_helm4_bug - kube_version_case "1.29+" "v1.29" +@test "capabilities: KUBE_VERSION v1.31.1-gke.1678000 -> v1.31.1" { + kube_version_case "v1.31.1-gke.1678000" "v1.31.1" +} + +@test "capabilities: KUBE_VERSION 1.30.4+rke2r1 -> v1.30.4" { + kube_version_case "1.30.4+rke2r1" "v1.30.4" +} + +@test "capabilities: invalid KUBE_VERSION is ignored with a warning" { + local v default + plugin_init + run_plugin generate + default="$(probe_value kubeVersion)" + + for v in "latest" "1" "v" "1.x" "one.two"; do + export KUBE_VERSION="${v}" + run_plugin generate + assert_success + assert_regex "${stderr}" "WARNING: ignoring invalid KUBE_VERSION '${v}'" + assert_equal "$(probe_value kubeVersion)" "${default}" + done } diff --git a/test/versions.bats b/test/versions.bats index 26563f5..259cc5e 100644 --- a/test/versions.bats +++ b/test/versions.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats -# Tool version detection: Helm 2 is not supported, Helm >= 3 and -# helmfile >= 1 are required. Versions are only checked in init/generate. +# Tool version detection: Helm 2 is not supported, Helm >= 3.6 and +# helmfile >= 1 (>= 1.2 with Helm 4) are required. Versions are only checked in init/generate. setup() { load helpers @@ -27,7 +27,7 @@ setup() { export HELM_BINARY run_plugin init assert_failure - assert_regex "${stderr}" "helm v2.17.0 is not supported, helm >= 3 is required" + assert_regex "${stderr}" "helm v2.17.0 is not supported, helm >= 3.6 is required" } @test "versions: helm 2 is rejected in generate" { @@ -36,11 +36,14 @@ setup() { run_plugin generate assert_failure assert_output "" - assert_regex "${stderr}" "helm >= 3 is required" + assert_regex "${stderr}" "helm >= 3.6 is required" } @test "versions: helm 3 and 4 versions are accepted" { local v + # fake a helm 4 compatible helmfile so the real helmfile version does not matter + HELMFILE_BINARY="$(make_fake_version helmfile "helmfile version 1.2.0")" + export HELMFILE_BINARY for v in v3.6.0 v3.19.4 v4.0.0 v4.2.3; do HELM_BINARY="$(make_fake_version helm "${v}")" export HELM_BINARY @@ -50,6 +53,42 @@ setup() { done } +@test "versions: helm older than 3.6 is rejected" { + local v + for v in v3.0.0 v3.5.4; do + HELM_BINARY="$(make_fake_version helm "${v}")" + export HELM_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "helm ${v} is not supported, helm >= 3.6 is required" + done +} + +@test "versions: helmfile older than 1.2 is rejected with helm 4" { + HELM_BINARY="$(make_fake_version helm "v4.0.0")" + HELMFILE_BINARY="$(make_fake_version helmfile "helmfile version 1.1.9")" + export HELM_BINARY HELMFILE_BINARY + run_plugin init + assert_failure + assert_regex "${stderr}" "helmfile >= 1.2 is required for helm 4" +} + +@test "versions: helmfile 1.2 is accepted with helm 4" { + HELM_BINARY="$(make_fake_version helm "v4.0.0")" + HELMFILE_BINARY="$(make_fake_version helmfile "helmfile version 1.2.0")" + export HELM_BINARY HELMFILE_BINARY + run_plugin init + assert_success +} + +@test "versions: helmfile older than 1.2 is accepted with helm 3" { + HELM_BINARY="$(make_fake_version helm "v3.19.4")" + HELMFILE_BINARY="$(make_fake_version helmfile "helmfile version 1.1.9")" + export HELM_BINARY HELMFILE_BINARY + run_plugin init + assert_success +} + @test "versions: unparsable helm version is rejected" { HELM_BINARY="$(make_fake_version helm "")" export HELM_BINARY @@ -75,7 +114,7 @@ setup() { @test "versions: helmfile 1.x is accepted with and without v prefix" { local v - for v in "helmfile version 1.5.2" "helmfile version v1.0.0"; do + for v in "helmfile version 1.5.2" "helmfile version v1.2.0"; do HELMFILE_BINARY="$(make_fake_version helmfile "${v}")" export HELMFILE_BINARY run_plugin init From 22977d1bc823f6cb3b06392e48f57ac15382135e Mon Sep 17 00:00:00 2001 From: s-skorobohatko Date: Thu, 17 Sep 2026 15:22:08 +0300 Subject: [PATCH 05/14] remove obsolete code --- CHANGELOG.md | 8 + Makefile | 9 +- src/argocd-helmfile-plugin.sh | 433 +++++++++++++--------------------- test/discover.bats | 13 +- test/env.bats | 69 ++++++ test/generate.bats | 42 ++++ 6 files changed, 298 insertions(+), 276 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 727e69d..e9e0aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to Semantic Versioning (https://semver.org/). ## [Unreleased] ### Removed +- Dead code: `if [[ true ]]` wrappers, duplicate `PATH` expansion, unused `print_env_vars`, unused `/tmp/__