diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5683dc20..dbbdee63 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,7 +57,7 @@ jobs: go-version-file: go.mod - name: check disk usage run: df -h - - uses: azure/setup-helm@v4.3.1 + - uses: azure/setup-helm@v5.0.0 with: version: ${{ matrix.helm-version }} - name: Build @@ -96,35 +96,35 @@ jobs: - helm-version: v3.18.6 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '' # In case you need to test some optional helmfile features, # enable it via extra-helmfile-flags below. - helm-version: v3.18.6 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '--enable-live-output' - helm-version: v3.20.1 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '' - helm-version: v3.20.1 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '--enable-live-output' # Helmfile now supports both Helm 3.x and Helm 4.x - helm-version: v4.1.3 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '' - helm-version: v4.1.3 kustomize-version: v5.8.0 plugin-secrets-version: 4.7.4 - plugin-diff-version: 3.15.1 + plugin-diff-version: 3.15.3 extra-helmfile-flags: '--enable-live-output' steps: - uses: actions/checkout@v6 diff --git a/Dockerfile b/Dockerfile index 1e7340e9..6709dc81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,7 +95,7 @@ RUN set -x && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.1 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.3 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff --git a/Dockerfile.debian-stable-slim b/Dockerfile.debian-stable-slim index fe542dbf..9d4f7de8 100644 --- a/Dockerfile.debian-stable-slim +++ b/Dockerfile.debian-stable-slim @@ -104,7 +104,7 @@ RUN set -x && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.1 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.3 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index ee21fc1c..757f4500 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -104,7 +104,7 @@ RUN set -x && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.1 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.3 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff --git a/cmd/apply.go b/cmd/apply.go index aa399737..c79e97cb 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -61,6 +61,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.BoolVar(&applyOptions.SuppressDiff, "suppress-diff", false, "suppress diff in the output. Usable in new installs") f.BoolVar(&applyOptions.Wait, "wait", false, `Override helmDefaults.wait setting "helm upgrade --install --wait"`) f.BoolVar(&applyOptions.WaitForJobs, "wait-for-jobs", false, `Override helmDefaults.waitForJobs setting "helm upgrade --install --wait-for-jobs"`) + f.IntVar(&applyOptions.Timeout, "timeout", 0, `Override helmDefaults.timeout in seconds for "helm upgrade --install --timeout" (default 0, which uses helmDefaults.timeout or helm's default if not set)`) f.BoolVar(&applyOptions.ReuseValues, "reuse-values", false, `Override helmDefaults.reuseValues "helm upgrade --install --reuse-values"`) f.BoolVar(&applyOptions.ResetValues, "reset-values", false, `Override helmDefaults.reuseValues "helm upgrade --install --reset-values"`) f.StringVar(&applyOptions.PostRenderer, "post-renderer", "", `pass --post-renderer to "helm template" or "helm upgrade --install"`) @@ -71,6 +72,7 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.StringVar(&applyOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'") f.IntVar(&applyOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`) f.BoolVar(&applyOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking") + f.StringVar(&applyOptions.Description, "description", "", `Set description for all releases. If set, overridesdescriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`) return cmd } diff --git a/cmd/sync.go b/cmd/sync.go index 376b1938..5d180103 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -47,7 +47,7 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.BoolVar(&syncOptions.SyncReleaseLabels, "sync-release-labels", false, "sync release labels to the target release") f.BoolVar(&syncOptions.Wait, "wait", false, `Override helmDefaults.wait setting "helm upgrade --install --wait"`) f.BoolVar(&syncOptions.WaitForJobs, "wait-for-jobs", false, `Override helmDefaults.waitForJobs setting "helm upgrade --install --wait-for-jobs"`) - f.IntVar(&syncOptions.Timeout, "timeout", 0, `Override helmDefaults.timeout setting "helm upgrade --install --timeout" (default 0, which means no timeout)`) + f.IntVar(&syncOptions.Timeout, "timeout", 0, `Override helmDefaults.timeout in seconds for "helm upgrade --install --timeout" (default 0, which uses helmDefaults.timeout or helm's default if not set)`) f.BoolVar(&syncOptions.ReuseValues, "reuse-values", false, `Override helmDefaults.reuseValues "helm upgrade --install --reuse-values"`) f.BoolVar(&syncOptions.ResetValues, "reset-values", false, `Override helmDefaults.reuseValues "helm upgrade --install --reset-values"`) f.StringVar(&syncOptions.PostRenderer, "post-renderer", "", `pass --post-renderer to "helm template" or "helm upgrade --install"`) @@ -57,6 +57,7 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command { f.StringVar(&syncOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'") f.IntVar(&syncOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`) f.BoolVar(&syncOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking") + f.StringVar(&syncOptions.Description, "description", "", `Set description for all releases. If set, overrides descriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`) return cmd } diff --git a/docs/index.md b/docs/index.md index 1aeb0a9d..218d9d10 100644 --- a/docs/index.md +++ b/docs/index.md @@ -593,6 +593,8 @@ Helmfile uses some OS environment variables to override default behaviour: * `HELMFILE_FILE_PATH` - specify the path to the helmfile.yaml file * `HELMFILE_INTERACTIVE` - enable interactive mode, expecting `true` lower case. The same as `--interactive` CLI flag * `HELMFILE_RENDER_YAML` - force helmfile.yaml to be rendered as a Go template regardless of file extension, expecting `true` lower case. Useful for migrating from v0 to v1 without renaming files to `.gotmpl` +* `HELMFILE_AWS_SDK_LOG_LEVEL` - configure AWS SDK logging level for vals library. Valid values: `off` (default, secure, case-insensitive), `minimal`, `standard`, `verbose`, or custom comma-separated values like `request,response`. See issue #2270 for details +* `HELMFILE_VALS_FAIL_ON_MISSING_KEY_IN_MAP` - enable strict mode for vals secret references. When set to `true` (or any value accepted by Go's `strconv.ParseBool` like `TRUE`, `1`), vals will fail when a referenced key does not exist in the secret map. Invalid values will cause an error when vals is initialized (when secret refs are first evaluated). Default is `false` (when unset or empty) for backward compatibility. See issue #1563 for details ## CLI Reference @@ -1588,7 +1590,7 @@ Hooks associated to `presync` events are triggered before each release is synced This is the ideal event to execute any commands that may mutate the cluster state as it will not be run for read-only operations like `lint`, `diff` or `template`. `preapply` hooks are triggered before a release is uninstalled, installed, or upgraded as part of `helmfile apply`. -This is the ideal event to hook into when you are going to use `helmfile apply` for every kind of change and you want the hook to be triggered regardless of whether the releases have changed or not. Be sure to make each `preapply` hook command idempotent. Otherwise, rerunning helmfile-apply on a transient failure may end up either breaking your cluster, or the hook that runs for the second time will never succeed. +This is the ideal event to hook into when you are going to use `helmfile apply` for every kind of change. Note that preapply hooks will only run if at least one release has changes to apply. Be sure to make each `preapply` hook command idempotent. Otherwise, rerunning `helmfile apply` on a transient failure may end up either breaking your cluster, or the hook that runs for the second time will never succeed. `preuninstall` hooks are triggered immediately before a release is uninstalled as part of `helmfile apply`, `helmfile sync`, `helmfile delete`, and `helmfile destroy`. diff --git a/go.mod b/go.mod index 62cfd842..67c02224 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 github.com/gofrs/flock v0.13.0 @@ -37,8 +37,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.20.1 helm.sh/helm/v4 v4.1.3 - k8s.io/apimachinery v0.35.2 - k8s.io/client-go v0.35.2 + k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.35.3 ) require ( @@ -57,7 +57,7 @@ require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/dimchansky/utfbom v1.1.1 // indirect - github.com/fatih/color v1.18.0 + github.com/fatih/color v1.19.0 github.com/fujiwara/tfstate-lookup v1.10.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -104,7 +104,7 @@ require ( golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.271.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.2 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect @@ -156,7 +156,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.0 // indirect @@ -340,7 +340,7 @@ require ( gopkg.in/gookit/color.v1 v1.1.6 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/api v0.35.2 // indirect + k8s.io/api v0.35.3 // indirect k8s.io/apiextensions-apiserver v0.35.1 // indirect k8s.io/apiserver v0.35.1 // indirect k8s.io/cli-runtime v0.35.1 // indirect diff --git a/go.sum b/go.sum index b9d8245a..b689e7bb 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,8 @@ github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= @@ -183,8 +183,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= github.com/aws/aws-sdk-go-v2/service/kms v1.50.2 h1:UOHOXigIzDRaEU03CBQcZ5uW7FNC7E+vwfhsQWXl5RQ= github.com/aws/aws-sdk-go-v2/service/kms v1.50.2/go.mod h1:nAa5gmcmAmjXN3tGuhPSHLXFeWv+7nzKhjZzh8F7MH0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.3 h1:9bb0dEq1WzA0ZxIGG2EmwEgxfMAJpHyusxwbVN7f6iM= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.3/go.mod h1:2z9eg35jfuRtdPE4Ci0ousrOU9PBhDBilXA1cwq9Ptk= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= @@ -307,8 +307,8 @@ github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSY github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fluxcd/cli-utils v0.37.2-flux.1 h1:tQ588ghtRN+E+kHq415FddfqA9v4brn/1WWgrP6rQR0= @@ -1025,8 +1025,8 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1060,18 +1060,18 @@ helm.sh/helm/v4 v4.1.3 h1:Abfmb+oJUtxoaXDyB2Jhw1zRk3hT6aFfHta+AXb8Lno= helm.sh/helm/v4 v4.1.3/go.mod h1:5dSo8rRgn3OTkDAc/k0Ipw5/Q+BlqKIKZwa0XwSiINI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= -k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= -k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= -k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= -k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= diff --git a/pkg/app/app.go b/pkg/app/app.go index c5a5eb80..1a47dcf5 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -171,8 +171,9 @@ func (a *App) Diff(c DiffConfigProvider) error { Concurrency: c.Concurrency(), IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), - }, func() { + }, func() []error { msg, matched, affected, errs = a.diff(run, c) + return errs }) if msg != nil { @@ -247,8 +248,9 @@ func (a *App) Template(c TemplateConfigProvider) error { Values: c.Values(), KubeVersion: c.KubeVersion(), HelmOCIPlainHTTP: a.HelmOCIPlainHTTP, - }, func() { + }, func() []error { ok, errs = a.template(run, c) + return errs }) if prepErr != nil { @@ -269,8 +271,9 @@ func (a *App) WriteValues(c WriteValuesConfigProvider) error { IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), Concurrency: c.Concurrency(), - }, func() { + }, func() []error { ok, errs = a.writeValues(run, c) + return errs }) if prepErr != nil { @@ -323,8 +326,9 @@ func (a *App) Lint(c LintConfigProvider) error { Concurrency: c.Concurrency(), IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), - }, func() { + }, func() []error { ok, lintErrs, errs = a.lint(run, c) + return append(errs, lintErrs...) }) if prepErr != nil { @@ -365,8 +369,9 @@ func (a *App) Unittest(c UnittestConfigProvider) error { Concurrency: c.Concurrency(), IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), - }, func() { + }, func() []error { ok, unittestErrs, errs = a.unittest(run, c) + return append(errs, unittestErrs...) }) if prepErr != nil { @@ -401,7 +406,9 @@ func (a *App) Fetch(c FetchConfigProvider) error { OutputDir: c.OutputDir(), OutputDirTemplate: c.OutputDirTemplate(), Concurrency: c.Concurrency(), - }, func() {}) + }, func() []error { + return nil + }) if prepErr != nil { errs = append(errs, prepErr) @@ -427,8 +434,9 @@ func (a *App) Sync(c SyncConfigProvider) error { IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), Validate: c.Validate(), Concurrency: c.Concurrency(), - }, func() { + }, func() []error { ok, errs = a.sync(run, c) + return errs }) if prepErr != nil { @@ -464,7 +472,7 @@ func (a *App) Apply(c ApplyConfigProvider) error { Concurrency: c.Concurrency(), IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), - }, func() { + }, func() []error { matched, updated, es := a.apply(run, c) mut.Lock() @@ -472,6 +480,7 @@ func (a *App) Apply(c ApplyConfigProvider) error { mut.Unlock() ok, errs = matched, es + return errs }) if prepErr != nil { @@ -500,8 +509,9 @@ func (a *App) Status(c StatusesConfigProvider) error { SkipRepos: true, SkipDeps: true, Concurrency: c.Concurrency(), - }, func() { + }, func() []error { ok, errs = a.status(run, c) + return errs }) if err != nil { @@ -522,8 +532,9 @@ func (a *App) Destroy(c DestroyConfigProvider) error { Concurrency: c.Concurrency(), DeleteWait: c.DeleteWait(), DeleteTimeout: c.DeleteTimeout(), - }, func() { + }, func() []error { ok, errs = a.delete(run, true, c) + return errs }) if err != nil { errs = append(errs, err) @@ -548,8 +559,9 @@ func (a *App) Test(c TestConfigProvider) error { SkipRefresh: c.SkipRefresh(), SkipDeps: c.SkipDeps(), Concurrency: c.Concurrency(), - }, func() { + }, func() []error { errs = a.test(run, c) + return errs }) if err != nil { @@ -567,11 +579,12 @@ func (a *App) PrintDAGState(c DAGConfigProvider) error { SkipRepos: true, SkipDeps: true, Concurrency: 2, - }, func() { + }, func() []error { err = a.dag(run) if err != nil { errs = append(errs, err) } + return errs }) return ok, errs }, false, false, SetFilter(true)) @@ -583,7 +596,7 @@ func (a *App) PrintState(c StateConfigProvider) error { SkipRepos: true, SkipDeps: true, Concurrency: 2, - }, func() { + }, func() []error { if c.EmbedValues() { for i := range run.state.Releases { r := run.state.Releases[i] @@ -591,7 +604,7 @@ func (a *App) PrintState(c StateConfigProvider) error { values, err := run.state.LoadYAMLForEmbedding(&r, r.Values, r.MissingFileHandler, r.ValuesPathPrefix) if err != nil { errs = []error{err} - return + return errs } run.state.Releases[i].Values = values @@ -599,7 +612,7 @@ func (a *App) PrintState(c StateConfigProvider) error { secrets, err := run.state.LoadYAMLForEmbedding(&r, r.Secrets, r.MissingFileHandler, r.ValuesPathPrefix) if err != nil { errs = []error{err} - return + return errs } run.state.Releases[i].Secrets = secrets @@ -609,17 +622,18 @@ func (a *App) PrintState(c StateConfigProvider) error { stateYaml, err := run.state.ToYaml() if err != nil { errs = []error{err} - return + return errs } sourceFile, err := run.state.FullFilePath() if err != nil { errs = []error{err} - return + return errs } fmt.Printf("---\n# Source: %s\n\n%+v", sourceFile, stateYaml) errs = []error{} + return errs }) if err != nil { @@ -648,26 +662,30 @@ func (a *App) ListReleases(c ListConfigProvider) error { err := a.ForEachState(func(run *Run) (_ bool, errs []error) { var stateReleases []*HelmRelease - var err error + var listErr error if !c.SkipCharts() { - err = run.withPreparedCharts("list", state.ChartPrepareOptions{ + prepErr := run.withPreparedCharts("list", state.ChartPrepareOptions{ SkipRepos: true, SkipDeps: true, Concurrency: 2, - }, func() { + }, func() []error { rel, err := a.list(run) if err != nil { - panic(err) + errs = append(errs, err) + return []error{err} } stateReleases = rel + return nil }) + if prepErr != nil { + errs = append(errs, prepErr) + } } else { - stateReleases, err = a.list(run) - } - - if err != nil { - errs = append(errs, err) + stateReleases, listErr = a.list(run) + if listErr != nil { + errs = append(errs, listErr) + } } if len(stateReleases) > 0 { @@ -709,14 +727,16 @@ func (a *App) ListReleases(c ListConfigProvider) error { func (a *App) list(run *Run) ([]*HelmRelease, error) { var releases []*HelmRelease - for _, r := range run.state.Releases { + resolvedState, err := run.state.ResolveDeps() + if err != nil { + return nil, fmt.Errorf("unable to resolve dependencies for %s: %w", run.state.FilePath, err) + } + + for _, r := range resolvedState.Releases { labels := "" if r.Labels == nil { r.Labels = map[string]string{} } - for k, v := range run.state.CommonLabels { - r.Labels[k] = v - } var keys []string for k := range r.Labels { @@ -730,7 +750,7 @@ func (a *App) list(run *Run) ([]*HelmRelease, error) { } labels = strings.Trim(labels, ",") - enabled, err := state.ConditionEnabled(r, run.state.Values()) + enabled, err := state.ConditionEnabled(r, resolvedState.Values()) if err != nil { return nil, err } @@ -1716,6 +1736,10 @@ Do you really want to apply? // Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies st.Releases = selectedAndNeededReleases + if len(releasesToBeUpdated) == 0 && len(releasesToBeDeleted) == 0 { + return true, false, nil + } + if !interactive || interactive && r.askForConfirmation(confMsg) { if _, preapplyErrors := withDAG(st, helm, a.Logger, state.PlanOptions{Purpose: "invoking preapply hooks for", Reverse: true, SelectedReleases: toApplyWithNeeds, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error { for _, r := range subst.Releases { @@ -1773,6 +1797,7 @@ Do you really want to apply? Wait: c.Wait(), WaitRetries: c.WaitRetries(), WaitForJobs: c.WaitForJobs(), + Timeout: c.Timeout(), ReuseValues: c.ReuseValues(), ResetValues: c.ResetValues(), PostRenderer: c.PostRenderer(), @@ -1785,6 +1810,7 @@ Do you really want to apply? TrackMode: c.TrackMode(), TrackTimeout: c.TrackTimeout(), TrackLogs: c.TrackLogs(), + Description: c.Description(), } return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts) })) @@ -2241,6 +2267,7 @@ Do you really want to sync? Wait: c.Wait(), WaitRetries: c.WaitRetries(), WaitForJobs: c.WaitForJobs(), + Timeout: c.Timeout(), ReuseValues: c.ReuseValues(), ResetValues: c.ResetValues(), PostRenderer: c.PostRenderer(), @@ -2253,6 +2280,7 @@ Do you really want to sync? TrackMode: c.TrackMode(), TrackTimeout: c.TrackTimeout(), TrackLogs: c.TrackLogs(), + Description: c.Description(), } return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), opts) })) diff --git a/pkg/app/app_apply_test.go b/pkg/app/app_apply_test.go index 2f235f1a..7dd663cf 100644 --- a/pkg/app/app_apply_test.go +++ b/pkg/app/app_apply_test.go @@ -25,6 +25,7 @@ func TestApply_2(t *testing.T) { fields fields ns string concurrency int + timeout int skipDiffOnInstall bool error string files map[string]string @@ -84,6 +85,7 @@ func TestApply_2(t *testing.T) { syncErr := app.Apply(applyConfig{ // if we check log output, concurrency must be 1. otherwise the test becomes non-deterministic. concurrency: tc.concurrency, + timeout: tc.timeout, logger: logger, skipDiffOnInstall: tc.skipDiffOnInstall, skipNeeds: tc.fields.skipNeeds, @@ -653,4 +655,30 @@ foo 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default concurrency: 1, }) }) + + t.Run("timeout flag is passed to helm", func(t *testing.T) { + check(t, testcase{ + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + timeout: 300, + concurrency: 1, + upgraded: []exectest.Release{ + {Name: "my-release", Flags: []string{"--timeout", "300s", "--kube-context", "default", "--namespace", "default"}}, + }, + diffs: map[exectest.DiffKey]error{ + {Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) } diff --git a/pkg/app/app_list_test.go b/pkg/app/app_list_test.go index f8084682..9fbb4644 100644 --- a/pkg/app/app_list_test.go +++ b/pkg/app/app_list_test.go @@ -2,6 +2,7 @@ package app import ( "bytes" + "encoding/json" "os" "testing" @@ -301,3 +302,160 @@ func TestListWithJSONOutput(t *testing.T) { testListWithJSONOutput(t, configImpl{skipCharts: true}) }) } + +func TestListWithLockFileVersion(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.yaml": ` +repositories: +- name: bitnami + url: https://charts.bitnami.com/bitnami + +releases: +- name: redis + namespace: default + chart: bitnami/redis + version: ">=1.0.0" +`, + "/path/to/helmfile.lock": `version: v0.0.0 +digest: sha256:abc123 +generated: "2024-01-01T00:00:00Z" +dependencies: +- name: redis + repository: https://charts.bitnami.com/bitnami + version: 17.0.7 +`, + } + + stdout := os.Stdout + defer func() { os.Stdout = stdout }() + + var buffer bytes.Buffer + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Fatalf("unexpected error creating vals runtime: %v", err) + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + valsRuntime: valsRuntime, + }, files) + + expectNoCallsToHelm(app) + + out, err := testutil.CaptureStdout(func() { + err := app.ListReleases(configImpl{skipCharts: true, output: "json"}) + assert.Nil(t, err) + }) + assert.NoError(t, err) + + var releases []HelmRelease + if err := json.Unmarshal([]byte(out), &releases); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) + } + + assert.Len(t, releases, 1, "expected 1 release") + assert.Equal(t, "redis", releases[0].Name) + assert.Equal(t, "bitnami/redis", releases[0].Chart) + assert.Equal(t, "17.0.7", releases[0].Version, "expected version from helmfile.lock") +} + +func TestListWithLockFileVersion_MultiFile(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.d/first.yaml": ` +repositories: +- name: bitnami + url: https://charts.bitnami.com/bitnami + +releases: +- name: redis + namespace: default + chart: bitnami/redis + version: ">=1.0.0" +`, + "/path/to/helmfile.d/first.lock": `version: v0.0.0 +digest: sha256:abc123 +generated: "2024-01-01T00:00:00Z" +dependencies: +- name: redis + repository: https://charts.bitnami.com/bitnami + version: 17.0.7 +`, + "/path/to/helmfile.d/second.yaml": ` +repositories: +- name: bitnami + url: https://charts.bitnami.com/bitnami + +releases: +- name: nginx + namespace: default + chart: bitnami/nginx + version: ">=1.0.0" +`, + "/path/to/helmfile.d/second.lock": `version: v0.0.0 +digest: sha256:def456 +generated: "2024-01-01T00:00:00Z" +dependencies: +- name: nginx + repository: https://charts.bitnami.com/bitnami + version: 15.0.0 +`, + } + + stdout := os.Stdout + defer func() { os.Stdout = stdout }() + + var buffer bytes.Buffer + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Fatalf("unexpected error creating vals runtime: %v", err) + } + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + valsRuntime: valsRuntime, + }, files) + + expectNoCallsToHelm(app) + + out, err := testutil.CaptureStdout(func() { + err := app.ListReleases(configImpl{skipCharts: true, output: "json"}) + assert.Nil(t, err) + }) + assert.NoError(t, err) + + var releases []HelmRelease + if err := json.Unmarshal([]byte(out), &releases); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) + } + + assert.Len(t, releases, 2, "expected 2 releases") + + releaseMap := make(map[string]HelmRelease) + for _, r := range releases { + releaseMap[r.Name] = r + } + + redis := releaseMap["redis"] + assert.Equal(t, "bitnami/redis", redis.Chart) + assert.Equal(t, "17.0.7", redis.Version, "expected redis version from first.lock") + + nginx := releaseMap["nginx"] + assert.Equal(t, "bitnami/nginx", nginx.Chart) + assert.Equal(t, "15.0.0", nginx.Version, "expected nginx version from second.lock") +} diff --git a/pkg/app/app_sync_test.go b/pkg/app/app_sync_test.go index 200e8f5c..ce38e77e 100644 --- a/pkg/app/app_sync_test.go +++ b/pkg/app/app_sync_test.go @@ -25,6 +25,7 @@ func TestSync(t *testing.T) { fields fields ns string concurrency int + timeout int skipDiffOnInstall bool error string files map[string]string @@ -81,6 +82,7 @@ func TestSync(t *testing.T) { syncErr := app.Sync(applyConfig{ concurrency: tc.concurrency, + timeout: tc.timeout, logger: logger, skipDiffOnInstall: tc.skipDiffOnInstall, skipNeeds: tc.fields.skipNeeds, @@ -478,4 +480,27 @@ releases: concurrency: 1, }) }) + + t.Run("timeout flag is passed to helm", func(t *testing.T) { + check(t, testcase{ + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: my-release + chart: incubator/raw + namespace: default +`, + }, + timeout: 600, + concurrency: 1, + upgraded: []exectest.Release{ + {Name: "my-release", Flags: []string{"--timeout", "600s", "--kube-context", "default", "--namespace", "default"}}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + }) + }) } diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index ce40b48c..841ce6fb 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -2388,6 +2388,7 @@ type applyConfig struct { wait bool waitRetries int waitForJobs bool + timeout int reuseValues bool postRenderer string postRendererArgs []string @@ -2428,6 +2429,10 @@ func (a applyConfig) WaitForJobs() bool { return a.waitForJobs } +func (a applyConfig) Timeout() int { + return a.timeout +} + func (a applyConfig) Values() []string { return a.values } @@ -2624,6 +2629,10 @@ func (a applyConfig) TrackLogs() bool { return a.trackLogs } +func (a applyConfig) Description() string { + return "" +} + type depsConfig struct { skipRepos bool includeNeeds bool diff --git a/pkg/app/cleanup_hooks_error_test.go b/pkg/app/cleanup_hooks_error_test.go new file mode 100644 index 00000000..9a877486 --- /dev/null +++ b/pkg/app/cleanup_hooks_error_test.go @@ -0,0 +1,121 @@ +package app + +import ( + "sync" + "testing" + + "github.com/helmfile/vals" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + + "github.com/helmfile/helmfile/pkg/exectest" + ffs "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/helmexec" +) + +func TestCleanupHooksErrorPropagation(t *testing.T) { + type testcase struct { + files map[string]string + releaseName string + expectedError bool + expectedInLogs string + } + + check := func(t *testing.T, tc testcase) { + t.Helper() + + var helm = &exectest.Helm{ + FailOnUnexpectedList: true, + FailOnUnexpectedDiff: true, + DiffMutex: &sync.Mutex{}, + ChartsMutex: &sync.Mutex{}, + ReleasesMutex: &sync.Mutex{}, + } + + valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) + if err != nil { + t.Fatalf("unexpected error creating vals runtime: %v", err) + } + + bs := runWithLogCapture(t, "info", func(t *testing.T, logger *zap.SugaredLogger) { + t.Helper() + + app := appWithFs(&App{ + OverrideHelmBinary: DefaultHelmBinary, + fs: ffs.DefaultFileSystem(), + OverrideKubeContext: "default", + DisableKubeVersionAutoDetection: true, + Env: "default", + Logger: logger, + helms: map[helmKey]helmexec.Interface{ + createHelmKey("helm", "default"): helm, + }, + valsRuntime: valsRuntime, + }, tc.files) + + syncErr := app.Sync(applyConfig{ + concurrency: 1, + logger: logger, + }) + + if tc.expectedError { + assert.Error(t, syncErr, "expected error for release %s", tc.releaseName) + } else { + assert.NoError(t, syncErr, "unexpected error for release %s", tc.releaseName) + } + }) + + logOutput := bs.String() + assert.Contains(t, logOutput, tc.expectedInLogs, "unexpected log output") + } + + t.Run("cleanup hook receives error when sync fails", func(t *testing.T) { + check(t, testcase{ + releaseName: "error-release", + files: map[string]string{ + "/path/to/helmfile.yaml": ` +hooks: + - name: global-cleanup + events: + - cleanup + showlogs: true + command: echo + args: + - "error is '{{ .Event.Error }}'" + +releases: + - name: error-release + chart: incubator/raw + namespace: default +`, + }, + expectedError: true, + expectedInLogs: "error is 'failed processing release error-release: error'", + }) + }) + + t.Run("cleanup hook receives nil when sync succeeds", func(t *testing.T) { + check(t, testcase{ + releaseName: "success-release", + files: map[string]string{ + "/path/to/helmfile.yaml": ` +hooks: + - name: global-cleanup + events: + - cleanup + showlogs: true + command: echo + args: + - "error is '{{ .Event.Error }}'" + +releases: + - name: success-release + chart: incubator/raw + namespace: default +`, + }, + expectedError: false, + expectedInLogs: "error is ''", + }) + }) +} diff --git a/pkg/app/config.go b/pkg/app/config.go index b64296cd..4c263d6d 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -61,6 +61,7 @@ type ApplyConfigProvider interface { Wait() bool WaitRetries() int WaitForJobs() bool + Timeout() int IncludeTests() bool @@ -93,6 +94,8 @@ type ApplyConfigProvider interface { TrackTimeout() int TrackLogs() bool + Description() string + concurrencyConfig interactive loggingConfig @@ -116,6 +119,7 @@ type SyncConfigProvider interface { Wait() bool WaitRetries() int WaitForJobs() bool + Timeout() int SyncArgs() string Validate() bool @@ -129,6 +133,8 @@ type SyncConfigProvider interface { TrackTimeout() int TrackLogs() bool + Description() string + DAGConfig concurrencyConfig diff --git a/pkg/app/init.go b/pkg/app/init.go index 223fd10f..7fcd0e81 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -19,7 +19,7 @@ import ( const ( HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x) - HelmDiffRecommendedVersion = "v3.15.1" + HelmDiffRecommendedVersion = "v3.15.3" HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4 HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture) HelmGitRecommendedVersion = "v1.3.0" diff --git a/pkg/app/run.go b/pkg/app/run.go index 589bba9b..27ea2ae1 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -57,7 +57,7 @@ func (r *Run) prepareChartsIfNeeded(helmfileCommand string, dir string, concurre return releaseToChart, nil } -func (r *Run) withPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func()) error { +func (r *Run) withPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func() []error) error { if r.ReleaseToChart != nil { panic("Run.PrepareCharts can be called only once") } @@ -119,9 +119,16 @@ func (r *Run) withPreparedCharts(helmfileCommand string, opts state.ChartPrepare r.ReleaseToChart = releaseToChart - f() + errs := f() + var firstErr error + for _, e := range errs { + if e != nil { + firstErr = e + break + } + } - _, err = r.state.TriggerGlobalCleanupEvent(helmfileCommand) + _, err = r.state.TriggerGlobalCleanupEvent(helmfileCommand, firstErr) return err } diff --git a/pkg/app/testdata/testapply/helm-status-check-to-release-existence/log b/pkg/app/testdata/testapply/helm-status-check-to-release-existence/log index 59565383..5aacb4ea 100644 --- a/pkg/app/testdata/testapply/helm-status-check-to-release-existence/log +++ b/pkg/app/testdata/testapply/helm-status-check-to-release-existence/log @@ -2,8 +2,3 @@ merged environment: &{default map[] map[] map[]} 2 release(s) found in helmfile.yaml Checking release existence using `helm status` for release foo_notFound -invoking preapply hooks for 1 groups of releases in this order: -GROUP RELEASES -1 default//bar, default//foo_notFound - -invoking preapply hooks for releases in group 1/1: default//bar, default//foo_notFound diff --git a/pkg/app/testdata/testapply/noop/log b/pkg/app/testdata/testapply/noop/log index 661f5a67..dc714f59 100644 --- a/pkg/app/testdata/testapply/noop/log +++ b/pkg/app/testdata/testapply/noop/log @@ -1,10 +1,3 @@ merged environment: &{default map[] map[] map[]} 2 release(s) found in helmfile.yaml -invoking preapply hooks for 2 groups of releases in this order: -GROUP RELEASES -1 default//foo -2 default//bar - -invoking preapply hooks for releases in group 1/2: default//foo -invoking preapply hooks for releases in group 2/2: default//bar diff --git a/pkg/app/testdata/testapply_2/timeout_flag_is_passed_to_helm/log b/pkg/app/testdata/testapply_2/timeout_flag_is_passed_to_helm/log new file mode 100644 index 00000000..5e22123e --- /dev/null +++ b/pkg/app/testdata/testapply_2/timeout_flag_is_passed_to_helm/log @@ -0,0 +1,21 @@ +merged environment: &{default map[] map[] map[]} +1 release(s) found in helmfile.yaml + +Affected releases are: + my-release (incubator/raw) UPDATED + +invoking preapply hooks for 1 groups of releases in this order: +GROUP RELEASES +1 default/default/my-release + +invoking preapply hooks for releases in group 1/1: default/default/my-release +processing 1 groups of releases in this order: +GROUP RELEASES +1 default/default/my-release + +processing releases in group 1/1: default/default/my-release + +UPDATED RELEASES: +NAME NAMESPACE CHART VERSION DURATION +my-release default incubator/raw 3.1.0 0s + diff --git a/pkg/app/testdata/testapply_hooks/hooks_for_no-diff_release/log b/pkg/app/testdata/testapply_hooks/hooks_for_no-diff_release/log index b2f0bdff..0da06e80 100644 --- a/pkg/app/testdata/testapply_hooks/hooks_for_no-diff_release/log +++ b/pkg/app/testdata/testapply_hooks/hooks_for_no-diff_release/log @@ -1,9 +1,3 @@ hook[prepare] logs | foo hook[prepare] logs | - -hook[preapply] logs | foo -hook[preapply] logs | - -hook[cleanup] logs | foo -hook[cleanup] logs | diff --git a/pkg/config/apply.go b/pkg/config/apply.go index f97d712c..8580bba2 100644 --- a/pkg/config/apply.go +++ b/pkg/config/apply.go @@ -57,6 +57,8 @@ type ApplyOptions struct { WaitRetries int // WaitForJobs is true if the helm command should wait for the jobs to be completed WaitForJobs bool + // Timeout is the timeout for helm operations in seconds + Timeout int // Propagate '--skip-schema-validation' to helmv3 template and helm install SkipSchemaValidation bool // ReuseValues is true if the helm command should reuse the values @@ -86,6 +88,8 @@ type ApplyOptions struct { TrackTimeout int // TrackLogs enables log streaming with kubedog TrackLogs bool + // Description is the description that will be passed to helm upgrade --description + Description string } // NewApply creates a new Apply @@ -235,6 +239,11 @@ func (a *ApplyImpl) WaitForJobs() bool { return a.ApplyOptions.WaitForJobs } +// Timeout returns the timeout. +func (a *ApplyImpl) Timeout() int { + return a.ApplyOptions.Timeout +} + // ReuseValues returns the ReuseValues. func (a *ApplyImpl) ReuseValues() bool { if !a.ResetValues() { @@ -307,6 +316,11 @@ func (a *ApplyImpl) TrackLogs() bool { return a.ApplyOptions.TrackLogs } +// Description returns the description. +func (a *ApplyImpl) Description() string { + return a.ApplyOptions.Description +} + func (a *ApplyImpl) ValidateConfig() error { validTrackModes := []string{"helm", "helm-legacy", "kubedog"} if a.ApplyOptions.TrackMode != "" && !slices.Contains(validTrackModes, a.ApplyOptions.TrackMode) { diff --git a/pkg/config/sync.go b/pkg/config/sync.go index 79a90eb6..8124108b 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -59,6 +59,8 @@ type SyncOptions struct { TrackTimeout int // TrackLogs enables log streaming with kubedog TrackLogs bool + // Description is the description that will be passed to helm upgrade --description + Description string } // NewSyncOptions creates a new Apply @@ -214,6 +216,11 @@ func (t *SyncImpl) TrackLogs() bool { return t.SyncOptions.TrackLogs } +// Description returns the description. +func (t *SyncImpl) Description() string { + return t.SyncOptions.Description +} + func (t *SyncImpl) ValidateConfig() error { validTrackModes := []string{"helm", "helm-legacy", "kubedog"} if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) { diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index b5df7100..cd4d52bc 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -28,4 +28,10 @@ const ( // Can be overridden by AWS_SDK_GO_LOG_LEVEL environment variable // See issue #2270 and vals PR #893 AWSSDKLogLevel = "HELMFILE_AWS_SDK_LOG_LEVEL" + + // ValsFailOnMissingKeyInMap controls whether vals should fail when a key is missing in a map. + // When set to "true", vals returns an error if a referenced key does not exist in the secret map. + // Default is false for backward compatibility (returns empty string for missing keys). + // See issue #1563 + ValsFailOnMissingKeyInMap = "HELMFILE_VALS_FAIL_ON_MISSING_KEY_IN_MAP" ) diff --git a/pkg/event/bus_test.go b/pkg/event/bus_test.go index feb4eb2a..26c4b0f3 100644 --- a/pkg/event/bus_test.go +++ b/pkg/event/bus_test.go @@ -1,8 +1,10 @@ package event import ( + "errors" "fmt" "io" + "strings" "testing" "go.uber.org/zap" @@ -13,6 +15,11 @@ import ( ) type runner struct { + executeCalls []struct { + cmd string + args []string + env map[string]string + } } func (r *runner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) { @@ -20,6 +27,11 @@ func (r *runner) ExecuteStdIn(cmd string, args []string, env map[string]string, } func (r *runner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) { + r.executeCalls = append(r.executeCalls, struct { + cmd string + args []string + env map[string]string + }{cmd: cmd, args: args, env: env}) if cmd == "ng" { return nil, fmt.Errorf("cmd failed due to invalid cmd: %s", cmd) } @@ -188,3 +200,119 @@ func TestTrigger(t *testing.T) { } } } + +func TestTriggerCleanupEventWithError(t *testing.T) { + runner := &runner{} + + core, _ := observer.New(zap.InfoLevel) + logger := zap.New(core).Sugar() + + testError := errors.New("sync failed: release error") + + hooks := []Hook{ + { + Name: "cleanup-with-error", + Events: []string{"cleanup"}, + Command: "echo", + Args: []string{"error is '{{ .Event.Error }}'"}, + ShowLogs: true, + }, + } + + bus := &Bus{ + Hooks: hooks, + StateFilePath: "/path/to/helmfile.yaml", + BasePath: ".", + Namespace: "default", + Env: environment.Environment{Name: "default"}, + Logger: logger, + Fs: ffs.DefaultFileSystem(), + Runner: runner, + } + + data := map[string]any{ + "HelmfileCommand": "sync", + } + + executed, err := bus.Trigger("cleanup", testError, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !executed { + t.Fatal("expected cleanup hook to be executed") + } + + if len(runner.executeCalls) != 1 { + t.Fatalf("expected 1 execute call, got %d", len(runner.executeCalls)) + } + + call := runner.executeCalls[0] + if call.cmd != "echo" { + t.Errorf("expected command 'echo', got %q", call.cmd) + } + + if len(call.args) != 1 { + t.Fatalf("expected 1 arg, got %d", len(call.args)) + } + + expectedArg := "error is 'sync failed: release error'" + if !strings.Contains(call.args[0], "error is") { + t.Errorf("expected arg to contain 'error is', got %q", call.args[0]) + } + + if call.args[0] != expectedArg { + t.Errorf("expected arg %q, got %q", expectedArg, call.args[0]) + } +} + +func TestTriggerCleanupEventWithNilError(t *testing.T) { + runner := &runner{} + + core, _ := observer.New(zap.InfoLevel) + logger := zap.New(core).Sugar() + + hooks := []Hook{ + { + Name: "cleanup-nil-error", + Events: []string{"cleanup"}, + Command: "echo", + Args: []string{"error is '{{ .Event.Error }}'"}, + ShowLogs: true, + }, + } + + bus := &Bus{ + Hooks: hooks, + StateFilePath: "/path/to/helmfile.yaml", + BasePath: ".", + Namespace: "default", + Env: environment.Environment{Name: "default"}, + Logger: logger, + Fs: ffs.DefaultFileSystem(), + Runner: runner, + } + + data := map[string]any{ + "HelmfileCommand": "sync", + } + + executed, err := bus.Trigger("cleanup", nil, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !executed { + t.Fatal("expected cleanup hook to be executed") + } + + if len(runner.executeCalls) != 1 { + t.Fatalf("expected 1 execute call, got %d", len(runner.executeCalls)) + } + + call := runner.executeCalls[0] + expectedArg := "error is ''" + if call.args[0] != expectedArg { + t.Errorf("expected arg %q, got %q", expectedArg, call.args[0]) + } +} diff --git a/pkg/plugins/vals.go b/pkg/plugins/vals.go index 8c65ceaf..f0c2767f 100644 --- a/pkg/plugins/vals.go +++ b/pkg/plugins/vals.go @@ -1,8 +1,10 @@ package plugins import ( + "fmt" "io" "os" + "strconv" "strings" "sync" @@ -17,46 +19,82 @@ const ( ) var instance *vals.Runtime -var once sync.Once +var mu sync.Mutex + +func buildValsOptions() (vals.Options, error) { + // Configure AWS SDK logging via HELMFILE_AWS_SDK_LOG_LEVEL environment variable + // Default: "off" to prevent sensitive information (tokens, auth headers) from being exposed + // See issue #2270 and vals PR helmfile/vals#893 + // + // Valid values: + // - "off" (default): No AWS SDK logging - secure, prevents credential leakage + // - "minimal": Log retries only - minimal debugging info + // - "standard": Log retries + requests - moderate debugging (previous default) + // - "verbose": Log everything - full debugging (requests, responses, bodies, signing) + // - Custom: Comma-separated values like "request,response" + // + // Note: AWS_SDK_GO_LOG_LEVEL environment variable always takes precedence over this setting + // Note: Case-insensitive for known values like "off", "OFF", "Off" + logLevel := strings.TrimSpace(os.Getenv(envvar.AWSSDKLogLevel)) + + // Configure fail on missing key behavior + // Default to false for backward compatibility + // Set HELMFILE_VALS_FAIL_ON_MISSING_KEY_IN_MAP=true to enable strict mode + // Supports common boolean values: "true", "TRUE", "1", etc. + // See issue #1563 + envVal := strings.TrimSpace(os.Getenv(envvar.ValsFailOnMissingKeyInMap)) + var failOnMissingKey bool + if envVal != "" { + var err error + failOnMissingKey, err = strconv.ParseBool(envVal) + if err != nil { + return vals.Options{}, fmt.Errorf("invalid value for %s: %q (must be a valid boolean)", envvar.ValsFailOnMissingKeyInMap, envVal) + } + } + + // Default to "off" for security if not specified + if logLevel == "" { + logLevel = "off" + } + + // Normalize known values to lowercase for case-insensitive handling + if strings.EqualFold(logLevel, "off") { + logLevel = "off" + } + + opts := vals.Options{ + CacheSize: valsCacheSize, + FailOnMissingKeyInMap: failOnMissingKey, + AWSLogLevel: logLevel, + } + + // Also suppress vals' own internal logging unless user wants verbose output + // This prevents vals' log messages (separate from AWS SDK logs) from exposing credentials + if logLevel == "off" { + opts.LogOutput = io.Discard + } + // For other levels, allow vals to log to default output for debugging + + return opts, nil +} func ValsInstance() (*vals.Runtime, error) { - var err error - once.Do(func() { - // Configure AWS SDK logging via HELMFILE_AWS_SDK_LOG_LEVEL environment variable - // Default: "off" to prevent sensitive information (tokens, auth headers) from being exposed - // See issue #2270 and vals PR helmfile/vals#893 - // - // Valid values: - // - "off" (default): No AWS SDK logging - secure, prevents credential leakage - // - "minimal": Log retries only - minimal debugging info - // - "standard": Log retries + requests - moderate debugging (previous default) - // - "verbose": Log everything - full debugging (requests, responses, bodies, signing) - // - Custom: Comma-separated values like "request,response" - // - // Note: AWS_SDK_GO_LOG_LEVEL environment variable always takes precedence over this setting - logLevel := strings.TrimSpace(os.Getenv(envvar.AWSSDKLogLevel)) + mu.Lock() + defer mu.Unlock() - opts := vals.Options{ - CacheSize: valsCacheSize, - } + if instance != nil { + return instance, nil + } - // Default to "off" for security if not specified - if logLevel == "" { - logLevel = "off" - } + opts, err := buildValsOptions() + if err != nil { + return nil, err + } - // Set AWS SDK log level for vals library - opts.AWSLogLevel = logLevel + instance, err = vals.New(opts) + if err != nil { + return nil, err + } - // Also suppress vals' own internal logging unless user wants verbose output - // This prevents vals' log messages (separate from AWS SDK logs) from exposing credentials - if logLevel == "off" { - opts.LogOutput = io.Discard - } - // For other levels, allow vals to log to default output for debugging - - instance, err = vals.New(opts) - }) - - return instance, err + return instance, nil } diff --git a/pkg/plugins/vals_test.go b/pkg/plugins/vals_test.go index 31d71d40..2b98489e 100644 --- a/pkg/plugins/vals_test.go +++ b/pkg/plugins/vals_test.go @@ -2,11 +2,11 @@ package plugins import ( "io" - "os" - "strings" "testing" "github.com/helmfile/vals" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/helmfile/helmfile/pkg/envvar" ) @@ -25,19 +25,165 @@ func TestValsInstance(t *testing.T) { } } -// TestAWSSDKLogLevelConfiguration tests the AWS SDK log level configuration logic +func TestBuildValsOptions(t *testing.T) { + tests := []struct { + name string + awsLogLevel string + failOnMissingKey string + expectedLogLevel string + expectedFailOnMissingKey bool + expectedLogOutputDiscarded bool + expectError bool + }{ + { + name: "defaults", + awsLogLevel: "", + failOnMissingKey: "", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "explicit failOnMissingKey true", + awsLogLevel: "", + failOnMissingKey: "true", + expectedLogLevel: "off", + expectedFailOnMissingKey: true, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey false", + awsLogLevel: "", + failOnMissingKey: "false", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey with whitespace", + awsLogLevel: "", + failOnMissingKey: " true ", + expectedLogLevel: "off", + expectedFailOnMissingKey: true, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey uppercase TRUE", + awsLogLevel: "", + failOnMissingKey: "TRUE", + expectedLogLevel: "off", + expectedFailOnMissingKey: true, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey numeric 1", + awsLogLevel: "", + failOnMissingKey: "1", + expectedLogLevel: "off", + expectedFailOnMissingKey: true, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey numeric 0", + awsLogLevel: "", + failOnMissingKey: "0", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "failOnMissingKey invalid value", + awsLogLevel: "", + failOnMissingKey: "invalid", + expectError: true, + }, + { + name: "aws log level verbose", + awsLogLevel: "verbose", + failOnMissingKey: "", + expectedLogLevel: "verbose", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: false, + }, + { + name: "aws log level with whitespace", + awsLogLevel: " minimal ", + failOnMissingKey: "", + expectedLogLevel: "minimal", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: false, + }, + { + name: "aws log level OFF uppercase", + awsLogLevel: "OFF", + failOnMissingKey: "", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "aws log level Off mixed case", + awsLogLevel: "Off", + failOnMissingKey: "", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "aws log level Off mixed case", + awsLogLevel: "Off", + failOnMissingKey: "", + expectedLogLevel: "off", + expectedFailOnMissingKey: false, + expectedLogOutputDiscarded: true, + }, + { + name: "both options set", + awsLogLevel: "standard", + failOnMissingKey: "true", + expectedLogLevel: "standard", + expectedFailOnMissingKey: true, + expectedLogOutputDiscarded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(envvar.AWSSDKLogLevel, tt.awsLogLevel) + t.Setenv(envvar.ValsFailOnMissingKeyInMap, tt.failOnMissingKey) + + opts, err := buildValsOptions() + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), envvar.ValsFailOnMissingKeyInMap) + return + } + + require.NoError(t, err) + + assert.Equal(t, tt.expectedLogLevel, opts.AWSLogLevel) + assert.Equal(t, tt.expectedFailOnMissingKey, opts.FailOnMissingKeyInMap) + assert.Equal(t, valsCacheSize, opts.CacheSize) + + isDiscarded := opts.LogOutput == io.Discard + assert.Equal(t, tt.expectedLogOutputDiscarded, isDiscarded) + }) + } +} + func TestAWSSDKLogLevelConfiguration(t *testing.T) { tests := []struct { name string envValue string expectedLogLevel string - expectedLogOutput bool // true if LogOutput should be io.Discard + expectedLogOutput bool }{ { name: "no env var defaults to off", envValue: "", expectedLogLevel: "off", - expectedLogOutput: true, // LogOutput should be io.Discard + expectedLogOutput: true, }, { name: "explicit off", @@ -45,11 +191,17 @@ func TestAWSSDKLogLevelConfiguration(t *testing.T) { expectedLogLevel: "off", expectedLogOutput: true, }, + { + name: "OFF uppercase", + envValue: "OFF", + expectedLogLevel: "off", + expectedLogOutput: true, + }, { name: "minimal logging", envValue: "minimal", expectedLogLevel: "minimal", - expectedLogOutput: false, // LogOutput should NOT be io.Discard + expectedLogOutput: false, }, { name: "standard logging", @@ -73,94 +225,34 @@ func TestAWSSDKLogLevelConfiguration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Note: This test verifies the configuration logic, not the actual vals.New() call - // since ValsInstance() uses sync.Once and can only be initialized once per test run. + t.Setenv(envvar.AWSSDKLogLevel, tt.envValue) - // Simulate the logic from ValsInstance() - var logLevel string - if tt.envValue != "" { - logLevel = strings.TrimSpace(tt.envValue) - } + opts, err := buildValsOptions() + require.NoError(t, err) - // Default to "off" for security if not specified - if logLevel == "" { - logLevel = "off" - } + assert.Equal(t, tt.expectedLogLevel, opts.AWSLogLevel) - // Verify expected log level - if logLevel != tt.expectedLogLevel { - t.Errorf("Expected log level %q, got %q", tt.expectedLogLevel, logLevel) - } - - // Verify LogOutput configuration logic - opts := vals.Options{ - CacheSize: valsCacheSize, - } - opts.AWSLogLevel = logLevel - - // Verify LogOutput is set to io.Discard only when level is "off" - if tt.expectedLogOutput { - opts.LogOutput = io.Discard - if opts.LogOutput != io.Discard { - t.Error("Expected LogOutput to be io.Discard for 'off' level") - } - } + isDiscarded := opts.LogOutput == io.Discard + assert.Equal(t, tt.expectedLogOutput, isDiscarded) }) } } -// TestEnvironmentVariableReading verifies that the HELMFILE_AWS_SDK_LOG_LEVEL env var is read correctly -func TestEnvironmentVariableReading(t *testing.T) { - tests := []struct { - name string - envValue string - expectedValue string - }{ - { - name: "empty defaults to off", - envValue: "", - expectedValue: "off", - }, - { - name: "whitespace trimmed", - envValue: " minimal ", - expectedValue: "minimal", - }, - { - name: "standard value preserved", - envValue: "standard", - expectedValue: "standard", - }, - } +func TestBuildValsOptionsIntegration(t *testing.T) { + t.Run("valid configuration produces working vals options", func(t *testing.T) { + t.Setenv(envvar.AWSSDKLogLevel, "off") + t.Setenv(envvar.ValsFailOnMissingKeyInMap, "true") - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save and restore env var - original := os.Getenv(envvar.AWSSDKLogLevel) - defer func() { - if original == "" { - os.Unsetenv(envvar.AWSSDKLogLevel) - } else { - os.Setenv(envvar.AWSSDKLogLevel, original) - } - }() + opts, err := buildValsOptions() + require.NoError(t, err) - // Set test env var - if tt.envValue == "" { - os.Unsetenv(envvar.AWSSDKLogLevel) - } else { - os.Setenv(envvar.AWSSDKLogLevel, tt.envValue) - } + assert.Equal(t, valsCacheSize, opts.CacheSize) + assert.Equal(t, "off", opts.AWSLogLevel) + assert.True(t, opts.FailOnMissingKeyInMap) + assert.Equal(t, io.Discard, opts.LogOutput) - // Read and process like ValsInstance() does - logLevel := strings.TrimSpace(os.Getenv(envvar.AWSSDKLogLevel)) - if logLevel == "" { - logLevel = "off" - } - - if logLevel != tt.expectedValue { - t.Errorf("Expected %q, got %q", tt.expectedValue, logLevel) - } - }) - } + rt, err := vals.New(opts) + require.NoError(t, err) + assert.NotNil(t, rt) + }) } diff --git a/pkg/state/chart_dependency.go b/pkg/state/chart_dependency.go index f8ee0de3..60101c8a 100644 --- a/pkg/state/chart_dependency.go +++ b/pkg/state/chart_dependency.go @@ -140,8 +140,15 @@ func (st *HelmState) mergeLockedDependencies() (*HelmState, error) { // When basePath is set (e.g. when loaded with baseDir instead of os.Chdir), // resolve the lock file path relative to basePath so it can be found // without changing the working directory. - if lockFile != "" && st.basePath != "" && !filepath.IsAbs(lockFile) { - lockFile = filepath.Join(st.basePath, lockFile) + switch { + case lockFile != "": + if st.basePath != "" && !filepath.IsAbs(lockFile) { + lockFile = filepath.Join(st.basePath, lockFile) + } + case st.basePath != "": + // When no custom lockfile is specified, use the default lockfile name + // joined with basePath to ensure it's found when not changing CWD. + lockFile = filepath.Join(st.basePath, filename+".lock") } depMan := NewChartDependencyManager(filename, st.logger, lockFile) @@ -258,8 +265,13 @@ func getUnresolvedDependenciess(st *HelmState) (string, *UnresolvedDependencies) func updateDependencies(st *HelmState, shell helmexec.DependencyUpdater, unresolved *UnresolvedDependencies, filename, wd string) (*HelmState, error) { lockFile := st.LockFile - if lockFile != "" && st.basePath != "" && !filepath.IsAbs(lockFile) { - lockFile = filepath.Join(st.basePath, lockFile) + switch { + case lockFile != "": + if st.basePath != "" && !filepath.IsAbs(lockFile) { + lockFile = filepath.Join(st.basePath, lockFile) + } + case st.basePath != "": + lockFile = filepath.Join(st.basePath, filename+".lock") } depMan := NewChartDependencyManager(filename, st.logger, lockFile) diff --git a/pkg/state/state.go b/pkg/state/state.go index 28b5602e..09bf65f6 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -353,7 +353,10 @@ type ReleaseSpec struct { UnitTests []string `yaml:"unitTests,omitempty"` // Name is the name of this release - Name string `yaml:"name,omitempty"` + Name string `yaml:"name,omitempty"` + + // Description is the description for this release that will be passed to helm upgrade with --description flag + Description string `yaml:"description,omitempty"` Namespace string `yaml:"namespace,omitempty"` Labels map[string]string `yaml:"labels,omitempty"` Values []any `yaml:"values,omitempty"` @@ -917,6 +920,7 @@ type SyncOpts struct { TrackMode string TrackTimeout int TrackLogs bool + Description string } type SyncOpt interface{ Apply(*SyncOpts) } @@ -3158,8 +3162,8 @@ func (st *HelmState) TriggerGlobalPrepareEvent(helmfileCommand string) (bool, er return st.triggerGlobalReleaseEvent("prepare", nil, helmfileCommand) } -func (st *HelmState) TriggerGlobalCleanupEvent(helmfileCommand string) (bool, error) { - return st.triggerGlobalReleaseEvent("cleanup", nil, helmfileCommand) +func (st *HelmState) TriggerGlobalCleanupEvent(helmfileCommand string, evtErr error) (bool, error) { + return st.triggerGlobalReleaseEvent("cleanup", evtErr, helmfileCommand) } func (st *HelmState) triggerGlobalReleaseEvent(evt string, evtErr error, helmfileCmd string) (bool, error) { @@ -3447,6 +3451,28 @@ func (st *HelmState) appendChartDownloadFlags(flags []string, release *ReleaseSp return flags } +// appendDescriptionFlags appends the helm command-line flag for release description +// Command line takes precedence over config file +func (st *HelmState) appendDescriptionFlags(flags []string, release *ReleaseSpec, opt *SyncOpts, helm helmexec.Interface) ([]string, error) { + description := release.Description + if opt != nil && opt.Description != "" { + description = opt.Description + } + + if description != "" { + if !helm.IsVersionAtLeast("3.3.0") { + // Determine error message based on source + if opt != nil && opt.Description != "" { + return nil, fmt.Errorf("--description flag requires Helm 3.3.0 or greater") + } + return nil, fmt.Errorf("releases[].description requires Helm 3.3.0 or greater") + } + flags = append(flags, "--description", description) + } + + return flags, nil +} + func (st *HelmState) needsPlainHttp(release *ReleaseSpec, repo *RepositorySpec) bool { var repoPlainHttp, relPlainHttp bool if repo != nil { @@ -3576,6 +3602,11 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp flags = st.appendPostRenderFlags(flags, release, postRenderer, helm) + flags, err := st.appendDescriptionFlags(flags, release, opt, helm) + if err != nil { + return nil, nil, err + } + var postRendererArgs []string if opt != nil { postRendererArgs = opt.PostRendererArgs diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 9251e9e9..0b3e0ceb 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -908,6 +908,188 @@ func TestHelmState_flagsForUpgrade(t *testing.T) { "--namespace", "test-namespace", }, }, + { + name: "description-from-release", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.10.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "Release description from config", + }, + syncOpts: &SyncOpts{}, + want: []string{ + "--version", "0.1", + "--description", "Release description from config", + "--namespace", "test-namespace", + }, + }, + { + name: "description-from-cli", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.10.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + }, + syncOpts: &SyncOpts{ + Description: "CLI description from --description flag", + }, + want: []string{ + "--version", "0.1", + "--description", "CLI description from --description flag", + "--namespace", "test-namespace", + }, + }, + { + name: "description-cli-overrides-release", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.10.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "Release description from config", + }, + syncOpts: &SyncOpts{ + Description: "CLI description overrides config", + }, + want: []string{ + "--version", "0.1", + "--description", "CLI description overrides config", + "--namespace", "test-namespace", + }, + }, + { + name: "description-empty-string-not-passed", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.10.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "", + }, + syncOpts: &SyncOpts{ + Description: "", + }, + want: []string{ + "--version", "0.1", + "--namespace", "test-namespace", + }, + }, + { + name: "description-from-config-unsupported-version-3.1.0", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.1.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "Release description from config", + }, + syncOpts: &SyncOpts{}, + wantErr: "releases[].description requires Helm 3.3.0 or greater", + }, + { + name: "description-from-config-unsupported-version-3.2.4", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.2.4"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "Release description from config", + }, + syncOpts: &SyncOpts{}, + wantErr: "releases[].description requires Helm 3.3.0 or greater", + }, + { + name: "description-from-cli-unsupported-version", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.2.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + }, + syncOpts: &SyncOpts{ + Description: "CLI description from --description flag", + }, + wantErr: "--description flag requires Helm 3.3.0 or greater", + }, + { + name: "description-empty-on-old-version", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.1.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + // No description set + }, + syncOpts: &SyncOpts{}, + want: []string{ + "--version", "0.1", + "--namespace", "test-namespace", + // No --description flag should appear + }, + }, + { + name: "description-from-config-supported-version-3.3.0", + defaults: HelmSpec{ + Verify: false, + CreateNamespace: &disable, + }, + version: semver.MustParse("3.3.0"), + release: &ReleaseSpec{ + Chart: "test/chart", + Version: "0.1", + Name: "test-charts", + Namespace: "test-namespace", + Description: "Release description from config", + }, + syncOpts: &SyncOpts{}, + want: []string{ + "--version", "0.1", + "--description", "Release description from config", + "--namespace", "test-namespace", + }, + }, } for i := range tests { tt := tests[i] @@ -2548,10 +2730,10 @@ generated: 2019-05-16T15:42:45.50486+09:00 } logger := helmexec.NewLogger(io.Discard, "debug") - basePath := "/src" + basePath := filepath.ToSlash(t.TempDir()) state := &HelmState{ basePath: basePath, - FilePath: "/src/helmfile.yaml", + FilePath: filepath.Join(basePath, "helmfile.yaml"), ReleaseSetSpec: ReleaseSetSpec{ Releases: []ReleaseSpec{ { @@ -2584,8 +2766,8 @@ generated: 2019-05-16T15:42:45.50486+09:00 } fs := testhelper.NewTestFs(map[string]string{ - "/example/Chart.yaml": `foo: FOO`, - "/src/example/Chart.yaml": `foo: FOO`, + "/example/Chart.yaml": `foo: FOO`, + filepath.Join(basePath, "example/Chart.yaml"): `foo: FOO`, }) fs.Cwd = basePath state = injectFs(state, fs) @@ -2648,7 +2830,7 @@ func TestHelmState_ResolveDeps_NoLockFile(t *testing.T) { logger: logger, fs: &filesystem.FileSystem{ ReadFile: func(f string) ([]byte, error) { - if f != "helmfile.lock" { + if f != filepath.Join("/src", "helmfile.lock") { return nil, fmt.Errorf("stub: unexpected file: %s", f) } return nil, os.ErrNotExist diff --git a/pkg/state/temp_test.go b/pkg/state/temp_test.go index 0dcc6153..7ea16939 100644 --- a/pkg/state/temp_test.go +++ b/pkg/state/temp_test.go @@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) { run(testcase{ subject: "baseline", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, - want: "foo-values-5bc9c89c6b", + want: "foo-values-6ccb848dcd", }) run(testcase{ subject: "different bytes content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: []byte(`{"k":"v"}`), - want: "foo-values-7bf9c8bcdf", + want: "foo-values-5bcbbc4c85", }) run(testcase{ subject: "different map content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: map[string]any{"k": "v"}, - want: "foo-values-65694d8947", + want: "foo-values-7c6468f955", }) run(testcase{ subject: "different chart", release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"}, - want: "foo-values-856c5f7dd5", + want: "foo-values-8645f5847f", }) run(testcase{ subject: "different name", release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"}, - want: "bar-values-fff55fbf5", + want: "bar-values-54bd8c865", }) run(testcase{ subject: "specific ns", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"}, - want: "myns-foo-values-6bfbb74765", + want: "myns-foo-values-b4849b445", }) for id, n := range ids { diff --git a/test/integration/run.sh b/test/integration/run.sh index e88df567..0507e3b7 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -27,7 +27,7 @@ export HELM_DATA_HOME="${helm_dir}/data" export HELM_HOME="${HELM_DATA_HOME}" export HELM_PLUGINS="${HELM_DATA_HOME}/plugins" export HELM_CONFIG_HOME="${helm_dir}/config" -HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.1}" +HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.3}" HELM_GIT_VERSION="${HELM_GIT_VERSION:-1.4.1}" HELM_SECRETS_VERSION="${HELM_SECRETS_VERSION:-4.7.4}" export GNUPGHOME="${PWD}/${dir}/.gnupg"