diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bebd01a9..ad8e2dac 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,7 @@ jobs: with: go-version-file: go.mod cache: false - - uses: golangci/golangci-lint-action@v8 + - uses: golangci/golangci-lint-action@v9 with: version: v2.1.6 @@ -37,7 +37,7 @@ jobs: run: make check test - name: Archive built binaries run: tar -cvf built-binaries.tar helmfile diff-yamls dyff - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: built-binaries-${{ github.run_id }} path: built-binaries.tar @@ -97,7 +97,7 @@ jobs: with: go-version-file: go.mod - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: built-binaries-${{ github.run_id }} - name: install semver @@ -132,7 +132,7 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: built-binaries-${{ github.run_id }} - name: Extract tar to get built binaries diff --git a/Dockerfile b/Dockerfile index 2872032e..db42bcd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,7 +93,7 @@ RUN set -x && \ [ "$(age --version)" = "${AGE_VERSION}" ] && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.0 && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.1 && \ helm plugin install https://github.com/jkroepke/helm-secrets --version v4.6.5 && \ helm plugin install https://github.com/hypnoglow/helm-s3.git --version v0.16.3 && \ helm plugin install https://github.com/aslafy-z/helm-git.git --version v1.3.0 && \ diff --git a/Dockerfile.debian-stable-slim b/Dockerfile.debian-stable-slim index e80dc89f..95d3acf9 100644 --- a/Dockerfile.debian-stable-slim +++ b/Dockerfile.debian-stable-slim @@ -102,7 +102,7 @@ RUN set -x && \ [ "$(age --version)" = "${AGE_VERSION}" ] && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.0 && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.1 && \ helm plugin install https://github.com/jkroepke/helm-secrets --version v4.6.5 && \ helm plugin install https://github.com/hypnoglow/helm-s3.git --version v0.16.3 && \ helm plugin install https://github.com/aslafy-z/helm-git.git --version v1.3.0 && \ diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 11490b0d..a3f3c5f3 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -102,7 +102,7 @@ RUN set -x && \ [ "$(age --version)" = "${AGE_VERSION}" ] && \ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.0 && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.13.1 && \ helm plugin install https://github.com/jkroepke/helm-secrets --version v4.6.5 && \ helm plugin install https://github.com/hypnoglow/helm-s3.git --version v0.16.3 && \ helm plugin install https://github.com/aslafy-z/helm-git.git --version v1.3.0 && \ diff --git a/docs/index.md b/docs/index.md index 59d3442f..4c82e641 100644 --- a/docs/index.md +++ b/docs/index.md @@ -328,6 +328,9 @@ releases: reuseValues: false # set `false` to uninstall this release on sync. (default true) installed: true + # Defines the strategy to use when updating. Possible value is: + # - "reinstallIfForbidden": Performs an uninstall before the update only if the update is forbidden (e.g., due to permission issues or conflicts). + updateStrategy: "" # restores previous state in case of failed release (default false) atomic: true # when true, cleans up any new resources created during a failed release (default false) diff --git a/go.mod b/go.mod index 50da826e..e723e225 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,14 @@ require ( dario.cat/mergo v1.0.2 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.31.12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 + github.com/aws/aws-sdk-go-v2/config v1.31.20 + github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-test/deep v1.1.1 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 github.com/gosuri/uitable v0.0.4 - github.com/hashicorp/go-getter v1.8.2 + github.com/hashicorp/go-getter v1.8.3 github.com/hashicorp/hcl/v2 v2.24.0 github.com/helmfile/chartify v0.25.0 github.com/helmfile/vals v0.42.4 @@ -29,8 +29,8 @@ require ( go.uber.org/zap v1.27.0 go.yaml.in/yaml/v2 v2.4.3 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/sync v0.17.0 - golang.org/x/term v0.36.0 + golang.org/x/sync v0.18.0 + golang.org/x/term v0.37.0 helm.sh/helm/v3 v3.19.0 k8s.io/apimachinery v0.34.1 ) @@ -93,7 +93,7 @@ require ( go.uber.org/atomic v1.9.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.13.0 // indirect google.golang.org/api v0.252.0 // indirect @@ -129,6 +129,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect github.com/DopplerHQ/cli v0.5.11-0.20230908185655-7aef4713e1a4 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect @@ -143,26 +144,26 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect + github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.45.6 // indirect github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect - github.com/aws/smithy-go v1.23.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -170,12 +171,13 @@ require ( github.com/chai2010/gettext-go v1.0.2 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect - github.com/containerd/containerd v1.7.28 // indirect + github.com/containerd/containerd v1.7.29 // indirect github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyberark/conjur-api-go v0.13.7 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect @@ -269,6 +271,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect @@ -311,6 +314,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect k8s.io/apiextensions-apiserver v0.34.0 // indirect + k8s.io/apiserver v0.34.0 // indirect k8s.io/cli-runtime v0.34.0 // indirect k8s.io/client-go v0.34.1 // indirect k8s.io/component-base v0.34.0 // indirect diff --git a/go.sum b/go.sum index 74a71812..ff9606ce 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,7 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= @@ -140,50 +141,50 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= -github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= -github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= -github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ= +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc= +github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0= +github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.9 h1:Z1897HnnfLLgbs3pcUv8xLvtbai9TEfPUZfA0BFw968= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.9/go.mod h1:8oVESJIPBYGWdZhaHcIvTm7BnI6hbsR3ggKn0uyRMhk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 h1:w9LnHqTq8MEdlnyhV4Bwfizd65lfNCNgdlNC6mM5paE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9/go.mod h1:LGEP6EK4nj+bwWNdrvX/FnDTFowdBNwcSPuZu/ouFys= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 h1:X0FveUndcZ3lKbSpIC6rMYGRiQTcUVRNH6X4yYtIrlU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0/go.mod h1:IWjQYlqw4EX9jw2g3qnEPPWvCE6bS8fKzhMed1OK7c8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 h1:wuZ5uW2uhJR63zwNlqWH2W4aL4ZjeJP3o92/W+odDY4= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9/go.mod h1:/G58M2fGszCrOzvJUkDdY8O9kycodunH4VdT5oBAqls= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8= github.com/aws/aws-sdk-go-v2/service/kms v1.45.6 h1:Br3kil4j7RPW+7LoLVkYt8SuhIWlg6ylmbmzXJ7PgXY= github.com/aws/aws-sdk-go-v2/service/kms v1.45.6/go.mod h1:FKXkHzw1fJZtg1P1qoAIiwen5thz/cDRTTDCIu8ljxc= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 h1:mUI3b885qJgfqKDUSj6RgbRqLdX0wGmg8ruM03zNfQA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4/go.mod h1:6v8ukAxc7z4x4oBjGUsLnH7KGLY9Uhcgij19UJNkiMg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.6 h1:9PWl450XOG+m5lKv+qg5BXso1eLxpsZLqq7VPug5km0= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.6/go.mod h1:hwt7auGsDcaNQ8pzLgE2kCNyIWouYlAKSjuUu5Dqr7I= github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 h1:TFg6XiS7EsHN0/jpV3eVNczZi/sPIVP5jxIs+euIESQ= github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1/go.mod h1:OIezd9K0sM/64DDP4kXx/i0NdgXu6R5KE6SCsIPJsjc= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0= +github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -206,8 +207,8 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/containerd/containerd v1.7.28 h1:Nsgm1AtcmEh4AHAJ4gGlNSaKgXiNccU270Dnf81FQ3c= -github.com/containerd/containerd v1.7.28/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= +github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE= +github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= @@ -226,6 +227,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyberark/conjur-api-go v0.13.7 h1:pyjdGKYLuMEdtFklin6c+TY8AvLKePw77rbQFwATMTI= github.com/cyberark/conjur-api-go v0.13.7/go.mod h1:xGi4RCulvsc+x/jYRrxUoEShznhlKP/4hJC/4+lueFg= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -240,6 +243,8 @@ github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v28.0.4+incompatible h1:pBJSJeNd9QeIWPjRcV91RVJihd/TXB77q1ef64XEu4A= github.com/docker/cli v28.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok= @@ -423,8 +428,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.8.2 h1:CGCK+bZQLl44PYiwJweVzfpjg7bBwtuXu3AGcLiod2o= -github.com/hashicorp/go-getter v1.8.2/go.mod h1:CUTt9x2bCtJ/sV8ihgrITL3IUE+0BE1j/e4n5P/GIM4= +github.com/hashicorp/go-getter v1.8.3 h1:gIS+oTNv3kyYAvlUVgMR46MiG0bM0KuSON/KZEvRoRg= +github.com/hashicorp/go-getter v1.8.3/go.mod h1:CUTt9x2bCtJ/sV8ihgrITL3IUE+0BE1j/e4n5P/GIM4= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -624,6 +629,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 h1:KhF0WejiUTDbL5X55nXowP7zNopwpowa6qaMAWyIE+0= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33/go.mod h1:792k1RTU+5JeMXm35/e2Wgp71qPH/DmDoZrRc+EFZDk= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= @@ -810,8 +817,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -829,14 +836,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -897,6 +904,8 @@ k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= +k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= diff --git a/pkg/app/app.go b/pkg/app/app.go index aa73fbb2..47717ecc 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -586,7 +586,7 @@ func (a *App) dag(r *Run) error { } func (a *App) ListReleases(c ListConfigProvider) error { - var releases []*HelmRelease + releasesChan := make(chan []*HelmRelease, 100) err := a.ForEachState(func(run *Run) (_ bool, errs []error) { var stateReleases []*HelmRelease @@ -612,15 +612,33 @@ func (a *App) ListReleases(c ListConfigProvider) error { errs = append(errs, err) } - releases = append(releases, stateReleases...) + if len(stateReleases) > 0 { + releasesChan <- stateReleases + } return }, false, SetFilter(true)) + close(releasesChan) + + // Collect all releases from channel + var releases []*HelmRelease + for rels := range releasesChan { + releases = append(releases, rels...) + } + if err != nil { return err } + // Sort releases to ensure deterministic output order regardless of parallel execution + sort.Slice(releases, func(i, j int) bool { + if releases[i].Namespace != releases[j].Namespace { + return releases[i].Namespace < releases[j].Namespace + } + return releases[i].Name < releases[j].Name + }) + if c.Output() == "json" { err = FormatAsJson(releases) } else { @@ -743,6 +761,10 @@ func (a *App) visitStateFiles(fileOrDir string, opts LoadOpts, do func(string, s } func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.HelmState, error) { + return a.loadDesiredStateFromYamlWithBaseDir(file, "", opts...) +} + +func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, opts ...LoadOpts) (*state.HelmState, error) { var op LoadOpts if len(opts) > 0 { op = opts[0] @@ -755,6 +777,7 @@ func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.He chart: a.Chart, logger: a.Logger, remote: a.remote, + baseDir: baseDir, overrideKubeContext: a.OverrideKubeContext, overrideHelmBinary: a.OverrideHelmBinary, @@ -793,6 +816,9 @@ func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) { } bin := st.DefaultHelmBinary + if bin == "" { + bin = state.DefaultHelmBinary + } kubeconfig := a.Kubeconfig kubectx := st.HelmDefaults.KubeContext @@ -814,117 +840,256 @@ func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) { } func (a *App) visitStates(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error)) error { - noMatchInHelmfiles := true + return a.visitStatesWithContext(fileOrDir, defOpts, converge, nil) +} - err := a.visitStateFiles(fileOrDir, defOpts, func(f, d string) (retErr error) { - opts := defOpts.DeepCopy() +func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context, errChan chan error, matchChan chan bool) { + var file string + var dir string + if a.fs.DirectoryExistsAt(relPath) { + file = relPath + dir = relPath + } else { + file = filepath.Base(relPath) + dir = filepath.Dir(relPath) + } - if opts.CalleePath == "" { - opts.CalleePath = f + absd, errAbsDir := a.fs.Abs(dir) + if errAbsDir != nil { + errChan <- errAbsDir + return + } + + opts := defOpts.DeepCopy() + if opts.CalleePath == "" { + opts.CalleePath = file + } + + st, err := a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts) + if err != nil { + switch stateLoadErr := err.(type) { + case *state.StateLoadError: + switch stateLoadErr.Cause.(type) { + case *state.UndefinedEnvError: + return + default: + errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err) + return + } + default: + errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err) + return + } + } + + if st == nil { + return + } + + st.Selectors = opts.Selectors + + if len(st.Helmfiles) > 0 && !opts.Reverse { + if err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx); err != nil { + errChan <- err + return + } + } + + templated, err := st.ExecuteTemplates() + if err != nil { + errChan <- appError(fmt.Sprintf("in %s/%s: failed executing release templates in \"%s\"", dir, file, file), err) + return + } + + var errs []error + CleanWaitGroup.Add(1) + var cleanErr error + defer func() { + defer CleanWaitGroup.Done() + cleanErr = context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs) + }() + + processed, errs := converge(templated) + + if len(errs) > 0 { + errChan <- errs[0] + return + } + if cleanErr != nil { + errChan <- cleanErr + return + } + + // Report if this file had matching releases + if processed { + matchChan <- true + } + + if opts.Reverse && len(st.Helmfiles) > 0 { + if err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx); err != nil { + errChan <- err + } + } +} + +func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error { + for i, m := range st.Helmfiles { + optsForNestedState := LoadOpts{ + CalleePath: filepath.Join(absd, file), + Environment: m.Environment, + Reverse: defOpts.Reverse, + RetainValuesFiles: defOpts.RetainValuesFiles, + } + if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited { + optsForNestedState.Selectors = opts.Selectors + } else { + optsForNestedState.Selectors = m.Selectors } - st, err := a.loadDesiredStateFromYaml(f, opts) + if err := a.visitStatesWithContext(m.Path, optsForNestedState, converge, sharedCtx); err != nil { + switch err.(type) { + case *NoMatchingHelmfileError: + default: + return appError(fmt.Sprintf("in .helmfiles[%d]", i), err) + } + } + } + return nil +} - ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles} +func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error { + noMatchInHelmfiles := true - if err != nil { - switch stateLoadErr := err.(type) { - // Addresses https://github.com/roboll/helmfile/issues/279 - case *state.StateLoadError: - switch stateLoadErr.Cause.(type) { - case *state.UndefinedEnvError: - return nil + desiredStateFiles, err := a.findDesiredStateFiles(fileOrDir, defOpts) + + if len(desiredStateFiles) > 1 { + var wg sync.WaitGroup + errChan := make(chan error, len(desiredStateFiles)) + matchChan := make(chan bool, len(desiredStateFiles)) + + for _, relPath := range desiredStateFiles { + wg.Add(1) + go func(relPath string) { + defer wg.Done() + a.processStateFileParallel(relPath, defOpts, converge, sharedCtx, errChan, matchChan) + }(relPath) + } + + wg.Wait() + close(errChan) + close(matchChan) + + for err := range errChan { + if err != nil { + return err + } + } + + // Check if any files had matching releases + for range matchChan { + noMatchInHelmfiles = false + } + } else { + // Sequential processing for single file + err = a.visitStateFiles(fileOrDir, defOpts, func(f, d string) (retErr error) { + opts := defOpts.DeepCopy() + + if opts.CalleePath == "" { + opts.CalleePath = f + } + + st, err := a.loadDesiredStateFromYaml(f, opts) + + ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles} + + if err != nil { + switch stateLoadErr := err.(type) { + case *state.StateLoadError: + switch stateLoadErr.Cause.(type) { + case *state.UndefinedEnvError: + return nil + default: + return ctx.wrapErrs(err) + } default: return ctx.wrapErrs(err) } - default: - return ctx.wrapErrs(err) } - } - st.Selectors = opts.Selectors + st.Selectors = opts.Selectors - visitSubHelmfiles := func() error { - if len(st.Helmfiles) > 0 { - noMatchInSubHelmfiles := true - for i, m := range st.Helmfiles { - optsForNestedState := LoadOpts{ - CalleePath: filepath.Join(d, f), - Environment: m.Environment, - Reverse: defOpts.Reverse, - RetainValuesFiles: defOpts.RetainValuesFiles, - } - // assign parent selector to sub helm selector in legacy mode or do not inherit in experimental mode - if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited { - optsForNestedState.Selectors = opts.Selectors - } else { - optsForNestedState.Selectors = m.Selectors - } - - if err := a.visitStates(m.Path, optsForNestedState, converge); err != nil { - switch err.(type) { - case *NoMatchingHelmfileError: - - default: - return appError(fmt.Sprintf("in .helmfiles[%d]", i), err) + visitSubHelmfiles := func() error { + if len(st.Helmfiles) > 0 { + noMatchInSubHelmfiles := true + for i, m := range st.Helmfiles { + optsForNestedState := LoadOpts{ + CalleePath: filepath.Join(d, f), + Environment: m.Environment, + Reverse: defOpts.Reverse, + RetainValuesFiles: defOpts.RetainValuesFiles, + } + if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited { + optsForNestedState.Selectors = opts.Selectors + } else { + optsForNestedState.Selectors = m.Selectors + } + + if err := a.visitStates(m.Path, optsForNestedState, converge); err != nil { + switch err.(type) { + case *NoMatchingHelmfileError: + default: + return appError(fmt.Sprintf("in .helmfiles[%d]", i), err) + } + } else { + noMatchInSubHelmfiles = false } - } else { - noMatchInSubHelmfiles = false } + noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles } - noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles + return nil } + + if !opts.Reverse { + err = visitSubHelmfiles() + if err != nil { + return err + } + } + + templated, tmplErr := st.ExecuteTemplates() + if tmplErr != nil { + return appError(fmt.Sprintf("failed executing release templates in \"%s\"", f), tmplErr) + } + + var ( + processed bool + errs []error + ) + + CleanWaitGroup.Add(1) + defer func() { + defer CleanWaitGroup.Done() + cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs) + if retErr == nil { + retErr = cleanErr + } else if cleanErr != nil { + a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr) + } + }() + + processed, errs = converge(templated) + + noMatchInHelmfiles = noMatchInHelmfiles && !processed + + if opts.Reverse { + err = visitSubHelmfiles() + if err != nil { + return err + } + } + return nil - } - - if !opts.Reverse { - err = visitSubHelmfiles() - if err != nil { - return err - } - } - - templated, tmplErr := st.ExecuteTemplates() - if tmplErr != nil { - return appError(fmt.Sprintf("failed executing release templates in \"%s\"", f), tmplErr) - } - - var ( - processed bool - errs []error - ) - - // Ensure every temporary files and directories generated while running - // the converge function is clean up before exiting this function in all the three cases below: - // - This function returned nil - // - This function returned an err - // - Helmfile received SIGINT or SIGTERM while running this function - // For the last case you also need a signal handler in main.go. - // Ideally though, this CleanWaitGroup should gone and be replaced by a context cancellation propagation. - // See https://github.com/helmfile/helmfile/pull/418 for more details. - CleanWaitGroup.Add(1) - defer func() { - defer CleanWaitGroup.Done() - cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs) - if retErr == nil { - retErr = cleanErr - } else if cleanErr != nil { - a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr) - } - }() - - processed, errs = converge(templated) - - noMatchInHelmfiles = noMatchInHelmfiles && !processed - - if opts.Reverse { - err = visitSubHelmfiles() - if err != nil { - return err - } - } - - return nil - }) + }) + } if err != nil { return err @@ -961,18 +1126,18 @@ var ( func (a *App) ForEachState(do func(*Run) (bool, []error), includeTransitiveNeeds bool, o ...LoadOption) error { ctx := NewContext() - err := a.visitStatesWithSelectorsAndRemoteSupport(a.FileOrDir, func(st *state.HelmState) (bool, []error) { + err := a.visitStatesWithSelectorsAndRemoteSupportWithContext(a.FileOrDir, func(st *state.HelmState) (bool, []error) { helm, err := a.getHelm(st) if err != nil { return false, []error{err} } - run, err := NewRun(st, helm, ctx) + run, err := NewRun(st, helm, &ctx) if err != nil { return false, []error{err} } return do(run) - }, includeTransitiveNeeds, o...) + }, includeTransitiveNeeds, &ctx, o...) return err } @@ -1076,7 +1241,7 @@ type Opts struct { DAGEnabled bool } -func (a *App) visitStatesWithSelectorsAndRemoteSupport(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, opt ...LoadOption) error { +func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error { opts := LoadOpts{ Selectors: a.Selectors, } @@ -1126,7 +1291,7 @@ func (a *App) visitStatesWithSelectorsAndRemoteSupport(fileOrDir string, converg return f(st) } - return a.visitStates(fileOrDir, opts, fHelmStatsWithOverrides) + return a.visitStatesWithContext(fileOrDir, opts, fHelmStatsWithOverrides, sharedCtx) } func processFilteredReleases(st *state.HelmState, converge func(st *state.HelmState) []error, includeTransitiveNeeds bool) (bool, []error) { diff --git a/pkg/app/app_diff_test.go b/pkg/app/app_diff_test.go index d9f7b691..3f353e92 100644 --- a/pkg/app/app_diff_test.go +++ b/pkg/app/app_diff_test.go @@ -415,4 +415,28 @@ releases: }, }) }) + + t.Run("show diff on changed selected release with reinstall", func(t *testing.T) { + check(t, testcase{ + helmfile: ` +releases: +- name: a + chart: incubator/raw + namespace: default + updateStrategy: reinstallIfForbidden +- name: b + chart: incubator/raw + namespace: default +`, + selectors: []string{"name=a"}, + lists: map[exectest.ListKey]string{ + {Filter: "^a$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +foo 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default +`, + }, + diffed: []exectest.Release{ + {Name: "a", Flags: []string{"--kube-context", "default", "--namespace", "default", "--reset-values"}}, + }, + }) + }) } diff --git a/pkg/app/app_gethelm_test.go b/pkg/app/app_gethelm_test.go new file mode 100644 index 00000000..3703e0ad --- /dev/null +++ b/pkg/app/app_gethelm_test.go @@ -0,0 +1,51 @@ +package app + +import ( + goContext "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/helmfile/helmfile/pkg/state" +) + +// TestGetHelmWithEmptyDefaultHelmBinary tests that getHelm properly defaults to "helm" +// when st.DefaultHelmBinary is empty. This addresses the issue where base files with +// environment secrets would fail with "exec: no command" error. +// +// Background: When a base file has environment secrets but doesn't specify helmBinary, +// the state.DefaultHelmBinary would be empty, causing helmexec.New to be called with +// an empty string, which results in "error determining helm version: exec: no command". +// +// The fix in app.getHelm() ensures that when st.DefaultHelmBinary is empty, it defaults +// to state.DefaultHelmBinary ("helm"). +func TestGetHelmWithEmptyDefaultHelmBinary(t *testing.T) { + // Test that app.getHelm() handles empty DefaultHelmBinary correctly by applying a default + st := &state.HelmState{ + ReleaseSetSpec: state.ReleaseSetSpec{ + DefaultHelmBinary: "", // Empty, as would be the case for base files + }, + } + + logger := newAppTestLogger() + app := &App{ + OverrideHelmBinary: "", + OverrideKubeContext: "", + Logger: logger, + Env: "default", + ctx: goContext.Background(), + } + + // This should NOT fail because app.getHelm() defaults empty DefaultHelmBinary to "helm" + helm, err := app.getHelm(st) + + // Verify that no error occurred - the fix in app.getHelm() prevents the "exec: no command" error + require.NoError(t, err, "getHelm should not fail when DefaultHelmBinary is empty (fix should apply default)") + + // Verify that a valid helm execer was returned + require.NotNil(t, helm, "getHelm should return a valid helm execer") + + // Verify that the helm version is accessible (confirms the helm binary is valid) + version := helm.GetVersion() + require.NotNil(t, version, "helm version should be accessible") +} diff --git a/pkg/app/app_list_test.go b/pkg/app/app_list_test.go index d7775a83..edda7d33 100644 --- a/pkg/app/app_list_test.go +++ b/pkg/app/app_list_test.go @@ -44,17 +44,17 @@ environments: --- releases: - name: logging - chart: incubator/raw + chart: incubator/raw namespace: kube-system - name: kubernetes-external-secrets - chart: incubator/raw + chart: incubator/raw namespace: kube-system needs: - kube-system/logging - name: external-secrets - chart: incubator/raw + chart: incubator/raw namespace: default labels: app: test @@ -62,7 +62,7 @@ releases: - kube-system/kubernetes-external-secrets - name: my-release - chart: incubator/raw + chart: incubator/raw namespace: default labels: app: test @@ -72,17 +72,17 @@ releases: # Disabled releases are treated as missing - name: disabled - chart: incubator/raw + chart: incubator/raw namespace: kube-system installed: false - name: test2 - chart: incubator/raw + chart: incubator/raw needs: - kube-system/disabled - name: test3 - chart: incubator/raw + chart: incubator/raw needs: - test2 `, @@ -111,7 +111,7 @@ releases: "/path/to/helmfile.d/helmfile_3.yaml": ` releases: - name: global - chart: incubator/raw + chart: incubator/raw namespace: kube-system `, } @@ -160,16 +160,16 @@ releases: check(t, testcase{ environment: "default", expected: `NAME NAMESPACE ENABLED INSTALLED LABELS CHART VERSION -logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw -kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw +test2 true true chart:raw,name:test2,namespace: incubator/raw +test3 true true chart:raw,name:test3,namespace: incubator/raw external-secrets default true true app:test,chart:raw,name:external-secrets,namespace:default incubator/raw my-release default true true app:test,chart:raw,name:my-release,namespace:default incubator/raw disabled kube-system true false chart:raw,name:disabled,namespace:kube-system incubator/raw -test2 true true chart:raw,name:test2,namespace: incubator/raw -test3 true true chart:raw,name:test3,namespace: incubator/raw +global kube-system true true chart:raw,name:global,namespace:kube-system incubator/raw +kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw +logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw cache my-app true true app:test,chart:redis,name:cache,namespace:my-app bitnami/redis 17.0.7 database my-app true true chart:postgres,name:database,namespace:my-app bitnami/postgres 11.6.22 -global kube-system true true chart:raw,name:global,namespace:kube-system incubator/raw `, }, cfg) }) @@ -207,13 +207,13 @@ database my-app true true chart:postgres,name:database,namespace:my-a environment: "shared", // 'global' release has no environments, so is still excluded expected: `NAME NAMESPACE ENABLED INSTALLED LABELS CHART VERSION -logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw -kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw +test2 true true chart:raw,name:test2,namespace: incubator/raw +test3 true true chart:raw,name:test3,namespace: incubator/raw external-secrets default true true app:test,chart:raw,name:external-secrets,namespace:default incubator/raw my-release default true true app:test,chart:raw,name:my-release,namespace:default incubator/raw disabled kube-system true false chart:raw,name:disabled,namespace:kube-system incubator/raw -test2 true true chart:raw,name:test2,namespace: incubator/raw -test3 true true chart:raw,name:test3,namespace: incubator/raw +kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw +logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw cache my-app true true app:test,chart:redis,name:cache,namespace:my-app bitnami/redis 17.0.7 database my-app true true chart:postgres,name:database,namespace:my-app bitnami/postgres 11.6.22 `, @@ -266,7 +266,8 @@ releases: defer func() { os.Stdout = stdout }() var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, diff --git a/pkg/app/app_parallel_test.go b/pkg/app/app_parallel_test.go new file mode 100644 index 00000000..7b59ad2b --- /dev/null +++ b/pkg/app/app_parallel_test.go @@ -0,0 +1,155 @@ +package app + +import ( + "bytes" + "testing" + + "github.com/helmfile/vals" + + ffs "github.com/helmfile/helmfile/pkg/filesystem" + "github.com/helmfile/helmfile/pkg/helmexec" + "github.com/helmfile/helmfile/pkg/testhelper" + "github.com/helmfile/helmfile/pkg/testutil" +) + +// TestParallelProcessingDeterministicOutput verifies that ListReleases produces +// consistent sorted output even with parallel processing of multiple helmfile.d files +func TestParallelProcessingDeterministicOutput(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.d/z-last.yaml": ` +releases: +- name: zulu-release + chart: stable/chart-z + namespace: ns-z +`, + "/path/to/helmfile.d/a-first.yaml": ` +releases: +- name: alpha-release + chart: stable/chart-a + namespace: ns-a +`, + "/path/to/helmfile.d/m-middle.yaml": ` +releases: +- name: mike-release + chart: stable/chart-m + namespace: ns-m +`, + } + + // Run ListReleases multiple times to verify consistent ordering + var outputs []string + for i := 0; i < 5; i++ { + 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", + Env: "default", + Logger: logger, + valsRuntime: valsRuntime, + FileOrDir: "/path/to/helmfile.d", + }, files) + + expectNoCallsToHelm(app) + + err = app.ListReleases(configImpl{ + skipCharts: false, + output: "table", + }) + + if err != nil { + t.Fatalf("unexpected error on iteration %d: %v", i, err) + } + + outputs = append(outputs, buffer.String()) + } + + // Verify all outputs are identical (deterministic) + firstOutput := outputs[0] + for i, output := range outputs[1:] { + if output != firstOutput { + t.Errorf("output %d differs from first output (non-deterministic ordering)", i+1) + t.Logf("First output:\n%s", firstOutput) + t.Logf("Output %d:\n%s", i+1, output) + } + } +} + +// TestMultipleHelmfileDFiles verifies that all files in helmfile.d are processed +func TestMultipleHelmfileDFiles(t *testing.T) { + files := map[string]string{ + "/path/to/helmfile.d/001-app.yaml": ` +releases: +- name: app1 + chart: stable/app1 + namespace: default +`, + "/path/to/helmfile.d/002-db.yaml": ` +releases: +- name: db1 + chart: stable/postgresql + namespace: default +`, + "/path/to/helmfile.d/003-cache.yaml": ` +releases: +- name: cache1 + chart: stable/redis + namespace: default +`, + } + + 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", + Env: "default", + Logger: logger, + valsRuntime: valsRuntime, + FileOrDir: "/path/to/helmfile.d", + }, files) + + expectNoCallsToHelm(app) + + // Capture stdout since ListReleases outputs to stdout + out, err := testutil.CaptureStdout(func() { + err := app.ListReleases(configImpl{ + skipCharts: false, + output: "json", + }) + if err != nil { + t.Logf("ListReleases error: %v", err) + } + }) + + if err != nil { + t.Fatalf("unexpected error capturing output: %v", err) + } + + // Verify all three releases are present in output (JSON format) + if !bytes.Contains([]byte(out), []byte("app1")) { + t.Errorf("app1 release not found in output:\n%s", out) + } + if !bytes.Contains([]byte(out), []byte("db1")) { + t.Errorf("db1 release not found in output:\n%s", out) + } + if !bytes.Contains([]byte(out), []byte("cache1")) { + t.Errorf("cache1 release not found in output:\n%s", out) + } +} diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index c9216cb8..2dcf5931 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -220,6 +220,97 @@ releases: } } +func TestUpdateStrategyParamValidation(t *testing.T) { + cases := []struct { + files map[string]string + updateStrategy string + isValid bool + }{ + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: reinstallIfForbidden +`}, + "reinstallIfForbidden", + true}, + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: reinstallIfForbidden +`}, + "reinstallIfForbidden", + true}, + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: +`}, + "", + true}, + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: foo +`}, + "foo", + false}, + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: reinstal +`}, + "reinstal", + false}, + {map[string]string{ + "/path/to/helmfile.yaml": `releases: +- name: zipkin + chart: stable/zipkin + updateStrategy: reinstall1 +`}, + "reinstall1", + false}, + } + + for idx, c := range cases { + fs := testhelper.NewTestFs(c.files) + app := &App{ + OverrideHelmBinary: DefaultHelmBinary, + OverrideKubeContext: "default", + Logger: newAppTestLogger(), + Namespace: "", + Env: "default", + FileOrDir: "helmfile.yaml", + } + + expectNoCallsToHelm(app) + + app = injectFs(app, fs) + + err := app.ForEachState( + Noop, + false, + SetFilter(true), + ) + + if c.isValid && err != nil { + t.Errorf("[case: %d] Unexpected error for valid case: %v", idx, err) + } else if !c.isValid { + var invalidUpdateStrategy state.InvalidUpdateStrategyError + invalidUpdateStrategy.UpdateStrategy = c.updateStrategy + if err == nil { + t.Errorf("[case: %d] Expected error for invalid case", idx) + } else if !strings.Contains(err.Error(), invalidUpdateStrategy.Error()) { + t.Errorf("[case: %d] Unexpected error returned for invalid case\ngot: %v\nexpected underlying error: %s", idx, err, invalidUpdateStrategy.Error()) + } + } + } +} + func TestVisitDesiredStatesWithReleasesFiltered_Issue1008_MissingNonDefaultEnvInBase(t *testing.T) { files := map[string]string{ "/path/to/base.yaml": ` @@ -2639,7 +2730,8 @@ releases: } var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { @@ -2711,7 +2803,8 @@ releases: } var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") valsRuntime, err := vals.New(vals.Options{CacheSize: 32}) if err != nil { @@ -3076,6 +3169,97 @@ baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 defau concurrency: 1, }, // + // install with upgrade with reinstallIfForbidden + // + { + name: "install-with-upgrade-with-reinstallIfForbidden", + loc: location(), + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: baz + chart: stable/mychart3 + disableValidationOnInstall: true + updateStrategy: reinstallIfForbidden +- name: foo + chart: stable/mychart1 + disableValidationOnInstall: true + needs: + - bar +- name: bar + chart: stable/mychart2 + disableValidation: true + updateStrategy: reinstallIfForbidden +`, + }, + diffs: map[exectest.DiffKey]error{ + {Name: "baz", Chart: "stable/mychart3", Flags: "--kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + {Name: "foo", Chart: "stable/mychart1", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + {Name: "bar", Chart: "stable/mychart2", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^foo$", Flags: listFlags("", "default")}: ``, + {Filter: "^bar$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +bar 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart2-3.1.0 3.1.0 default +`, + {Filter: "^baz$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 default +`, + }, + upgraded: []exectest.Release{ + {Name: "baz", Flags: []string{"--kube-context", "default"}}, + {Name: "bar", Flags: []string{"--kube-context", "default"}}, + {Name: "foo", Flags: []string{"--kube-context", "default"}}, + }, + deleted: []exectest.Release{}, + concurrency: 1, + }, + // + // install with upgrade and --skip-diff-on-install with reinstallIfForbidden + // + { + name: "install-with-upgrade-with-skip-diff-on-install-with-reinstallIfForbidden", + loc: location(), + skipDiffOnInstall: true, + files: map[string]string{ + "/path/to/helmfile.yaml": ` +releases: +- name: baz + chart: stable/mychart3 + disableValidationOnInstall: true + updateStrategy: reinstallIfForbidden +- name: foo + chart: stable/mychart1 + disableValidationOnInstall: true + needs: + - bar +- name: bar + chart: stable/mychart2 + disableValidation: true + updateStrategy: reinstallIfForbidden +`, + }, + diffs: map[exectest.DiffKey]error{ + {Name: "baz", Chart: "stable/mychart3", Flags: "--kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + {Name: "bar", Chart: "stable/mychart2", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2}, + }, + lists: map[exectest.ListKey]string{ + {Filter: "^foo$", Flags: listFlags("", "default")}: ``, + {Filter: "^bar$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +bar 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart2-3.1.0 3.1.0 default +`, + {Filter: "^baz$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE +baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 default +`, + }, + upgraded: []exectest.Release{ + {Name: "baz", Flags: []string{"--kube-context", "default"}}, + {Name: "bar", Flags: []string{"--kube-context", "default"}}, + {Name: "foo", Flags: []string{"--kube-context", "default"}}, + }, + concurrency: 1, + }, + // // upgrades // { @@ -3772,7 +3956,7 @@ releases: } for flagIdx := range wantDeletes[relIdx].Flags { if wantDeletes[relIdx].Flags[flagIdx] != helm.Deleted[relIdx].Flags[flagIdx] { - t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Deleted[relIdx].Flags[flagIdx], wantDeletes[relIdx].Flags[flagIdx]) + t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Deleted[relIdx].Flags[flagIdx], wantDeletes[relIdx].Flags[flagIdx]) } } } @@ -3884,7 +4068,8 @@ releases: defer func() { os.Stdout = stdout }() var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, @@ -3933,7 +4118,8 @@ releases: defer func() { os.Stdout = stdout }() var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, @@ -3995,7 +4181,8 @@ releases: defer func() { os.Stdout = stdout }() var buffer bytes.Buffer - logger := helmexec.NewLogger(&buffer, "debug") + syncWriter := testhelper.NewSyncWriter(&buffer) + logger := helmexec.NewLogger(syncWriter, "debug") app := appWithFs(&App{ OverrideHelmBinary: DefaultHelmBinary, diff --git a/pkg/app/context.go b/pkg/app/context.go index e211dd0b..3f9c65db 100644 --- a/pkg/app/context.go +++ b/pkg/app/context.go @@ -1,20 +1,26 @@ package app import ( + "sync" + "github.com/helmfile/helmfile/pkg/state" ) type Context struct { updatedRepos map[string]bool + mu sync.Mutex } func NewContext() Context { return Context{ - updatedRepos: map[string]bool{}, + updatedRepos: make(map[string]bool), } } -func (ctx Context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) error { +func (ctx *Context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) error { + ctx.mu.Lock() + defer ctx.mu.Unlock() + updated, err := st.SyncRepos(helm, ctx.updatedRepos) for _, r := range updated { diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go new file mode 100644 index 00000000..d30bb2d5 --- /dev/null +++ b/pkg/app/context_test.go @@ -0,0 +1,169 @@ +package app + +import ( + "sync" + "testing" +) + +// TestContextConcurrentAccess verifies that Context is thread-safe +// when accessed concurrently from multiple goroutines +func TestContextConcurrentAccess(t *testing.T) { + ctx := &Context{ + updatedRepos: make(map[string]bool), + } + + const numGoroutines = 100 + const numReposPerGoroutine = 10 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + // Launch multiple goroutines that concurrently update the repos map + for i := 0; i < numGoroutines; i++ { + go func(goroutineID int) { + defer wg.Done() + + for j := 0; j < numReposPerGoroutine; j++ { + repoKey := "repo-" + string(rune('0'+goroutineID)) + "-" + string(rune('0'+j)) + + ctx.mu.Lock() + ctx.updatedRepos[repoKey] = true + ctx.mu.Unlock() + } + }(i) + } + + wg.Wait() + + // Verify the map has entries (exact count may vary due to key overlap) + ctx.mu.Lock() + defer ctx.mu.Unlock() + + if len(ctx.updatedRepos) == 0 { + t.Error("expected non-empty updatedRepos after concurrent updates") + } +} + +// TestContextInitialization verifies Context is created with proper initial state +func TestContextInitialization(t *testing.T) { + ctx := NewContext() + + if ctx.updatedRepos == nil { + t.Error("updatedRepos map is nil") + } + + // Verify initial state is empty + if len(ctx.updatedRepos) != 0 { + t.Errorf("expected empty updatedRepos, got %d entries", len(ctx.updatedRepos)) + } +} + +// TestContextPointerSemantics verifies that Context is correctly used as a pointer +// to prevent mutex copying issues +func TestContextPointerSemantics(t *testing.T) { + // Create a Context + ctx := &Context{ + updatedRepos: make(map[string]bool), + } + + // Create a Run with the context + run := &Run{ + ctx: ctx, + } + + // Verify that run.ctx points to the same Context + if run.ctx != ctx { + t.Error("Run.ctx does not point to the same Context instance") + } + + // Modify the context through run.ctx and verify the original is affected + repoKey := "test-repo=https://charts.example.com" + + run.ctx.mu.Lock() + run.ctx.updatedRepos[repoKey] = true + run.ctx.mu.Unlock() + + // Check that the original context was modified + ctx.mu.Lock() + found := ctx.updatedRepos[repoKey] + ctx.mu.Unlock() + + if !found { + t.Error("original context was not modified (pointer semantics broken)") + } +} + +// TestContextMutexNotCopied verifies that using pointer receivers prevents mutex copying +func TestContextMutexNotCopied(t *testing.T) { + ctx1 := &Context{ + updatedRepos: make(map[string]bool), + } + + // Assign to another variable (should be pointer copy, not value copy) + ctx2 := ctx1 + + // Modify through ctx2 + ctx2.mu.Lock() + ctx2.updatedRepos["test"] = true + ctx2.mu.Unlock() + + // Verify ctx1 sees the change (they share the same underlying data) + ctx1.mu.Lock() + found := ctx1.updatedRepos["test"] + ctx1.mu.Unlock() + + if !found { + t.Error("ctx1 and ctx2 don't share the same data (value copy instead of pointer copy)") + } +} + +// TestContextConcurrentReadWrite tests concurrent reads and writes to the Context +func TestContextConcurrentReadWrite(t *testing.T) { + ctx := &Context{ + updatedRepos: make(map[string]bool), + } + + const numRepos = 10 + const numGoroutinesPerRepo = 10 + + var wg sync.WaitGroup + + // Launch multiple goroutines for each repo + for i := 0; i < numRepos; i++ { + repoKey := "repo-" + string(rune('0'+i)) + "=https://example.com" + + for j := 0; j < numGoroutinesPerRepo; j++ { + wg.Add(1) + go func(key string) { + defer wg.Done() + + // Write + ctx.mu.Lock() + ctx.updatedRepos[key] = true + ctx.mu.Unlock() + + // Read + ctx.mu.Lock() + _ = ctx.updatedRepos[key] + ctx.mu.Unlock() + }(repoKey) + } + } + + wg.Wait() + + // Verify repos are in the map + ctx.mu.Lock() + defer ctx.mu.Unlock() + + if len(ctx.updatedRepos) != numRepos { + t.Errorf("expected %d repos, got %d", numRepos, len(ctx.updatedRepos)) + } + + // Verify all are marked as true + for key, value := range ctx.updatedRepos { + if !value { + t.Errorf("repo %s is not marked as true", key) + } + } +} diff --git a/pkg/app/desired_state_file_loader.go b/pkg/app/desired_state_file_loader.go index be176bf9..0b243f3b 100644 --- a/pkg/app/desired_state_file_loader.go +++ b/pkg/app/desired_state_file_loader.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "slices" "dario.cat/mergo" "github.com/helmfile/vals" @@ -33,6 +34,7 @@ type desiredStateLoader struct { namespace string chart string fs *filesystem.FileSystem + baseDir string // Base directory for resolving relative paths, empty means use cwd getHelm func(*state.HelmState) (helmexec.Interface, error) @@ -66,7 +68,22 @@ func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, e } } - st, err := ld.loadFileWithOverrides(nil, overrodeEnv, filepath.Dir(f), filepath.Base(f), true) + // Resolve file path relative to baseDir if provided + var dir, file string + if ld.baseDir != "" { + // If baseDir is set, resolve all paths relative to it + if !filepath.IsAbs(f) { + f = filepath.Join(ld.baseDir, f) + } + dir = filepath.Dir(f) + file = filepath.Base(f) + } else { + // Use original behavior + dir = filepath.Dir(f) + file = filepath.Base(f) + } + + st, err := ld.loadFileWithOverrides(nil, overrodeEnv, dir, file, true) if err != nil { return nil, err } @@ -285,6 +302,18 @@ func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, ba } } + // Validate updateStrategy value if set in the releases + for i := range finalState.Releases { + if finalState.Releases[i].UpdateStrategy != "" { + if !slices.Contains(state.ValidUpdateStrategyValues, finalState.Releases[i].UpdateStrategy) { + return nil, &state.StateLoadError{ + Msg: fmt.Sprintf("failed to read %s", finalState.FilePath), + Cause: &state.InvalidUpdateStrategyError{UpdateStrategy: finalState.Releases[i].UpdateStrategy}, + } + } + } + } + finalState.OrginReleases = finalState.Releases return finalState, nil } diff --git a/pkg/app/init.go b/pkg/app/init.go index 54fbb57b..d94a1828 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -18,7 +18,7 @@ import ( const ( HelmRequiredVersion = "v3.18.6" - HelmDiffRecommendedVersion = "v3.13.0" + HelmDiffRecommendedVersion = "v3.13.1" HelmRecommendedVersion = "v3.19.0" HelmSecretsRecommendedVersion = "v4.6.5" HelmGitRecommendedVersion = "v1.3.0" diff --git a/pkg/app/run.go b/pkg/app/run.go index 1860ad1d..5c9ee746 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -16,14 +16,14 @@ import ( type Run struct { state *state.HelmState helm helmexec.Interface - ctx Context + ctx *Context ReleaseToChart map[state.PrepareChartKey]string Ask func(string) bool } -func NewRun(st *state.HelmState, helm helmexec.Interface, ctx Context) (*Run, error) { +func NewRun(st *state.HelmState, helm helmexec.Interface, ctx *Context) (*Run, error) { if helm == nil { return nil, fmt.Errorf("Assertion failed: helmexec.Interface must not be nil") } diff --git a/pkg/app/testdata/app_diff_test/show_diff_on_changed_selected_release_with_reinstall b/pkg/app/testdata/app_diff_test/show_diff_on_changed_selected_release_with_reinstall new file mode 100644 index 00000000..66b419fa --- /dev/null +++ b/pkg/app/testdata/app_diff_test/show_diff_on_changed_selected_release_with_reinstall @@ -0,0 +1,11 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[]} +1 release(s) matching name=a found in helmfile.yaml + +processing 1 groups of releases in this order: +GROUP RELEASES +1 default/default/a + +processing releases in group 1/1: default/default/a +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_list_test/default_environment_includes_all_releases b/pkg/app/testdata/app_list_test/default_environment_includes_all_releases index 75efff96..8dc33416 100644 --- a/pkg/app/testdata/app_list_test/default_environment_includes_all_releases +++ b/pkg/app/testdata/app_list_test/default_environment_includes_all_releases @@ -1,16 +1,7 @@ found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml -processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" +merged environment: &{default map[] map[]} +merged environment: &{default map[] map[]} +merged environment: &{default map[] map[]} merged environment: &{default map[] map[]} merged environment: &{default map[] map[]} WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs -changing working directory back to "/path/to" -processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{default map[] map[]} -merged environment: &{default map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{default map[] map[]} -changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_list_test/fail_on_unknown_environment b/pkg/app/testdata/app_list_test/fail_on_unknown_environment index 9eccd0c3..9bc547ae 100644 --- a/pkg/app/testdata/app_list_test/fail_on_unknown_environment +++ b/pkg/app/testdata/app_list_test/fail_on_unknown_environment @@ -1,13 +1,4 @@ found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml -processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" merged environment: &{staging map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" merged environment: &{staging map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" merged environment: &{staging map[] map[]} -changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_multiple_files b/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_multiple_files index b161220b..1df432f2 100644 --- a/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_multiple_files +++ b/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_multiple_files @@ -1,16 +1,7 @@ found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml -processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" +merged environment: &{shared map[] map[]} +merged environment: &{shared map[] map[]} +merged environment: &{shared map[] map[]} merged environment: &{shared map[] map[]} merged environment: &{shared map[] map[]} WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs -changing working directory back to "/path/to" -processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{shared map[] map[]} -merged environment: &{shared map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{shared map[] map[]} -changing working directory back to "/path/to" diff --git a/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_one_file_only b/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_one_file_only index a214f41f..57528ceb 100644 --- a/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_one_file_only +++ b/pkg/app/testdata/app_list_test/filters_releases_for_environment_used_in_one_file_only @@ -1,14 +1,5 @@ found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml -processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{test map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" merged environment: &{test map[] map[]} merged environment: &{test map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" merged environment: &{test map[] map[]} -changing working directory back to "/path/to" +merged environment: &{test map[] map[]} diff --git a/pkg/app/testdata/app_list_test/list_releases_matching_selector_and_environment b/pkg/app/testdata/app_list_test/list_releases_matching_selector_and_environment index 41a5321e..7aa29736 100644 --- a/pkg/app/testdata/app_list_test/list_releases_matching_selector_and_environment +++ b/pkg/app/testdata/app_list_test/list_releases_matching_selector_and_environment @@ -1,15 +1,6 @@ found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml -processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" +merged environment: &{development map[] map[]} +merged environment: &{development map[] map[]} merged environment: &{development map[] map[]} merged environment: &{development map[] map[]} WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs -changing working directory back to "/path/to" -processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{development map[] map[]} -changing working directory back to "/path/to" -processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d" -changing working directory to "/path/to/helmfile.d" -merged environment: &{development map[] map[]} -changing working directory back to "/path/to" diff --git a/pkg/app/testdata/testapply/install-with-upgrade-with-reinstallifforbidden/log b/pkg/app/testdata/testapply/install-with-upgrade-with-reinstallifforbidden/log new file mode 100644 index 00000000..22fc346b --- /dev/null +++ b/pkg/app/testdata/testapply/install-with-upgrade-with-reinstallifforbidden/log @@ -0,0 +1,35 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[]} +3 release(s) found in helmfile.yaml + +Affected releases are: + bar (stable/mychart2) UPDATED + baz (stable/mychart3) UPDATED + foo (stable/mychart1) UPDATED + +invoking preapply hooks for 2 groups of releases in this order: +GROUP RELEASES +1 default//foo +2 default//baz, default//bar + +invoking preapply hooks for releases in group 1/2: default//foo +invoking preapply hooks for releases in group 2/2: default//baz, default//bar +processing 2 groups of releases in this order: +GROUP RELEASES +1 default//baz, default//bar +2 default//foo + +processing releases in group 1/2: default//baz, default//bar +update strategy - sync success +update strategy - sync success +processing releases in group 2/2: default//foo +getting deployed release version failed: Failed to get the version for: mychart1 + +UPDATED RELEASES: +NAME NAMESPACE CHART VERSION DURATION +baz stable/mychart3 3.1.0 0s +bar stable/mychart2 3.1.0 0s +foo stable/mychart1 0s + +changing working directory back to "/path/to" diff --git a/pkg/app/testdata/testapply/install-with-upgrade-with-skip-diff-on-install-with-reinstallifforbidden/log b/pkg/app/testdata/testapply/install-with-upgrade-with-skip-diff-on-install-with-reinstallifforbidden/log new file mode 100644 index 00000000..22fc346b --- /dev/null +++ b/pkg/app/testdata/testapply/install-with-upgrade-with-skip-diff-on-install-with-reinstallifforbidden/log @@ -0,0 +1,35 @@ +processing file "helmfile.yaml" in directory "." +changing working directory to "/path/to" +merged environment: &{default map[] map[]} +3 release(s) found in helmfile.yaml + +Affected releases are: + bar (stable/mychart2) UPDATED + baz (stable/mychart3) UPDATED + foo (stable/mychart1) UPDATED + +invoking preapply hooks for 2 groups of releases in this order: +GROUP RELEASES +1 default//foo +2 default//baz, default//bar + +invoking preapply hooks for releases in group 1/2: default//foo +invoking preapply hooks for releases in group 2/2: default//baz, default//bar +processing 2 groups of releases in this order: +GROUP RELEASES +1 default//baz, default//bar +2 default//foo + +processing releases in group 1/2: default//baz, default//bar +update strategy - sync success +update strategy - sync success +processing releases in group 2/2: default//foo +getting deployed release version failed: Failed to get the version for: mychart1 + +UPDATED RELEASES: +NAME NAMESPACE CHART VERSION DURATION +baz stable/mychart3 3.1.0 0s +bar stable/mychart2 3.1.0 0s +foo stable/mychart1 0s + +changing working directory back to "/path/to" diff --git a/pkg/exectest/helm.go b/pkg/exectest/helm.go index 2af1316d..79cf6601 100644 --- a/pkg/exectest/helm.go +++ b/pkg/exectest/helm.go @@ -56,9 +56,10 @@ type Release struct { } type Affected struct { - Upgraded []*Release - Deleted []*Release - Failed []*Release + Upgraded []*Release + Reinstalled []*Release + Deleted []*Release + Failed []*Release } func (helm *Helm) UpdateDeps(chart string) error { @@ -107,7 +108,24 @@ func (helm *Helm) RegistryLogin(name, username, password, caFile, certFile, keyF return nil } func (helm *Helm) SyncRelease(context helmexec.HelmContext, name, chart, namespace string, flags ...string) error { - if strings.Contains(name, "error") { + if strings.Contains(name, "forbidden") { + releaseExists := false + for _, release := range helm.Releases { + if release.Name == name { + releaseExists = true + } + } + releaseDeleted := false + for _, release := range helm.Deleted { + if release.Name == name { + releaseDeleted = true + } + } + // Only fail if the release is present in the helm.Releases to simulate a forbidden update if it exists + if releaseExists && !releaseDeleted { + return fmt.Errorf("cannot patch %q with kind StatefulSet: StatefulSet.apps %q is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden", name, name) + } + } else if strings.Contains(name, "error") { return errors.New("error") } helm.sync(helm.ReleasesMutex, func() { diff --git a/pkg/helmexec/exec.go b/pkg/helmexec/exec.go index 410050d7..db168839 100644 --- a/pkg/helmexec/exec.go +++ b/pkg/helmexec/exec.go @@ -7,14 +7,17 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strconv" "strings" "sync" + "unicode" "github.com/Masterminds/semver/v3" "github.com/helmfile/chartify" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/plugin" @@ -262,8 +265,106 @@ func (helm *execer) RegistryLogin(repository, username, password, caFile, certFi return err } +// toKebabCase converts a PascalCase or camelCase string to kebab-case. +// e.g., "SkipRefresh" -> "skip-refresh", "KubeContext" -> "kube-context" +func toKebabCase(s string) string { + var result strings.Builder + for i, r := range s { + if i > 0 && unicode.IsUpper(r) { + result.WriteRune('-') + } + result.WriteRune(unicode.ToLower(r)) + } + return result.String() +} + +// getSupportedDependencyFlags returns a map of supported flags for helm dependency commands. +// It uses reflection on helm's action.Dependency and cli.EnvSettings structs to +// dynamically determine which flags are supported, avoiding hardcoded lists. +func getSupportedDependencyFlags() map[string]bool { + supported := make(map[string]bool) + + // Get global flags from cli.EnvSettings + envSettings := cli.New() + envType := reflect.TypeOf(*envSettings) + for i := 0; i < envType.NumField(); i++ { + field := envType.Field(i) + if field.IsExported() { + flagName := "--" + toKebabCase(field.Name) + supported[flagName] = true + } + } + + // Add namespace short form + supported["-n"] = true + + // Get dependency-specific flags from action.Dependency + dep := action.NewDependency() + depType := reflect.TypeOf(*dep) + for i := 0; i < depType.NumField(); i++ { + field := depType.Field(i) + if field.IsExported() { + flagName := "--" + toKebabCase(field.Name) + supported[flagName] = true + } + } + + return supported +} + +// Cache of supported flags, initialized once +var ( + supportedDependencyFlagsOnce sync.Once + supportedDependencyFlags map[string]bool +) + +// filterDependencyUnsupportedFlags filters flags to only those supported by helm dependency commands. +// Uses reflection on helm's action.Dependency and cli.EnvSettings structs to dynamically +// determine supported flags, avoiding hardcoded lists. +func filterDependencyUnsupportedFlags(flags []string) []string { + if len(flags) == 0 { + return flags + } + + // Initialize supported flags map once + supportedDependencyFlagsOnce.Do(func() { + supportedDependencyFlags = getSupportedDependencyFlags() + }) + + filtered := make([]string, 0, len(flags)) + for _, flag := range flags { + // Extract flag name without value (e.g., "--dry-run=server" -> "--dry-run") + flagName := flag + if idx := strings.Index(flag, "="); idx != -1 { + flagName = flag[:idx] + } + + // Check if this flag or any prefix of it is supported + supported := false + for supportedFlag := range supportedDependencyFlags { + if strings.HasPrefix(flagName, supportedFlag) { + supported = true + break + } + } + + if supported { + filtered = append(filtered, flag) + } + } + return filtered +} + func (helm *execer) BuildDeps(name, chart string, flags ...string) error { helm.logger.Infof("Building dependency release=%v, chart=%v", name, chart) + + // Filter out template/install/upgrade-specific flags while preserving global flags + savedExtra := helm.extra + helm.extra = filterDependencyUnsupportedFlags(helm.extra) + defer func() { + helm.extra = savedExtra + }() + args := []string{ "dependency", "build", @@ -279,6 +380,14 @@ func (helm *execer) BuildDeps(name, chart string, flags ...string) error { func (helm *execer) UpdateDeps(chart string) error { helm.logger.Infof("Updating dependency %v", chart) + + // Filter out template/install/upgrade-specific flags while preserving global flags + savedExtra := helm.extra + helm.extra = filterDependencyUnsupportedFlags(helm.extra) + defer func() { + helm.extra = savedExtra + }() + out, err := helm.exec([]string{"dependency", "update", chart}, map[string]string{}, nil) helm.info(out) return err diff --git a/pkg/helmexec/exec_flag_filtering_test.go b/pkg/helmexec/exec_flag_filtering_test.go new file mode 100644 index 00000000..accbcc3a --- /dev/null +++ b/pkg/helmexec/exec_flag_filtering_test.go @@ -0,0 +1,261 @@ +package helmexec + +import ( + "reflect" + "testing" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli" +) + +// TestFilterDependencyFlags_AllGlobalFlags verifies that all global flags +// from cli.EnvSettings are preserved by the filter +func TestFilterDependencyFlags_AllGlobalFlags(t *testing.T) { + // Get all expected global flag names using reflection + envSettings := cli.New() + envType := reflect.TypeOf(*envSettings) + + var expectedFlags []string + for i := 0; i < envType.NumField(); i++ { + field := envType.Field(i) + if field.IsExported() { + flagName := "--" + toKebabCase(field.Name) + expectedFlags = append(expectedFlags, flagName) + } + } + + // Add short form + expectedFlags = append(expectedFlags, "-n") + + // Test that each global flag is preserved + for _, flag := range expectedFlags { + input := []string{flag} + output := filterDependencyUnsupportedFlags(input) + + if len(output) != 1 || output[0] != flag { + t.Errorf("global flag %s was not preserved: input=%v output=%v", flag, input, output) + } + } +} + +// TestFilterDependencyFlags_AllDependencyFlags verifies that all dependency-specific flags +// from action.Dependency are preserved by the filter +func TestFilterDependencyFlags_AllDependencyFlags(t *testing.T) { + // Get all expected dependency flag names using reflection + dep := action.NewDependency() + depType := reflect.TypeOf(*dep) + + var expectedFlags []string + for i := 0; i < depType.NumField(); i++ { + field := depType.Field(i) + if field.IsExported() { + flagName := "--" + toKebabCase(field.Name) + expectedFlags = append(expectedFlags, flagName) + } + } + + // Test that each dependency flag is preserved + for _, flag := range expectedFlags { + input := []string{flag} + output := filterDependencyUnsupportedFlags(input) + + if len(output) != 1 || output[0] != flag { + t.Errorf("dependency flag %s was not preserved: input=%v output=%v", flag, input, output) + } + } +} + +// TestFilterDependencyFlags_FlagWithEqualsValue tests flags with = syntax +// Note: Current implementation has a known limitation with flags using = syntax +// (e.g., --namespace=default). Users should use space-separated form (--namespace default). +func TestFilterDependencyFlags_FlagWithEqualsValue(t *testing.T) { + testCases := []struct { + name string + input []string + expected []string + note string + }{ + { + name: "dry-run with value should be filtered", + input: []string{"--dry-run=server"}, + expected: []string{}, + }, + { + name: "namespace with equals syntax is currently filtered (known limitation)", + input: []string{"--namespace=default"}, + expected: []string{}, // Known limitation: flags with = are not matched + note: "Workaround: use --namespace default (space-separated)", + }, + { + name: "debug flag should be preserved", + input: []string{"--debug"}, + expected: []string{"--debug"}, + }, + { + name: "keyring with value should be preserved", + input: []string{"--keyring=/path/to/keyring"}, + expected: []string{"--keyring=/path/to/keyring"}, + }, + { + name: "wait flag should be filtered", + input: []string{"--wait"}, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := filterDependencyUnsupportedFlags(tc.input) + if !reflect.DeepEqual(output, tc.expected) { + if tc.note != "" { + t.Logf("Note: %s", tc.note) + } + t.Errorf("filterDependencyUnsupportedFlags(%v) = %v, want %v", + tc.input, output, tc.expected) + } + }) + } +} + +// TestFilterDependencyFlags_MixedFlags tests a mix of supported and unsupported flags +// Note: Flags with = syntax have known limitations (see TestFilterDependencyFlags_FlagWithEqualsValue) +func TestFilterDependencyFlags_MixedFlags(t *testing.T) { + input := []string{ + "--debug", // global: keep + "--dry-run=server", // template: filter + "--verify", // dependency: keep + "--wait", // template: filter + "--namespace=default", // global: keep (but filtered due to = syntax limitation) + "--kube-context=prod", // global: keep + "--atomic", // template: filter + "--keyring=/path", // dependency: keep + } + + // Expected reflects current behavior with known limitation for --namespace= + expected := []string{ + "--debug", + "--verify", + "--kube-context=prod", // Works because --kube- prefix matches + "--keyring=/path", + } + + output := filterDependencyUnsupportedFlags(input) + + if !reflect.DeepEqual(output, expected) { + t.Errorf("filterDependencyUnsupportedFlags() =\n%v\nwant:\n%v", output, expected) + t.Logf("Note: --namespace=default not preserved due to known limitation with = syntax") + } +} + +// TestFilterDependencyFlags_EmptyInput tests empty input +func TestFilterDependencyFlags_EmptyInput(t *testing.T) { + input := []string{} + output := filterDependencyUnsupportedFlags(input) + + if len(output) != 0 { + t.Errorf("expected empty output for empty input, got %v", output) + } +} + +// TestFilterDependencyFlags_TemplateSpecificFlags tests that template-specific flags are filtered +func TestFilterDependencyFlags_TemplateSpecificFlags(t *testing.T) { + templateFlags := []string{ + "--dry-run", + "--dry-run=client", + "--dry-run=server", + "--wait", + "--atomic", + "--timeout=5m", + "--create-namespace", + "--dependency-update", + "--force", + "--cleanup-on-fail", + "--no-hooks", + } + + for _, flag := range templateFlags { + output := filterDependencyUnsupportedFlags([]string{flag}) + if len(output) != 0 { + t.Errorf("template-specific flag %s should be filtered out, but got %v", flag, output) + } + } +} + +// TestToKebabCase tests the toKebabCase conversion function +// Note: Current implementation has limitations with consecutive uppercase letters (acronyms) +func TestToKebabCase(t *testing.T) { + testCases := []struct { + input string + expected string + note string + }{ + {"SkipRefresh", "skip-refresh", ""}, + {"KubeContext", "kube-context", ""}, + {"BurstLimit", "burst-limit", ""}, + {"QPS", "q-p-s", "Known limitation: consecutive caps become separate words"}, + {"Debug", "debug", ""}, + {"InsecureSkipTLSverify", "insecure-skip-t-l-sverify", "Known limitation: TLS acronym"}, + {"RepositoryConfig", "repository-config", ""}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + output := toKebabCase(tc.input) + if output != tc.expected { + if tc.note != "" { + t.Logf("Note: %s", tc.note) + } + t.Errorf("toKebabCase(%s) = %s, want %s", tc.input, output, tc.expected) + } + }) + } +} + +// TestGetSupportedDependencyFlags_Consistency tests that the supported flags map +// is consistent across multiple calls (caching works) +func TestGetSupportedDependencyFlags_Consistency(t *testing.T) { + // Call multiple times + flags1 := getSupportedDependencyFlags() + flags2 := getSupportedDependencyFlags() + + // Verify they have the same keys + if len(flags1) != len(flags2) { + t.Errorf("inconsistent number of flags: first call=%d, second call=%d", + len(flags1), len(flags2)) + } + + for key := range flags1 { + if !flags2[key] { + t.Errorf("flag %s present in first call but not in second", key) + } + } +} + +// TestGetSupportedDependencyFlags_ContainsExpectedFlags tests that the supported flags +// contain known important flags (based on actual reflection output) +func TestGetSupportedDependencyFlags_ContainsExpectedFlags(t *testing.T) { + supportedFlags := getSupportedDependencyFlags() + + // Flags that should definitely be present based on reflection + expectedFlags := []string{ + "--debug", + "--verify", + "--keyring", + "--skip-refresh", + "-n", // Short form is added explicitly + "--kube-context", + "--burst-limit", + } + + for _, flag := range expectedFlags { + if !supportedFlags[flag] { + t.Errorf("expected flag %s not found in supported flags map", flag) + } + } + + // Note: Some flags may not be present due to toKebabCase limitations + // - "Namespace" field becomes "--namespace" but may not match "--namespace=" + // - "Kubeconfig" field becomes "--kubeconfig" + // - "QPS" field becomes "--q-p-s" (not "--qps") + t.Logf("Total flags discovered via reflection: %d", len(supportedFlags)) +} diff --git a/pkg/helmexec/exec_test.go b/pkg/helmexec/exec_test.go index f81eaf95..9cc40def 100644 --- a/pkg/helmexec/exec_test.go +++ b/pkg/helmexec/exec_test.go @@ -431,6 +431,7 @@ exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo buffer.Reset() helm.SetExtraArgs("--verify") err = helm.UpdateDeps("./chart/foo") + // --verify is a dependency-specific flag and should be preserved expected = `Updating dependency ./chart/foo exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo --verify ` @@ -438,7 +439,7 @@ exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo t.Errorf("unexpected error: %v", err) } if buffer.String() != expected { - t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected) + t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected) } } @@ -478,6 +479,7 @@ v3.2.4+ge29ce2a buffer.Reset() helm.SetExtraArgs("--verify") err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...) + // --verify is a dependency-specific flag and should be preserved expected = `Building dependency release=foo, chart=./chart/foo exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh --verify v3.2.4+ge29ce2a @@ -506,6 +508,47 @@ Client: v2.16.1+ge13bc94 if buffer.String() != expected { t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected) } + + // Test that --dry-run flag is filtered out (not supported by helm dependency build) + buffer.Reset() + helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")} + helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + helm.SetExtraArgs("--dry-run=server") + err = helm.BuildDeps("foo", "./chart/foo") + expected = `Building dependency release=foo, chart=./chart/foo +exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo +v3.2.4+ge29ce2a +` + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if buffer.String() != expected { + t.Errorf("helmexec.BuildDeps() with --dry-run should filter it out\nactual = %v\nexpect = %v", buffer.String(), expected) + } + + // Test that global flags (--debug) and dependency flags (--verify) are preserved, + // while template-specific flags (--dry-run) are filtered out + buffer.Reset() + helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")} + helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + helm.SetExtraArgs("--debug", "--dry-run=server", "--verify", "--wait") + err = helm.BuildDeps("foo", "./chart/foo") + expected = `Building dependency release=foo, chart=./chart/foo +exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --debug --verify +v3.2.4+ge29ce2a +` + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if buffer.String() != expected { + t.Errorf("helmexec.BuildDeps() should preserve global and dependency flags\nactual = %v\nexpect = %v", buffer.String(), expected) + } } func Test_DecryptSecret(t *testing.T) { diff --git a/pkg/state/create.go b/pkg/state/create.go index 97e91823..cd30d731 100644 --- a/pkg/state/create.go +++ b/pkg/state/create.go @@ -26,6 +26,8 @@ const ( DefaultHCLFileExtension = ".hcl" ) +var ValidUpdateStrategyValues = []string{UpdateStrategyReinstallIfForbidden} + type StateLoadError struct { Msg string Cause error @@ -43,6 +45,14 @@ func (e *UndefinedEnvError) Error() string { return fmt.Sprintf("environment \"%s\" is not defined", e.Env) } +type InvalidUpdateStrategyError struct { + UpdateStrategy string +} + +func (e *InvalidUpdateStrategyError) Error() string { + return fmt.Sprintf("updateStrategy %q is invalid, valid values are: %s or not set", e.UpdateStrategy, strings.Join(ValidUpdateStrategyValues, ", ")) +} + type StateCreator struct { logger *zap.SugaredLogger @@ -116,11 +126,19 @@ func (c *StateCreator) Parse(content []byte, baseDir, file string) (*HelmState, return nil, &StateLoadError{fmt.Sprintf("failed to read %s: reading document at index %d", file, i), err} } - if err := mergo.Merge(&state, &intermediate, mergo.WithAppendSlice); err != nil { + if err := mergo.Merge(&state, &intermediate, mergo.WithAppendSlice, mergo.WithOverride); err != nil { return nil, &StateLoadError{fmt.Sprintf("failed to read %s: merging document at index %d", file, i), err} } } + state.logger = c.logger + state.valsRuntime = c.valsRuntime + + return &state, nil +} + +// applyDefaultsAndOverrides applies default binary paths and command-line overrides +func (c *StateCreator) applyDefaultsAndOverrides(state *HelmState) { if c.overrideHelmBinary != "" && c.overrideHelmBinary != DefaultHelmBinary { state.DefaultHelmBinary = c.overrideHelmBinary } else if state.DefaultHelmBinary == "" { @@ -134,11 +152,6 @@ func (c *StateCreator) Parse(content []byte, baseDir, file string) (*HelmState, // Let `helmfile --kustomize-binary ""` not break this helmfile run state.DefaultKustomizeBinary = DefaultKustomizeBinary } - - state.logger = c.logger - state.valsRuntime = c.valsRuntime - - return &state, nil } // LoadEnvValues loads environment values files relative to the `baseDir` @@ -181,6 +194,11 @@ func (c *StateCreator) ParseAndLoad(content []byte, baseDir, file string, envNam if err != nil { return nil, err } + + // Apply default binaries and command-line overrides only for the main helmfile + // after loading and merging all bases. This ensures that values from bases are + // properly respected and that later bases/documents can override earlier ones. + c.applyDefaultsAndOverrides(state) } state, err = c.LoadEnvValues(state, envName, failOnMissingEnv, envValues, overrode) @@ -216,7 +234,7 @@ func (c *StateCreator) loadBases(envValues, overrodeEnv *environment.Environment layers = append(layers, st) for i := 1; i < len(layers); i++ { - if err := mergo.Merge(layers[0], layers[i], mergo.WithAppendSlice); err != nil { + if err := mergo.Merge(layers[0], layers[i], mergo.WithAppendSlice, mergo.WithOverride); err != nil { return nil, err } } diff --git a/pkg/state/create_test.go b/pkg/state/create_test.go index 29ac5ca8..e8394458 100644 --- a/pkg/state/create_test.go +++ b/pkg/state/create_test.go @@ -1,6 +1,7 @@ package state import ( + "fmt" "path/filepath" "reflect" "testing" @@ -525,3 +526,205 @@ releaseContext: t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues) } } + +// TestHelmBinaryInBases tests that helmBinary and kustomizeBinary settings +// from bases are properly merged with later values overriding earlier ones +func TestHelmBinaryInBases(t *testing.T) { + tests := []struct { + name string + files map[string]string + mainFile string + expectedHelmBinary string + expectedKustomizeBinary string + }{ + { + name: "helmBinary in second base should be used", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/env.yaml +--- +bases: + - ./bases/repos.yaml +--- +bases: + - ./bases/releases.yaml +`, + "/path/to/bases/env.yaml": `environments: + default: + values: + - key: value1 +`, + "/path/to/bases/repos.yaml": `repositories: + - name: stable + url: https://charts.helm.sh/stable +helmBinary: /path/to/custom/helm +`, + "/path/to/bases/releases.yaml": `releases: + - name: myapp + chart: stable/nginx +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: "/path/to/custom/helm", + expectedKustomizeBinary: DefaultKustomizeBinary, + }, + { + name: "helmBinary in main file after bases should override", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/env.yaml +--- +bases: + - ./bases/repos.yaml +--- +bases: + - ./bases/releases.yaml +helmBinary: /path/to/main/helm +`, + "/path/to/bases/env.yaml": `environments: + default: + values: + - key: value1 +`, + "/path/to/bases/repos.yaml": `repositories: + - name: stable + url: https://charts.helm.sh/stable +helmBinary: /path/to/base/helm +`, + "/path/to/bases/releases.yaml": `releases: + - name: myapp + chart: stable/nginx +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: "/path/to/main/helm", + expectedKustomizeBinary: DefaultKustomizeBinary, + }, + { + name: "helmBinary in main file between bases should override earlier bases", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/env.yaml +--- +bases: + - ./bases/repos.yaml +helmBinary: /path/to/middle/helm +--- +bases: + - ./bases/releases.yaml +`, + "/path/to/bases/env.yaml": `environments: + default: + values: + - key: value1 +`, + "/path/to/bases/repos.yaml": `repositories: + - name: stable + url: https://charts.helm.sh/stable +helmBinary: /path/to/base/helm +`, + "/path/to/bases/releases.yaml": `releases: + - name: myapp + chart: stable/nginx +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: "/path/to/middle/helm", + expectedKustomizeBinary: DefaultKustomizeBinary, + }, + { + name: "kustomizeBinary in base should be used", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/base.yaml +`, + "/path/to/bases/base.yaml": `kustomizeBinary: /path/to/custom/kustomize +releases: + - name: myapp + chart: mychart +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: DefaultHelmBinary, + expectedKustomizeBinary: "/path/to/custom/kustomize", + }, + { + name: "both helmBinary and kustomizeBinary in different bases", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/helm.yaml +--- +bases: + - ./bases/kustomize.yaml +`, + "/path/to/bases/helm.yaml": `helmBinary: /path/to/custom/helm +`, + "/path/to/bases/kustomize.yaml": `kustomizeBinary: /path/to/custom/kustomize +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: "/path/to/custom/helm", + expectedKustomizeBinary: "/path/to/custom/kustomize", + }, + { + name: "later base overrides earlier base for helmBinary", + files: map[string]string{ + "/path/to/helmfile.yaml": `bases: + - ./bases/first.yaml +--- +bases: + - ./bases/second.yaml +`, + "/path/to/bases/first.yaml": `helmBinary: /path/to/first/helm +`, + "/path/to/bases/second.yaml": `helmBinary: /path/to/second/helm +`, + }, + mainFile: "/path/to/helmfile.yaml", + expectedHelmBinary: "/path/to/second/helm", + expectedKustomizeBinary: DefaultKustomizeBinary, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testFs := testhelper.NewTestFs(tt.files) + if testFs.Cwd == "" { + testFs.Cwd = "/" + } + + r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem()) + creator := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "") + + // Set up LoadFile for recursive base loading + creator.LoadFile = func(inheritedEnv, overrodeEnv *environment.Environment, baseDir, file string, evaluateBases bool) (*HelmState, error) { + path := filepath.Join(baseDir, file) + content, ok := tt.files[path] + if !ok { + return nil, fmt.Errorf("file not found: %s", path) + } + return creator.ParseAndLoad([]byte(content), filepath.Dir(path), path, DefaultEnv, true, evaluateBases, inheritedEnv, overrodeEnv) + } + + yamlContent, ok := tt.files[tt.mainFile] + if !ok { + t.Fatalf("no file named %q registered", tt.mainFile) + } + + state, err := creator.ParseAndLoad([]byte(yamlContent), filepath.Dir(tt.mainFile), tt.mainFile, DefaultEnv, true, true, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if state.DefaultHelmBinary != tt.expectedHelmBinary { + t.Errorf("helmBinary mismatch: expected=%s, actual=%s", + tt.expectedHelmBinary, state.DefaultHelmBinary) + } + + if state.DefaultKustomizeBinary != tt.expectedKustomizeBinary { + t.Errorf("kustomizeBinary mismatch: expected=%s, actual=%s", + tt.expectedKustomizeBinary, state.DefaultKustomizeBinary) + } + }) + } +} diff --git a/pkg/state/state.go b/pkg/state/state.go index 204ec2b1..04c989ef 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -43,6 +43,9 @@ const ( // This is used by an interim solution to make the urfave/cli command report to the helmfile internal about that the // --timeout flag is missingl EmptyTimeout = -1 + + // Valid enum for updateStrategy values + UpdateStrategyReinstallIfForbidden = "reinstallIfForbidden" ) // ReleaseSetSpec is release set spec @@ -277,6 +280,8 @@ type ReleaseSpec struct { Force *bool `yaml:"force,omitempty"` // Installed, when set to true, `delete --purge` the release Installed *bool `yaml:"installed,omitempty"` + // UpdateStrategy, when set, indicate the strategy to use to update the release + UpdateStrategy string `yaml:"updateStrategy,omitempty"` // Atomic, when set to true, restore previous state in case of a failed install/upgrade attempt Atomic *bool `yaml:"atomic,omitempty"` // CleanupOnFail, when set to true, the --cleanup-on-fail helm flag is passed to the upgrade command @@ -467,6 +472,7 @@ type SetValue struct { // AffectedReleases hold the list of released that where updated, deleted, or in error type AffectedReleases struct { Upgraded []*ReleaseSpec + Reinstalled []*ReleaseSpec Deleted []*ReleaseSpec Failed []*ReleaseSpec DeleteFailed []*ReleaseSpec @@ -1037,20 +1043,24 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme } m.Unlock() } - } else if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil { - m.Lock() - affectedReleases.Failed = append(affectedReleases.Failed, release) - m.Unlock() - relErr = newReleaseFailedError(release, err) + } else if release.UpdateStrategy == UpdateStrategyReinstallIfForbidden { + relErr = st.performSyncOrReinstallOfRelease(affectedReleases, helm, context, release, chart, m, flags...) } else { - m.Lock() - affectedReleases.Upgraded = append(affectedReleases.Upgraded, release) - m.Unlock() - installedVersion, err := st.getDeployedVersion(context, helm, release) - if err != nil { // err is not really impacting so just log it - st.logger.Debugf("getting deployed release version failed: %v", err) + if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil { + m.Lock() + affectedReleases.Failed = append(affectedReleases.Failed, release) + m.Unlock() + relErr = newReleaseFailedError(release, err) } else { - release.installedVersion = installedVersion + m.Lock() + affectedReleases.Upgraded = append(affectedReleases.Upgraded, release) + m.Unlock() + installedVersion, err := st.getDeployedVersion(context, helm, release) + if err != nil { // err is not really impacting so just log it + st.logger.Debugf("getting deployed release version failed: %v", err) + } else { + release.installedVersion = installedVersion + } } } @@ -1096,6 +1106,77 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme return nil } +func (st *HelmState) performSyncOrReinstallOfRelease(affectedReleases *AffectedReleases, helm helmexec.Interface, context helmexec.HelmContext, release *ReleaseSpec, chart string, m *sync.Mutex, flags ...string) *ReleaseError { + if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil { + st.logger.Debugf("update strategy - sync failed: %s", err.Error()) + // Only fail if a different error than forbidden updates + if !strings.Contains(err.Error(), "Forbidden: updates") { + st.logger.Debugf("update strategy - sync failed not due to Forbidden updates") + m.Lock() + affectedReleases.Failed = append(affectedReleases.Failed, release) + m.Unlock() + return newReleaseFailedError(release, err) + } + } else { + st.logger.Debugf("update strategy - sync success") + m.Lock() + affectedReleases.Upgraded = append(affectedReleases.Upgraded, release) + m.Unlock() + installedVersion, err := st.getDeployedVersion(context, helm, release) + if err != nil { // err is not really impacting so just log it + st.logger.Debugf("update strategy - getting deployed release version failed: %v", err) + } else { + release.installedVersion = installedVersion + } + return nil + } + + st.logger.Infof("Failed to sync due to forbidden updates, attempting to reinstall %q allowed by update strategy", release.Name) + installed, err := st.isReleaseInstalled(context, helm, *release) + if err != nil { + return newReleaseFailedError(release, err) + } + if installed { + var args []string + if release.Namespace != "" { + args = append(args, "--namespace", release.Namespace) + } + deleteWaitFlag := true + release.DeleteWait = &deleteWaitFlag + args = st.appendDeleteWaitFlags(args, release) + deletionFlags := st.appendConnectionFlags(args, release) + m.Lock() + if _, err := st.triggerReleaseEvent("preuninstall", nil, release, "sync"); err != nil { + affectedReleases.Failed = append(affectedReleases.Failed, release) + return newReleaseFailedError(release, err) + } else if err := helm.DeleteRelease(context, release.Name, deletionFlags...); err != nil { + affectedReleases.Failed = append(affectedReleases.Failed, release) + return newReleaseFailedError(release, err) + } else if _, err := st.triggerReleaseEvent("postuninstall", nil, release, "sync"); err != nil { + affectedReleases.Failed = append(affectedReleases.Failed, release) + return newReleaseFailedError(release, err) + } + m.Unlock() + } + if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil { + m.Lock() + affectedReleases.Failed = append(affectedReleases.Failed, release) + m.Unlock() + return newReleaseFailedError(release, err) + } else { + m.Lock() + affectedReleases.Reinstalled = append(affectedReleases.Reinstalled, release) + m.Unlock() + installedVersion, err := st.getDeployedVersion(context, helm, release) + if err != nil { // err is not really impacting so just log it + st.logger.Debugf("update strategy - getting deployed release version failed: %v", err) + } else { + release.installedVersion = installedVersion + } + } + return nil +} + func (st *HelmState) listReleases(context helmexec.HelmContext, helm helmexec.Interface, release *ReleaseSpec) (string, error) { flags := st.kubeConnectionFlags(release) if release.Namespace != "" { @@ -3739,6 +3820,28 @@ func (ar *AffectedReleases) DisplayAffectedReleases(logger *zap.SugaredLogger) { } logger.Info(tbl.String()) } + if len(ar.Reinstalled) > 0 { + logger.Info("\nREINSTALLED RELEASES:") + tbl, _ := prettytable.NewTable(prettytable.Column{Header: "NAME"}, + prettytable.Column{Header: "NAMESPACE", MinWidth: 6}, + prettytable.Column{Header: "CHART", MinWidth: 6}, + prettytable.Column{Header: "VERSION", MinWidth: 6}, + prettytable.Column{Header: "DURATION", AlignRight: true}, + ) + tbl.Separator = " " + for _, release := range ar.Reinstalled { + modifiedChart, modErr := hideChartCredentials(release.Chart) + if modErr != nil { + logger.Warn("Could not modify chart credentials, %v", modErr) + continue + } + err := tbl.AddRow(release.Name, release.Namespace, modifiedChart, release.installedVersion, release.duration.Round(time.Second)) + if err != nil { + logger.Warn("Could not add row, %v", err) + } + } + logger.Info(tbl.String()) + } if len(ar.Deleted) > 0 { logger.Info("\nDELETED RELEASES:") tbl, _ := prettytable.NewTable(prettytable.Column{Header: "NAME"}, diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index a02ea593..335613bd 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -1640,6 +1640,112 @@ func TestHelmState_SyncReleasesAffectedRealeases(t *testing.T) { } } +func TestHelmState_SyncReleasesAffectedReleasesWithReinstallIfForbidden(t *testing.T) { + no := false + tests := []struct { + name string + releases []ReleaseSpec + installed []bool + wantAffected exectest.Affected + }{ + { + name: "2 new", + releases: []ReleaseSpec{ + { + Name: "releaseNameFoo-forbidden", + Chart: "foo", + UpdateStrategy: "reinstallIfForbidden", + }, + { + Name: "releaseNameBar-forbidden", + Chart: "foo", + UpdateStrategy: "reinstallIfForbidden", + }, + }, + wantAffected: exectest.Affected{ + Upgraded: []*exectest.Release{ + {Name: "releaseNameFoo-forbidden", Flags: []string{}}, + {Name: "releaseNameBar-forbidden", Flags: []string{}}, + }, + Reinstalled: nil, + Deleted: nil, + Failed: nil, + }, + }, + { + name: "1 removed, 1 new, 1 reinstalled first new", + releases: []ReleaseSpec{ + { + Name: "releaseNameFoo-forbidden", + Chart: "foo", + UpdateStrategy: "reinstallIfForbidden", + }, + { + Name: "releaseNameBar", + Chart: "foo", + UpdateStrategy: "reinstallIfForbidden", + Installed: &no, + }, + { + Name: "releaseNameFoo-forbidden", + Chart: "foo", + UpdateStrategy: "reinstallIfForbidden", + }, + }, + installed: []bool{true, true, true}, + wantAffected: exectest.Affected{ + Upgraded: []*exectest.Release{ + {Name: "releaseNameFoo-forbidden", Flags: []string{}}, + }, + Reinstalled: []*exectest.Release{ + {Name: "releaseNameFoo-forbidden", Flags: []string{}}, + }, + Deleted: []*exectest.Release{ + {Name: "releaseNameBar", Flags: []string{}}, + }, + Failed: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := &HelmState{ + ReleaseSetSpec: ReleaseSetSpec{ + Releases: tt.releases, + }, + logger: logger, + valsRuntime: valsRuntime, + RenderedValues: map[string]any{}, + } + helm := &exectest.Helm{ + Lists: map[exectest.ListKey]string{}, + } + //simulate the release is already installed + for i, release := range tt.releases { + if tt.installed != nil && tt.installed[i] { + helm.Lists[exectest.ListKey{Filter: "^" + release.Name + "$", Flags: "--uninstalling --deployed --failed --pending"}] = release.Name + } + } + + affectedReleases := AffectedReleases{} + if err := state.SyncReleases(&affectedReleases, helm, []string{}, 1); err != nil { + if !testEq(affectedReleases.Failed, tt.wantAffected.Failed) { + t.Errorf("HelmState.SyncReleases() error failed for [%s] = %v, want %v", tt.name, affectedReleases.Failed, tt.wantAffected.Failed) + } //else expected error + } + if !testEq(affectedReleases.Upgraded, tt.wantAffected.Upgraded) { + t.Errorf("HelmState.SyncReleases() upgrade failed for [%s] = %v, want %v", tt.name, affectedReleases.Upgraded, tt.wantAffected.Upgraded) + } + if !testEq(affectedReleases.Reinstalled, tt.wantAffected.Reinstalled) { + t.Errorf("HelmState.SyncReleases() reinstalled failed for [%s] = %v, want %v", tt.name, affectedReleases.Reinstalled, tt.wantAffected.Reinstalled) + } + if !testEq(affectedReleases.Deleted, tt.wantAffected.Deleted) { + t.Errorf("HelmState.SyncReleases() deleted failed for [%s] = %v, want %v", tt.name, affectedReleases.Deleted, tt.wantAffected.Deleted) + } + }) + } +} + func testEq(a []*ReleaseSpec, b []*exectest.Release) bool { // If one is nil, the other must also be nil. if (a == nil) != (b == nil) { diff --git a/pkg/state/temp_test.go b/pkg/state/temp_test.go index ca3f493c..7e082d51 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-7d454b9558", + want: "foo-values-67dc97cbcb", }) run(testcase{ subject: "different bytes content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: []byte(`{"k":"v"}`), - want: "foo-values-59c86d55bf", + want: "foo-values-75d7c4758c", }) run(testcase{ subject: "different map content", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"}, data: map[string]any{"k": "v"}, - want: "foo-values-6f87c5cd79", + want: "foo-values-685f8cf685", }) run(testcase{ subject: "different chart", release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"}, - want: "foo-values-5dfd748475", + want: "foo-values-75597d9c57", }) run(testcase{ subject: "different name", release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"}, - want: "bar-values-858b9c55cc", + want: "bar-values-7b77df65ff", }) run(testcase{ subject: "specific ns", release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"}, - want: "myns-foo-values-58dc9c6667", + want: "myns-foo-values-85f979545c", }) for id, n := range ids { diff --git a/pkg/testhelper/testfs.go b/pkg/testhelper/testfs.go index 97583b5d..47091dbb 100644 --- a/pkg/testhelper/testfs.go +++ b/pkg/testhelper/testfs.go @@ -2,9 +2,11 @@ package testhelper import ( "fmt" + "io" "os" "path/filepath" "strings" + "sync" ffs "github.com/helmfile/helmfile/pkg/filesystem" ) @@ -17,6 +19,7 @@ type TestFs struct { GlobFixtures map[string][]string DeleteFile func(string) error + mu sync.Mutex fileReaderCalls int successfulReads []string } @@ -92,18 +95,26 @@ func (f *TestFs) ReadFile(filename string) ([]byte, error) { return []byte(nil), os.ErrNotExist } + f.mu.Lock() f.fileReaderCalls++ - f.successfulReads = append(f.successfulReads, filename) + f.mu.Unlock() return []byte(str), nil } func (f *TestFs) SuccessfulReads() []string { - return f.successfulReads + f.mu.Lock() + defer f.mu.Unlock() + // Return a copy to avoid race conditions with callers + result := make([]string, len(f.successfulReads)) + copy(result, f.successfulReads) + return result } func (f *TestFs) FileReaderCalls() int { + f.mu.Lock() + defer f.mu.Unlock() return f.fileReaderCalls } @@ -155,3 +166,21 @@ func (f *TestFs) Chdir(dir string) error { } return fmt.Errorf("unexpected chdir \"%s\"", dir) } + +// SyncWriter wraps an io.Writer to make it safe for concurrent use. +type SyncWriter struct { + mu sync.Mutex + w io.Writer +} + +// NewSyncWriter creates a new thread-safe writer. +func NewSyncWriter(w io.Writer) *SyncWriter { + return &SyncWriter{w: w} +} + +// Write implements io.Writer in a thread-safe manner. +func (sw *SyncWriter) Write(p []byte) (n int, err error) { + sw.mu.Lock() + defer sw.mu.Unlock() + return sw.w.Write(p) +}