Merge branch 'main' into fix/issue-1904-patch-template-values

This commit is contained in:
yxxhero 2026-05-31 09:58:17 +08:00 committed by GitHub
commit 6aec9fd6b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3466 additions and 154 deletions

View File

@ -96,35 +96,35 @@ jobs:
- helm-version: v3.18.6
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: ''
# In case you need to test some optional helmfile features,
# enable it via extra-helmfile-flags below.
- helm-version: v3.18.6
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: '--enable-live-output'
- helm-version: v3.21.0
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: ''
- helm-version: v3.21.0
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: '--enable-live-output'
# Helmfile now supports both Helm 3.x and Helm 4.x
- helm-version: v4.2.0
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: ''
- helm-version: v4.2.0
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.3
plugin-diff-version: 3.15.7
extra-helmfile-flags: '--enable-live-output'
steps:
- uses: actions/checkout@v6

View File

@ -48,6 +48,8 @@ func toCLIError(g *config.GlobalImpl, err error) error {
// NewRootCmd creates the root command for the CLI.
func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
globalImpl := config.NewGlobalImpl(globalConfig)
cmd := &cobra.Command{
Use: "helmfile",
Short: globalUsage,
@ -58,11 +60,11 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
PersistentPreRunE: func(c *cobra.Command, args []string) error {
// Valid levels:
// https://github.com/uber-go/zap/blob/7e7e266a8dbce911a49554b945538c5b950196b8/zapcore/level.go#L126
logLevel := globalConfig.LogLevel
logLevel := globalImpl.LogLevel()
switch {
case globalConfig.Debug:
case globalImpl.Debug():
logLevel = "debug"
case globalConfig.Quiet:
case globalImpl.Quiet():
logLevel = "warn"
}
@ -83,8 +85,6 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
flags.ParseErrorsAllowlist.UnknownFlags = true
globalImpl := config.NewGlobalImpl(globalConfig)
// when set environment HELMFILE_UPGRADE_NOTICE_DISABLED any value, skip upgrade notice.
var versionOpts []extension.CobraOption
if os.Getenv(envvar.UpgradeNoticeDisabled) == "" {
@ -121,8 +121,8 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
}
func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalOptions) {
fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", app.DefaultHelmBinary, "Path to the helm binary")
fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", app.DefaultKustomizeBinary, "Path to the kustomize binary")
fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", "", fmt.Sprintf(`Path to the helm binary. Overrides "HELMFILE_HELM_BINARY" OS environment variable when specified (default %q)`, app.DefaultHelmBinary))
fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", "", fmt.Sprintf(`Path to the kustomize binary. Overrides "HELMFILE_KUSTOMIZE_BINARY" OS environment variable when specified (default %q)`, app.DefaultKustomizeBinary))
fs.StringVarP(&globalOptions.File, "file", "f", "", "load config from file or directory. defaults to \"`helmfile.yaml`\" or \"helmfile.yaml.gotmpl\" or \"helmfile.d\" (means \"helmfile.d/*.yaml\" or \"helmfile.d/*.yaml.gotmpl\") in this preference. Specify - to load the config from the standard input.")
fs.StringVarP(&globalOptions.Environment, "environment", "e", "", `specify the environment name. Overrides "HELMFILE_ENVIRONMENT" OS environment variable when specified. defaults to "default"`)
fs.StringArrayVar(&globalOptions.StateValuesSet, "state-values-set", nil, "set state values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2). Used to override .Values within the helmfile template (not values template).")
@ -134,14 +134,14 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO
fs.BoolVar(&globalOptions.DisableForceUpdate, "disable-force-update", false, `do not force helm repos to update when executing "helm repo add" (Helm 3 only)`)
fs.BoolVar(&globalOptions.EnforcePluginVerification, "enforce-plugin-verification", false, `fail plugin installation if verification is not supported (for security purposes)`)
fs.BoolVar(&globalOptions.HelmOCIPlainHTTP, "oci-plain-http", false, `use plain HTTP for OCI registries (required for local/insecure registries in Helm 4)`)
fs.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, "Silence output. Equivalent to log-level warn")
fs.BoolVarP(&globalOptions.Quiet, "quiet", "q", false, `Silence output. Equivalent to log-level warn. Overrides "HELMFILE_QUIET" OS environment variable when specified`)
fs.StringVar(&globalOptions.Kubeconfig, "kubeconfig", "", "Use a particular kubeconfig file")
fs.StringVar(&globalOptions.KubeContext, "kube-context", "", "Set kubectl context. Uses current context by default")
fs.BoolVar(&globalOptions.Debug, "debug", false, "Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect")
fs.StringVar(&globalOptions.KubeContext, "kube-context", "", `Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default`)
fs.BoolVar(&globalOptions.Debug, "debug", false, `Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect. Overrides "HELMFILE_DEBUG" OS environment variable when specified`)
fs.BoolVar(&globalOptions.Color, "color", false, "Output with color")
fs.BoolVar(&globalOptions.NoColor, "no-color", false, "Output without color")
fs.StringVar(&globalOptions.LogLevel, "log-level", "info", "Set log level, default info")
fs.StringVarP(&globalOptions.Namespace, "namespace", "n", "", "Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}")
fs.BoolVar(&globalOptions.NoColor, "no-color", false, `Output without color. Overrides "HELMFILE_NO_COLOR" and "NO_COLOR" OS environment variables when specified`)
fs.StringVar(&globalOptions.LogLevel, "log-level", "", `Set log level. Overrides "HELMFILE_LOG_LEVEL" OS environment variable when specified (default "info")`)
fs.StringVarP(&globalOptions.Namespace, "namespace", "n", "", `Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}`)
fs.StringVarP(&globalOptions.Chart, "chart", "c", "", "Set chart. Uses the chart set in release by default, and is available in template as {{ .Chart }}")
fs.StringArrayVarP(&globalOptions.Selector, "selector", "l", nil, `Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar.
A release must match all labels in a group in order to be used. Multiple groups can be specified at once.

View File

@ -60,5 +60,20 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.BoolVar(&syncOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails")
f.StringVar(&syncOptions.Description, "description", "", `Set description for all releases. If set, overrides descriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`)
// Diff-related flags for --interactive mode
f.IntVar(&syncOptions.Context, "context", 0, "output NUM lines of context around changes (interactive preview only)")
f.StringVar(&syncOptions.DiffOutput, "output", "", "output format for diff plugin (interactive preview only)")
f.StringVar(&syncOptions.DiffArgs, "diff-args", "", "pass args to helm-diff (interactive preview only)")
f.StringArrayVar(&syncOptions.Suppress, "suppress", nil, "suppress specified Kubernetes objects in the diff output (interactive preview only). Can be provided multiple times. For example: --suppress KeycloakClient --suppress VaultSecret")
f.BoolVar(&syncOptions.SuppressSecrets, "suppress-secrets", false, "suppress secrets in the diff output (interactive preview only). highly recommended to specify on CI/CD use-cases")
f.BoolVar(&syncOptions.ShowSecrets, "show-secrets", false, "do not redact secret values in the diff output (interactive preview only). should be used for debug purpose only")
f.BoolVar(&syncOptions.NoHooks, "no-hooks", false, "do not diff changes made by hooks (interactive preview only)")
f.BoolVar(&syncOptions.SuppressDiff, "suppress-diff", false, "suppress diff in the output (interactive preview only). Usable in new installs")
f.BoolVar(&syncOptions.SkipDiffOnInstall, "skip-diff-on-install", false, "Skips running helm-diff on releases being newly installed on this sync (interactive preview only). Useful when the release manifests are too huge to be reviewed, or it's too time-consuming to diff at all")
f.BoolVar(&syncOptions.IncludeTests, "include-tests", false, "enable the diffing of the helm test hooks (interactive preview only)")
f.BoolVar(&syncOptions.DetailedExitcode, "detailed-exitcode", false, "return a non-zero exit code 2 instead of 0 when releases are synced (use --interactive to also see a diff preview)")
f.BoolVar(&syncOptions.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input (interactive preview only)")
f.StringArrayVar(&syncOptions.SuppressOutputLineRegex, "suppress-output-line-regex", nil, "a list of regex patterns to suppress output lines from diff output (interactive preview only)")
return cmd
}

View File

@ -40,21 +40,21 @@ Flags:
--allow-no-matching-release Do not exit with an error code if the provided selector has no matching releases.
-c, --chart string Set chart. Uses the chart set in release by default, and is available in template as {{ .Chart }}
--color Output with color
--debug Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect
--debug Enable verbose output for Helm and set log-level to debug, this disables --quiet/-q effect. Overrides "HELMFILE_DEBUG" OS environment variable when specified
--disable-force-update do not force helm repos to update when executing "helm repo add"
--enable-live-output Show live output from the Helm binary Stdout/Stderr into Helmfile own Stdout/Stderr.
It only applies for the Helm CLI commands, Stdout/Stderr for Hooks are still displayed only when it's execution finishes.
-e, --environment string specify the environment name. Overrides "HELMFILE_ENVIRONMENT" OS environment variable when specified. defaults to "default"
-f, --file helmfile.yaml load config from file or directory. defaults to "helmfile.yaml" or "helmfile.yaml.gotmpl" or "helmfile.d" (means "helmfile.d/*.yaml" or "helmfile.d/*.yaml.gotmpl") in this preference. Specify - to load the config from the standard input.
-b, --helm-binary string Path to the helm binary (default "helm")
-b, --helm-binary string Path to the helm binary. Overrides "HELMFILE_HELM_BINARY" OS environment variable when specified (default "helm")
-h, --help help for helmfile
-i, --interactive Request confirmation before attempting to modify clusters
--kube-context string Set kubectl context. Uses current context by default
-k, --kustomize-binary string Path to the kustomize binary (default "kustomize")
--log-level string Set log level, default info (default "info")
-n, --namespace string Set namespace. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}
--no-color Output without color
-q, --quiet Silence output. Equivalent to log-level warn
--kube-context string Set kubectl context. Overrides "HELMFILE_KUBE_CONTEXT" OS environment variable when specified. Uses current kubectl context by default
-k, --kustomize-binary string Path to the kustomize binary. Overrides "HELMFILE_KUSTOMIZE_BINARY" OS environment variable when specified (default "kustomize")
--log-level string Set log level. Overrides "HELMFILE_LOG_LEVEL" OS environment variable when specified (default "info")
-n, --namespace string Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}
--no-color Output without color. Overrides "HELMFILE_NO_COLOR" and "NO_COLOR" OS environment variables when specified
-q, --quiet Silence output. Equivalent to log-level warn. Overrides "HELMFILE_QUIET" OS environment variable when specified
-l, --selector stringArray Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar.
A release must match all labels in a group in order to be used. Multiple groups can be specified at once.
"--selector tier=frontend,tier!=proxy --selector tier=backend" will match all frontend, non-proxy releases AND all backend releases.

View File

@ -21,6 +21,7 @@ A `helmfile.yaml` has these top-level sections:
| `values` | Default values available in templates |
| `commonLabels` | Labels applied to all releases |
| `templates` | Reusable release templates |
| `defaultInherit` | Default template(s) for all releases to inherit |
| `hooks` | Global lifecycle hooks |
| `apiVersions` / `kubeVersion` | Kubernetes version capabilities |

View File

@ -66,6 +66,14 @@ Helmfile uses some OS environment variables to override default behaviour:
* `HELMFILE_USE_HELM_STATUS_TO_CHECK_RELEASE_EXISTENCE` - expecting non-empty value to use `helm status` to check release existence, instead of `helm list` which is the default behaviour
* `HELMFILE_EXPERIMENTAL` - enable experimental features, expecting `true` lower case
* `HELMFILE_ENVIRONMENT` - specify [Helmfile environment](environments.md), it has lower priority than CLI argument `--environment`
* `HELMFILE_KUBE_CONTEXT` - specify the kubectl context, it has lower priority than CLI argument `--kube-context`
* `HELMFILE_NAMESPACE` - specify the namespace, it has lower priority than CLI argument `--namespace`
* `HELMFILE_HELM_BINARY` - specify the path to the helm binary, it has lower priority than CLI argument `--helm-binary`
* `HELMFILE_KUSTOMIZE_BINARY` - specify the path to the kustomize binary, it has lower priority than CLI argument `--kustomize-binary`
* `HELMFILE_LOG_LEVEL` - specify the log level, it has lower priority than CLI argument `--log-level`
* `HELMFILE_DEBUG` - enable debug output, expecting `true` lower case. The same as `--debug` CLI flag
* `HELMFILE_QUIET` - silence output (equivalent to log-level warn), expecting `true` lower case. The same as `--quiet`/`-q` CLI flag
* `HELMFILE_NO_COLOR` - disable colored output, expecting `true` lower case. The same as `--no-color` CLI flag. `NO_COLOR` (any non-empty value, per [no-color.org](https://no-color.org/)) is also honored
* `HELMFILE_TEMPDIR` - specify directory to store temporary files
* `HELMFILE_UPGRADE_NOTICE_DISABLED` - expecting any non-empty value to skip the check for the latest version of Helmfile in [helmfile version](cli.md#version)
* `HELMFILE_GO_YAML_V3` - use *go.yaml.in/yaml/v3* instead of *go.yaml.in/yaml/v2*. It's `false` by default in Helmfile v0.x, and `true` in Helmfile v1.x.

View File

@ -159,6 +159,48 @@ See [issue helmfile/helmfile#435](https://github.com/helmfile/helmfile/issues/43
You might also find [issue roboll/helmfile#428](https://github.com/roboll/helmfile/issues/428) useful for more context on how we originally designed the release template and what it's supposed to solve.
### Default Template Inheritance
When all releases share the same template, specifying `inherit` on each one becomes repetitive. Use `defaultInherit` to apply a template to all releases automatically:
```yaml
templates:
default:
namespace: kube-system
missingFileHandler: Warn
values:
- config/{{`{{ .Release.Name }}`}}/values.yaml
defaultInherit: default
releases:
- name: heapster
chart: stable/heapster
version: 0.3.2
# inherits from "default" automatically
- name: kubernetes-dashboard
chart: stable/kubernetes-dashboard
version: 0.10.0
inherit:
- template: default
except:
- values
```
`defaultInherit` accepts a single template name or a list:
```yaml
# Single template
defaultInherit: default
# Multiple templates (merged in order)
defaultInherit:
- ns-template
- defaults
```
If a release already inherits from the same template explicitly, the default is not duplicated. Use `except` in the release's `inherit` to exclude specific fields when needed.
## Layering Release Values
Please note, that it is not possible to layer `values` sections. If `values` is defined in the release and in the release template, only the `values` defined in the release will be considered. The same applies to `secrets` and `set`.

40
go.mod
View File

@ -6,18 +6,19 @@ require (
dario.cat/mergo v1.0.2
github.com/Masterminds/semver/v3 v3.5.0
github.com/Masterminds/sprig/v3 v3.3.0
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/aws-sdk-go-v2/config v1.32.20
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/go-test/deep v1.1.1
github.com/gofrs/flock v0.13.0
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.7.0
github.com/gookit/color v1.6.1
github.com/gosuri/uitable v0.0.4
github.com/hashicorp/go-cty-funcs v0.1.0
github.com/hashicorp/go-getter/v2 v2.2.3
github.com/hashicorp/hcl/v2 v2.24.0
github.com/helmfile/chartify v0.26.3
github.com/helmfile/chartify v0.26.4
github.com/helmfile/vals v0.44.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
@ -163,26 +164,26 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 // indirect
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
github.com/aws/smithy-go v1.26.0 // 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
@ -192,7 +193,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/containerd/containerd v1.7.30 // indirect
github.com/containerd/containerd v1.7.32 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
@ -254,7 +255,6 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect

82
go.sum
View File

@ -152,50 +152,50 @@ github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9E
github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2/go.mod h1:bo7DhmS/OyVeAJTC768nEk92YKWskqJ4gn0gB5e59qQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17 h1:Zma31M1f9bbD/bsl6haTxupA0+z72L3l2ujKAH37zuI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.17/go.mod h1:ZNHrGwBST3tZxBCTKbindx0BEdPN0Jnh7yJ7EVnktUM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24 h1:yPLVC8Lbsw92eepgdIZCChHRNQek5eAvAz5wS+UIpJE=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.24/go.mod h1:H2h39H1AivHYkozUIUYoVJGMUOvdJ4Lv9DLyUSMAjW8=
github.com/aws/aws-sdk-go-v2/service/kms v1.51.0 h1:696UM+NwOrETBCLQJyCAGtVmmZmziBT59yMwgg6Fvrw=
github.com/aws/aws-sdk-go-v2/service/kms v1.51.0/go.mod h1:GBO/aaEi47QldDVoqw2CsM2UZQDoqDiFIMJD/ztHPs0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1 h1:vttIo8BQwfnhimKRBZBBF3Y38SAIxif72B/M91m9hDk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.1/go.mod h1:2qjInACJr84m/Tm4XXCcVNpejmbKy9kz7TEa6viQHSk=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 h1:XR42AXidhYs4HwH0I+yElLXVt7zb2hAyNHQJe6Blv7w=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6/go.mod h1:nOTsSVQlAsgwVRdtZYtECSnsInF8IUhrpnclCPat7Fs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5 h1:TY5Vh7uXQgJVuc6ahI6toLcRajG1aYSDCP3a0xsPvmo=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.5/go.mod h1:UkzShnbxHRIIL2cHi/7fBGLUAZIVTEADQjaA53bWWCE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
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=
@ -227,8 +227,8 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE=
github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M=
github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8=
github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E=
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 v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@ -469,8 +469,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU=
github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@ -532,8 +534,8 @@ github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e h1:xwy/1T0cxHW
github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM=
github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A=
github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko=
github.com/helmfile/chartify v0.26.3 h1:2wR0yfqtP/yG9y6uqM6nSKZ7W0E+nhhGGRsl14TOVVs=
github.com/helmfile/chartify v0.26.3/go.mod h1:/ReUGTnbNHIV5tKAGXODkRtS7HwnUiJi2EXbJ34RzgY=
github.com/helmfile/chartify v0.26.4 h1:pIzVe+mqBiBMlJEH3qUVKgFQKV/m4vGOVccdYWY4VbI=
github.com/helmfile/chartify v0.26.4/go.mod h1:jnMhinkuwSMfgPPNb3JYges/13xkXPEdUVnh1eGxTOQ=
github.com/helmfile/vals v0.44.0 h1:9Yf5JDIl3JUHE1XWR9GopurvAbuXowCSsgUShB4aWcI=
github.com/helmfile/vals v0.44.0/go.mod h1:siAvy7f4VPPCrgLGzDOW21ZbvR6Tbf9g7oGRme9fMH4=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=

View File

@ -484,7 +484,11 @@ func (a *App) Fetch(c FetchConfigProvider) error {
}
func (a *App) Sync(c SyncConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
var any bool
mut := &sync.Mutex{}
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := !c.SkipCRDs()
prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{
@ -500,7 +504,14 @@ func (a *App) Sync(c SyncConfigProvider) error {
Validate: c.Validate(),
Concurrency: c.Concurrency(),
}, func() []error {
ok, errs = a.SyncState(run, c)
matched, updated, es := a.SyncState(run, c)
mut.Lock()
any = any || updated
mut.Unlock()
ok = matched
errs = es
return errs
})
@ -510,6 +521,18 @@ func (a *App) Sync(c SyncConfigProvider) error {
return
}, c.IncludeTransitiveNeeds())
if err != nil {
return err
}
if ec, ok := c.(interface{ DetailedExitcode() bool }); ok && ec.DetailedExitcode() && any {
code := 2
return &Error{msg: "", code: &code}
}
return nil
}
func (a *App) Apply(c ApplyConfigProvider) error {
@ -2210,16 +2233,16 @@ func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) {
return true, errs
}
func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) {
func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) {
st := r.state
helm := r.helm
releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return false, []error{err}
return false, false, []error{err}
}
if len(releasesWithNeeds) == 0 {
return false, nil
return false, false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
@ -2228,7 +2251,7 @@ func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) {
toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, releasesWithNeeds)
if err != nil {
return false, []error{err}
return false, false, []error{err}
}
releasesToDelete := map[string]state.ReleaseSpec{}
@ -2279,9 +2302,57 @@ func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, []error) {
// Make the output deterministic for testing purpose
sort.Strings(names)
infoMsg := fmt.Sprintf(`Affected releases are:
interactive := c.Interactive()
var infoMsg string
var errs []error
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
operationsAttempted := false
if interactive {
if diffC, ok := c.(DiffConfigProvider); ok {
detectedKubeVersion := a.detectKubeVersion(st)
diffOpts := &state.DiffOpts{
Context: diffC.Context(),
Output: diffC.DiffOutput(),
Color: diffC.Color(),
NoColor: diffC.NoColor(),
Set: diffC.Set(),
DiffArgs: diffC.DiffArgs(),
SkipDiffOnInstall: diffC.SkipDiffOnInstall(),
ReuseValues: diffC.ReuseValues(),
ResetValues: diffC.ResetValues(),
PostRenderer: diffC.PostRenderer(),
PostRendererArgs: diffC.PostRendererArgs(),
SkipSchemaValidation: diffC.SkipSchemaValidation(),
SuppressOutputLineRegex: diffC.SuppressOutputLineRegex(),
TakeOwnership: diffC.TakeOwnership(),
DetectedKubeVersion: detectedKubeVersion,
}
infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts)
if len(diffErrs) > 0 {
return false, false, diffErrs
}
if infoMsgPtr != nil {
infoMsg = *infoMsgPtr
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
a.Logger.Debug(infoMsg)
}
confMsg := fmt.Sprintf(`%s
Do you really want to sync?
@ -2289,15 +2360,6 @@ Do you really want to sync?
`, infoMsg)
interactive := c.Interactive()
if !interactive {
a.Logger.Debug(infoMsg)
}
var errs []error
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = selectedAndNeededReleases
@ -2305,6 +2367,7 @@ Do you really want to sync?
if !interactive || interactive && r.askForConfirmation(confMsg) {
if len(releasesToDelete) > 0 {
operationsAttempted = true
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
@ -2326,6 +2389,7 @@ Do you really want to sync?
}
if len(releasesToUpdate) > 0 {
operationsAttempted = true
_, syncErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
@ -2378,7 +2442,9 @@ Do you really want to sync?
}
}
return true, errs
changesApplied := operationsAttempted && len(errs) == 0
return true, changesApplied, errs
}
func (a *App) template(r *Run, c TemplateConfigProvider) (bool, []error) {

View File

@ -14,6 +14,187 @@ import (
"github.com/helmfile/helmfile/pkg/helmexec"
)
func TestSyncInteractive(t *testing.T) {
type testcase struct {
interactive bool
confirm bool
error string
files map[string]string
selectors []string
lists map[exectest.ListKey]string
diffs map[exectest.DiffKey]error
wantDiffs int
upgraded []exectest.Release
deleted []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
wantUpgrades := tc.upgraded
wantDeletes := tc.deleted
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
Lists: tc.lists,
Diffs: tc.diffs,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
bs := runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, tc.files)
if tc.selectors != nil {
app.Selectors = tc.selectors
}
// Use ForEachState to gain access to the Run so we can inject Ask
forEachErr := app.ForEachState(func(run *Run) (bool, []error) {
run.Ask = func(msg string) bool {
return tc.confirm
}
ok, _, errs := app.SyncState(run, applyConfig{
concurrency: 1,
interactive: tc.interactive,
skipNeeds: true,
logger: logger,
})
return ok, errs
}, false)
var gotErr string
if forEachErr != nil {
gotErr = forEachErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
if len(wantUpgrades) > len(helm.Releases) {
t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades))
}
for relIdx := range wantUpgrades {
if wantUpgrades[relIdx].Name != helm.Releases[relIdx].Name {
t.Errorf("releases[%d].name: got %q, want %q", relIdx, helm.Releases[relIdx].Name, wantUpgrades[relIdx].Name)
}
for flagIdx := range wantUpgrades[relIdx].Flags {
if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] {
t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
}
}
}
if len(helm.Diffed) != tc.wantDiffs {
t.Fatalf("unexpected number of diffs: got %d, want %d", len(helm.Diffed), tc.wantDiffs)
}
if len(wantDeletes) > len(helm.Deleted) {
t.Fatalf("insufficient number of deletes: got %d, want %d", len(helm.Deleted), len(wantDeletes))
}
})
_ = bs
}
t.Run("non-interactive: sync proceeds without diff", func(t *testing.T) {
check(t, testcase{
interactive: false,
confirm: false,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("interactive with diff: user confirms", func(t *testing.T) {
check(t, testcase{
interactive: true,
confirm: true,
wantDiffs: 1,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
diffs: map[exectest.DiffKey]error{
{Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("interactive with diff: user rejects", func(t *testing.T) {
check(t, testcase{
interactive: true,
confirm: false,
wantDiffs: 1,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{},
diffs: map[exectest.DiffKey]error{
{Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
}
func TestSync(t *testing.T) {
type fields struct {
skipNeeds bool
@ -27,7 +208,9 @@ func TestSync(t *testing.T) {
concurrency int
timeout int
skipDiffOnInstall bool
detailedExitcode bool
error string
errorCode int
files map[string]string
selectors []string
lists map[exectest.ListKey]string
@ -88,6 +271,7 @@ func TestSync(t *testing.T) {
skipNeeds: tc.fields.skipNeeds,
includeNeeds: tc.fields.includeNeeds,
includeTransitiveNeeds: tc.fields.includeTransitiveNeeds,
detailedExitcode: tc.detailedExitcode,
})
var gotErr string
@ -99,6 +283,16 @@ func TestSync(t *testing.T) {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
if tc.errorCode >= 0 {
var gotCode int
if appErr, ok := syncErr.(*Error); ok && appErr != nil {
gotCode = appErr.Code()
}
if tc.errorCode != gotCode {
t.Fatalf("unexpected error code: got %d, want %d", gotCode, tc.errorCode)
}
}
if len(wantUpgrades) > len(helm.Releases) {
t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades))
}
@ -109,7 +303,7 @@ func TestSync(t *testing.T) {
}
for flagIdx := range wantUpgrades[relIdx].Flags {
if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] {
t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
}
}
}
@ -499,6 +693,30 @@ releases:
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("detailed-exitcode returns exit code 2 on successful sync", func(t *testing.T) {
check(t, testcase{
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
detailedExitcode: true,
errorCode: 2,
concurrency: 1,
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})

View File

@ -529,3 +529,239 @@ releases:
})
})
}
func TestTemplate_DefaultInherit(t *testing.T) {
type testcase struct {
error string
templated []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
templates:
default:
namespace: default-ns
labels:
managed: "true"
defaultInherit: default
releases:
- name: app1
chart: incubator/raw
- name: app2
chart: incubator/raw
inherit:
- template: default
except:
- labels
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
require.Equal(t, tc.templated, helm.Templated)
})
}
t.Run("default inherit applies template to all releases", func(t *testing.T) {
check(t, testcase{
templated: []exectest.Release{
{Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}},
{Name: "app2", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}},
},
})
})
}
func TestTemplate_DefaultInherit_Multiple(t *testing.T) {
type testcase struct {
error string
templated []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
templates:
ns:
namespace: from-ns-template
override:
namespace: from-ctx-template
defaultInherit:
- ns
- override
releases:
- name: app1
chart: incubator/raw
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
require.Equal(t, tc.templated, helm.Templated)
})
}
t.Run("multiple default inherits are applied in order", func(t *testing.T) {
check(t, testcase{
templated: []exectest.Release{
{Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "from-ctx-template"}},
},
})
})
}
func TestTemplate_DefaultInherit_NonExistent(t *testing.T) {
type testcase struct {
error string
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
defaultInherit: nonexistent
releases:
- name: app1
chart: incubator/raw
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
})
}
t.Run("fail due to non-existent template in defaultInherit", func(t *testing.T) {
check(t, testcase{
error: `in ./helmfile.yaml: failed executing release templates in "helmfile.yaml": release "app1" tried to inherit inexistent release template "nonexistent"`,
})
})
}

View File

@ -109,12 +109,63 @@ func (g *GlobalImpl) SetSet(set map[string]any) {
// HelmBinary returns the path to the Helm binary.
func (g *GlobalImpl) HelmBinary() string {
return g.GlobalOptions.HelmBinary
var helmBinary string
switch {
case g.GlobalOptions.HelmBinary != "":
helmBinary = g.GlobalOptions.HelmBinary
case os.Getenv("HELMFILE_HELM_BINARY") != "":
helmBinary = os.Getenv("HELMFILE_HELM_BINARY")
default:
helmBinary = state.DefaultHelmBinary
}
return helmBinary
}
// KustomizeBinary returns the path to the Kustomize binary.
func (g *GlobalImpl) KustomizeBinary() string {
return g.GlobalOptions.KustomizeBinary
var kustomizeBinary string
switch {
case g.GlobalOptions.KustomizeBinary != "":
kustomizeBinary = g.GlobalOptions.KustomizeBinary
case os.Getenv("HELMFILE_KUSTOMIZE_BINARY") != "":
kustomizeBinary = os.Getenv("HELMFILE_KUSTOMIZE_BINARY")
default:
kustomizeBinary = state.DefaultKustomizeBinary
}
return kustomizeBinary
}
// LogLevel returns the log level to use.
func (g *GlobalImpl) LogLevel() string {
var logLevel string
switch {
case g.GlobalOptions.LogLevel != "":
logLevel = g.GlobalOptions.LogLevel
case os.Getenv("HELMFILE_LOG_LEVEL") != "":
logLevel = os.Getenv("HELMFILE_LOG_LEVEL")
default:
logLevel = "info"
}
return logLevel
}
// Debug returns whether debug output is enabled.
func (g *GlobalImpl) Debug() bool {
if g.GlobalOptions.Debug {
return true
}
return os.Getenv(envvar.Debug) == "true"
}
// Quiet returns whether quiet output is enabled.
func (g *GlobalImpl) Quiet() bool {
if g.GlobalOptions.Quiet {
return true
}
return os.Getenv(envvar.Quiet) == "true"
}
// Kubeconfig returns the path to the kubeconfig file to use.
@ -124,12 +175,32 @@ func (g *GlobalImpl) Kubeconfig() string {
// KubeContext returns the name of the kubectl context to use.
func (g *GlobalImpl) KubeContext() string {
return g.GlobalOptions.KubeContext
var kubeContext string
switch {
case g.GlobalOptions.KubeContext != "":
kubeContext = g.GlobalOptions.KubeContext
case os.Getenv("HELMFILE_KUBE_CONTEXT") != "":
kubeContext = os.Getenv("HELMFILE_KUBE_CONTEXT")
default:
kubeContext = ""
}
return kubeContext
}
// Namespace returns the namespace to use.
func (g *GlobalImpl) Namespace() string {
return g.GlobalOptions.Namespace
var namespace string
switch {
case g.GlobalOptions.Namespace != "":
namespace = g.GlobalOptions.Namespace
case os.Getenv("HELMFILE_NAMESPACE") != "":
namespace = os.Getenv("HELMFILE_NAMESPACE")
default:
namespace = ""
}
return namespace
}
// Chart returns the chart to use.
@ -222,7 +293,7 @@ func (g *GlobalImpl) Color() bool {
return c
}
if g.GlobalOptions.NoColor {
if g.NoColor() {
return false
}
@ -239,7 +310,18 @@ func (g *GlobalImpl) Color() bool {
// NoColor returns the no color flag
func (g *GlobalImpl) NoColor() bool {
return g.GlobalOptions.NoColor
if g.GlobalOptions.NoColor {
return true
}
// Explicit --color short-circuits env-derived no-color: a flag must win over an env var.
if g.GlobalOptions.Color {
return false
}
if os.Getenv(envvar.NoColor) == "true" {
return true
}
// Honor the de-facto https://no-color.org/ standard: any non-empty value disables color.
return os.Getenv("NO_COLOR") != ""
}
// Env returns the environment to use.
@ -276,7 +358,7 @@ func (g *GlobalImpl) Interactive() bool {
// Args returns the args to use for helm
func (g *GlobalImpl) Args() string {
args := g.GlobalOptions.Args
enableHelmDebug := g.Debug
enableHelmDebug := g.Debug()
if enableHelmDebug {
args = fmt.Sprintf("%s %s", args, "--debug")

View File

@ -45,3 +45,378 @@ func TestFileOrDir(t *testing.T) {
}
os.Unsetenv(envvar.FilePath)
}
// TestKubeContext tests the kube-context flag and HELMFILE_KUBE_CONTEXT env var fallback
func TestKubeContext(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{KubeContext: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{KubeContext: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.KubeContext, test.env)
received := NewGlobalImpl(&test.opts).KubeContext()
require.Equalf(t, test.expected, received, "KubeContext expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.KubeContext)
}
// TestNamespace tests the namespace flag and HELMFILE_NAMESPACE env var fallback
func TestNamespace(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{Namespace: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{Namespace: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.Namespace, test.env)
received := NewGlobalImpl(&test.opts).Namespace()
require.Equalf(t, test.expected, received, "Namespace expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.Namespace)
}
// TestHelmBinary tests the helm-binary flag and HELMFILE_HELM_BINARY env var fallback
func TestHelmBinary(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "helm",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{HelmBinary: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{HelmBinary: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.HelmBinary, test.env)
received := NewGlobalImpl(&test.opts).HelmBinary()
require.Equalf(t, test.expected, received, "HelmBinary expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.HelmBinary)
}
// TestKustomizeBinary tests the kustomize-binary flag and HELMFILE_KUSTOMIZE_BINARY env var fallback
func TestKustomizeBinary(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "kustomize",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{KustomizeBinary: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{KustomizeBinary: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.KustomizeBinary, test.env)
received := NewGlobalImpl(&test.opts).KustomizeBinary()
require.Equalf(t, test.expected, received, "KustomizeBinary expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.KustomizeBinary)
}
// TestLogLevel tests the log-level flag and HELMFILE_LOG_LEVEL env var fallback
func TestLogLevel(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "info",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{LogLevel: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{LogLevel: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.LogLevel, test.env)
received := NewGlobalImpl(&test.opts).LogLevel()
require.Equalf(t, test.expected, received, "LogLevel expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.LogLevel)
}
// TestDebug tests the debug flag and HELMFILE_DEBUG env var fallback
func TestDebug(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected bool
}{
{
opts: GlobalOptions{},
env: "",
expected: false,
},
{
opts: GlobalOptions{},
env: "true",
expected: true,
},
{
opts: GlobalOptions{},
env: "anything",
expected: false,
},
{
opts: GlobalOptions{Debug: true},
env: "",
expected: true,
},
{
opts: GlobalOptions{Debug: true},
env: "true",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.Debug, test.env)
received := NewGlobalImpl(&test.opts).Debug()
require.Equalf(t, test.expected, received, "Debug expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.Debug)
}
// TestQuiet tests the quiet flag and HELMFILE_QUIET env var fallback
func TestQuiet(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected bool
}{
{
opts: GlobalOptions{},
env: "",
expected: false,
},
{
opts: GlobalOptions{},
env: "true",
expected: true,
},
{
opts: GlobalOptions{},
env: "anything",
expected: false,
},
{
opts: GlobalOptions{Quiet: true},
env: "",
expected: true,
},
{
opts: GlobalOptions{Quiet: true},
env: "true",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.Quiet, test.env)
received := NewGlobalImpl(&test.opts).Quiet()
require.Equalf(t, test.expected, received, "Quiet expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.Quiet)
}
// TestNoColor tests the no-color flag, HELMFILE_NO_COLOR and NO_COLOR env var fallbacks
func TestNoColor(t *testing.T) {
tests := []struct {
opts GlobalOptions
helmfileEnv string
standardEnv string
expected bool
}{
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "",
expected: false,
},
{
opts: GlobalOptions{},
helmfileEnv: "true",
standardEnv: "",
expected: true,
},
{
opts: GlobalOptions{},
helmfileEnv: "anything",
standardEnv: "",
expected: false,
},
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "1",
expected: true,
},
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "anything",
expected: true,
},
{
opts: GlobalOptions{NoColor: true},
helmfileEnv: "",
standardEnv: "",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.NoColor, test.helmfileEnv)
os.Setenv("NO_COLOR", test.standardEnv)
received := NewGlobalImpl(&test.opts).NoColor()
require.Equalf(t, test.expected, received, "NoColor expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.NoColor)
os.Unsetenv("NO_COLOR")
}
// TestColorRespectsNoColorEnv guards against ValidateConfig() firing when
// HELMFILE_NO_COLOR / NO_COLOR is set without an explicit --color/--no-color flag.
// Color() must consult NoColor() (which is env-aware) before falling back to TTY autodetect.
func TestColorRespectsNoColorEnv(t *testing.T) {
tests := []struct {
name string
helmfileEnv string
standardEnv string
}{
{name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"},
{name: "NO_COLOR set", standardEnv: "1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(envvar.NoColor, test.helmfileEnv)
t.Setenv("NO_COLOR", test.standardEnv)
g := NewGlobalImpl(&GlobalOptions{})
require.True(t, g.NoColor(), "NoColor() should be true when env is set")
require.False(t, g.Color(), "Color() should be false when NoColor() is true via env")
require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error from env-only no-color")
})
}
}
// TestColorFlagOverridesNoColorEnv guards against ValidateConfig() firing when
// --color is explicitly passed but HELMFILE_NO_COLOR / NO_COLOR is set in the
// environment. The flag must win over the env var.
func TestColorFlagOverridesNoColorEnv(t *testing.T) {
tests := []struct {
name string
helmfileEnv string
standardEnv string
}{
{name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"},
{name: "NO_COLOR set", standardEnv: "1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(envvar.NoColor, test.helmfileEnv)
t.Setenv("NO_COLOR", test.standardEnv)
g := NewGlobalImpl(&GlobalOptions{Color: true})
require.True(t, g.Color(), "Color() should be true when --color is set")
require.False(t, g.NoColor(), "NoColor() should be false when --color is set, even if env says otherwise")
require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error when --color overrides env no-color")
})
}
}

View File

@ -63,6 +63,21 @@ type SyncOptions struct {
TrackFailOnError bool
// Description is the description that will be passed to helm upgrade --description
Description string
// Diff-related options for --interactive mode
SuppressOutputLineRegex []string
IncludeTests bool
Suppress []string
SuppressSecrets bool
ShowSecrets bool
NoHooks bool
SuppressDiff bool
SkipDiffOnInstall bool
DiffArgs string
DetailedExitcode bool
StripTrailingCR bool
Context int
DiffOutput string
}
// NewSyncOptions creates a new Apply
@ -228,6 +243,71 @@ func (t *SyncImpl) Description() string {
return t.SyncOptions.Description
}
// SuppressOutputLineRegex returns the SuppressOutputLineRegex.
func (t *SyncImpl) SuppressOutputLineRegex() []string {
return t.SyncOptions.SuppressOutputLineRegex
}
// IncludeTests returns the IncludeTests.
func (t *SyncImpl) IncludeTests() bool {
return t.SyncOptions.IncludeTests
}
// Suppress returns the Suppress.
func (t *SyncImpl) Suppress() []string {
return t.SyncOptions.Suppress
}
// SuppressSecrets returns the SuppressSecrets.
func (t *SyncImpl) SuppressSecrets() bool {
return t.SyncOptions.SuppressSecrets
}
// ShowSecrets returns the ShowSecrets.
func (t *SyncImpl) ShowSecrets() bool {
return t.SyncOptions.ShowSecrets
}
// NoHooks returns the NoHooks.
func (t *SyncImpl) NoHooks() bool {
return t.SyncOptions.NoHooks
}
// SuppressDiff returns the SuppressDiff.
func (t *SyncImpl) SuppressDiff() bool {
return t.SyncOptions.SuppressDiff
}
// SkipDiffOnInstall returns the SkipDiffOnInstall.
func (t *SyncImpl) SkipDiffOnInstall() bool {
return t.SyncOptions.SkipDiffOnInstall
}
// DiffArgs returns the DiffArgs.
func (t *SyncImpl) DiffArgs() string {
return t.SyncOptions.DiffArgs
}
// DetailedExitcode returns the DetailedExitcode.
func (t *SyncImpl) DetailedExitcode() bool {
return t.SyncOptions.DetailedExitcode
}
// StripTrailingCR returns the StripTrailingCR.
func (t *SyncImpl) StripTrailingCR() bool {
return t.SyncOptions.StripTrailingCR
}
// Context returns the Context.
func (t *SyncImpl) Context() int {
return t.SyncOptions.Context
}
// DiffOutput returns the DiffOutput.
func (t *SyncImpl) DiffOutput() string {
return t.SyncOptions.DiffOutput
}
func (t *SyncImpl) ValidateConfig() error {
validTrackModes := []string{"helm", "helm-legacy", "kubedog"}
if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) {

View File

@ -9,6 +9,14 @@ const (
DisableRunnerUniqueID = "HELMFILE_DISABLE_RUNNER_UNIQUE_ID"
Experimental = "HELMFILE_EXPERIMENTAL" // environment variable for experimental features, expecting "true" lower case
Environment = "HELMFILE_ENVIRONMENT"
KubeContext = "HELMFILE_KUBE_CONTEXT"
Namespace = "HELMFILE_NAMESPACE"
HelmBinary = "HELMFILE_HELM_BINARY"
KustomizeBinary = "HELMFILE_KUSTOMIZE_BINARY"
LogLevel = "HELMFILE_LOG_LEVEL"
Debug = "HELMFILE_DEBUG"
Quiet = "HELMFILE_QUIET"
NoColor = "HELMFILE_NO_COLOR"
FilePath = "HELMFILE_FILE_PATH"
TempDir = "HELMFILE_TEMPDIR"
UpgradeNoticeDisabled = "HELMFILE_UPGRADE_NOTICE_DISABLED"

312
pkg/kubedog/display.go Normal file
View File

@ -0,0 +1,312 @@
package kubedog
import (
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/werf/kubedog/pkg/tracker/daemonset"
"github.com/werf/kubedog/pkg/tracker/deployment"
"github.com/werf/kubedog/pkg/tracker/indicators"
"github.com/werf/kubedog/pkg/tracker/job"
"github.com/werf/kubedog/pkg/tracker/pod"
"github.com/werf/kubedog/pkg/tracker/statefulset"
"github.com/werf/kubedog/pkg/utils"
"golang.org/x/term"
)
var statusProgressTableRatio = []float64{.58, .11, .12, .19}
var statusProgressSubTableRatio = []float64{.40, .15, .20, .25}
func writeOut(out io.Writer, s string) {
_, _ = fmt.Fprint(out, s)
}
func displayDeploymentStatusProgress(out io.Writer, resourceCaption string, status deployment.DeploymentStatus, prevStatus *deployment.DeploymentStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
available := "-"
if status.AvailableIndicator != nil {
available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("DEPLOYMENT", "REPLICAS", "AVAILABLE", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, available, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayStatefulSetStatusProgress(out io.Writer, resourceCaption string, status statefulset.StatefulSetStatus, prevStatus *statefulset.StatefulSetStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
ready := "-"
if status.ReadyIndicator != nil {
ready = status.ReadyIndicator.FormatTableElem(prevStatus.ReadyIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("STATEFULSET", "REPLICAS", "READY", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, ready, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
} else {
for _, w := range status.WarningMessages {
args = append(args, formatResourceWarning(w))
}
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayDaemonSetStatusProgress(out io.Writer, resourceCaption string, status daemonset.DaemonSetStatus, prevStatus *daemonset.DaemonSetStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
available := "-"
if status.AvailableIndicator != nil {
available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("DAEMONSET", "REPLICAS", "AVAILABLE", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, available, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayJobStatusProgress(out io.Writer, resourceCaption string, status job.JobStatus, prevStatus *job.JobStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
succeeded := "-"
if status.SucceededIndicator != nil {
succeeded = status.SucceededIndicator.FormatTableElem(prevStatus.SucceededIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("JOB", "ACTIVE", "DURATION", "SUCCEEDED/FAILED")
var active interface{} = "-"
if status.Active != 0 {
active = status.Active
}
failed := fmt.Sprintf("%d", status.Failed)
args := []interface{}{resourceCaption, active, status.Age, strings.Join([]string{succeeded, failed}, "/")}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
if len(status.Pods) > 0 {
st := displayChildPodsStatusProgress(&t, prevStatus.Pods, status.Pods, nil, showProgress)
extraMsg := ""
if len(status.WaitingForMessages) > 0 {
extraMsg += "---\n"
extraMsg += utils.BlueF("Waiting for: %s", strings.Join(status.WaitingForMessages, ", "))
}
st.Commit(extraMsg)
}
writeOut(out, t.Render())
}
func displayChildPodsAndWaiting(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, waitingForMessages []string) {
if len(pods) > 0 {
st := displayChildPodsStatusProgress(t, prevPods, pods, newPodsNames, true)
extraMsg := ""
if len(waitingForMessages) > 0 {
extraMsg += "---\n"
extraMsg += utils.BlueF("Waiting for: %s", strings.Join(waitingForMessages, ", "))
}
st.Commit(extraMsg)
}
}
func displayChildPodsStatusProgress(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, showProgress bool) *utils.Table {
subT := t.SubTable(statusProgressSubTableRatio...)
st := &subT
st.Header("POD", "READY", "RESTARTS", "STATUS")
podsNames := make([]string, 0, len(pods))
for podName := range pods {
podsNames = append(podsNames, podName)
}
sort.Strings(podsNames)
var podRows [][]interface{}
newPodSet := make(map[string]struct{}, len(newPodsNames))
for _, name := range newPodsNames {
newPodSet[name] = struct{}{}
}
for _, podName := range podsNames {
var podRow []interface{}
_, isPodNew := newPodSet[podName]
prevPodStatus := prevPods[podName]
podStatus := pods[podName]
isReady := false
if podStatus.StatusIndicator != nil {
isReady = podStatus.StatusIndicator.IsReady()
}
resource := formatPodResourceCaption(podName, isReady, podStatus.IsFailed, isPodNew)
ready := fmt.Sprintf("%d/%d", podStatus.ReadyContainers, podStatus.TotalContainers)
status := "-"
if podStatus.StatusIndicator != nil {
status = podStatus.StatusIndicator.FormatTableElem(prevPodStatus.StatusIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
IsResourceNew: isPodNew,
})
}
podRow = append(podRow, resource, ready, podStatus.Restarts, status)
if podStatus.IsFailed {
podRow = append(podRow, formatResourceError(podStatus.FailedReason))
}
podRows = append(podRows, podRow)
}
st.Rows(podRows...)
return st
}
func formatResourceCaption(caption string, isReady, isFailed bool) string {
switch {
case isReady:
return utils.GreenF("%s", caption)
case isFailed:
return utils.RedF("%s", caption)
default:
return utils.YellowF("%s", caption)
}
}
func formatPodResourceCaption(podName string, isReady, isFailed, isNew bool) string {
if !isNew {
return podName
}
return formatResourceCaption(podName, isReady, isFailed)
}
func formatResourceError(reason string) string {
return utils.RedF("error: %s", reason)
}
func formatResourceWarning(reason string) string {
return utils.YellowF("warning: %s", reason)
}
func termWidth() int {
if w, _, err := term.GetSize(int(os.Stderr.Fd())); err == nil && w > 0 {
return w
}
return 140
}
func displayCanaryStatus(out io.Writer, resourceCaption string, status CanaryStatusView) {
var parts []string
if status.Phase != "" {
parts = append(parts, fmt.Sprintf("phase %s", status.Phase))
}
if status.Age != "" {
parts = append(parts, fmt.Sprintf("age %s", status.Age))
}
msg := fmt.Sprintf("%s: %s", resourceCaption, strings.Join(parts, ", "))
if status.IsFailed {
msg = utils.RedF("%s", msg)
}
_, _ = fmt.Fprintln(out, msg)
}
type CanaryStatusView struct {
Phase string
Age string
IsFailed bool
}
func statusOutput() io.Writer {
return os.Stderr
}

453
pkg/kubedog/display_test.go Normal file
View File

@ -0,0 +1,453 @@
package kubedog
import (
"bytes"
"os"
"strings"
"testing"
"github.com/gookit/color"
"github.com/stretchr/testify/assert"
"github.com/werf/kubedog/pkg/tracker/daemonset"
"github.com/werf/kubedog/pkg/tracker/deployment"
"github.com/werf/kubedog/pkg/tracker/job"
"github.com/werf/kubedog/pkg/tracker/pod"
"github.com/werf/kubedog/pkg/tracker/statefulset"
)
// TestMain forces ANSI color output so that tests asserting on escape codes
// pass in non-TTY environments such as CI runners.
func TestMain(m *testing.M) {
color.ForceColor()
os.Exit(m.Run())
}
// --- formatResourceCaption ---
func TestFormatResourceCaption_Ready(t *testing.T) {
result := formatResourceCaption("deploy/myapp", true, false)
assert.Contains(t, result, "deploy/myapp")
// Green ANSI escape should be present
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_Failed(t *testing.T) {
result := formatResourceCaption("deploy/myapp", false, true)
assert.Contains(t, result, "deploy/myapp")
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_InProgress(t *testing.T) {
result := formatResourceCaption("deploy/myapp", false, false)
assert.Contains(t, result, "deploy/myapp")
// Yellow for in-progress
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_ReadyTakesPrecedence(t *testing.T) {
// isReady=true should win over isFailed=true
resultReady := formatResourceCaption("x", true, false)
resultFailed := formatResourceCaption("x", false, true)
// Colors should differ
assert.NotEqual(t, resultReady, resultFailed)
}
// --- formatPodResourceCaption ---
func TestFormatPodResourceCaption_NotNew(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", true, false, false)
// Not a new pod: no coloring applied, just the plain name
assert.Equal(t, "my-pod-abc", result)
}
func TestFormatPodResourceCaption_NewAndReady(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", true, false, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
func TestFormatPodResourceCaption_NewAndFailed(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", false, true, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
func TestFormatPodResourceCaption_NewInProgress(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", false, false, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
// --- formatResourceError / formatResourceWarning ---
func TestFormatResourceError(t *testing.T) {
result := formatResourceError("CrashLoopBackOff")
assert.Contains(t, result, "error:")
assert.Contains(t, result, "CrashLoopBackOff")
}
func TestFormatResourceWarning(t *testing.T) {
result := formatResourceWarning("PodNotScheduled")
assert.Contains(t, result, "warning:")
assert.Contains(t, result, "PodNotScheduled")
}
// --- termWidth ---
func TestTermWidth_ReturnsPositive(t *testing.T) {
w := termWidth()
assert.Greater(t, w, 0)
}
// --- displayDeploymentStatusProgress ---
func TestDisplayDeploymentStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
var prev deployment.DeploymentStatus
status := deployment.DeploymentStatus{}
// Must not panic and must produce some output
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.NotEmpty(t, out)
assert.Contains(t, out, "DEPLOYMENT")
}
func TestDisplayDeploymentStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, true)
var prev deployment.DeploymentStatus
status := deployment.DeploymentStatus{
IsFailed: true,
FailedReason: "ImagePullBackOff",
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "ImagePullBackOff")
}
func TestDisplayDeploymentStatusProgress_WithWaitingMessage(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
var prev deployment.DeploymentStatus
// WaitingForMessages is only rendered when there are pods
status := deployment.DeploymentStatus{
StatusGeneration: 1,
WaitingForMessages: []string{"up-to-date 1->3"},
Pods: map[string]pod.PodStatus{
"myapp-pod-abc": {ReadyContainers: 1, TotalContainers: 1},
},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "Waiting for:")
assert.Contains(t, out, "up-to-date 1->3")
}
func TestDisplayDeploymentStatusProgress_WithPods(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: map[string]pod.PodStatus{
"myapp-abc-123": {ReadyContainers: 1, TotalContainers: 1},
},
NewPodsNames: []string{"myapp-abc-123"},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "POD")
assert.Contains(t, out, "myapp-abc-123")
}
// --- displayStatefulSetStatusProgress ---
func TestDisplayStatefulSetStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, false)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "STATEFULSET")
}
func TestDisplayStatefulSetStatusProgress_WithWarnings(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, false)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{
WarningMessages: []string{"PodNotScheduled: insufficient resources"},
}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "warning:")
assert.Contains(t, out, "PodNotScheduled")
}
func TestDisplayStatefulSetStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, true)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{
IsFailed: true,
FailedReason: "timeout waiting for ready",
}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "timeout waiting for ready")
}
// --- displayDaemonSetStatusProgress ---
func TestDisplayDaemonSetStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("ds/myapp", false, false)
var prev daemonset.DaemonSetStatus
status := daemonset.DaemonSetStatus{}
assert.NotPanics(t, func() {
displayDaemonSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "DAEMONSET")
}
func TestDisplayDaemonSetStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("ds/myapp", false, true)
var prev daemonset.DaemonSetStatus
status := daemonset.DaemonSetStatus{
IsFailed: true,
FailedReason: "node not ready",
}
assert.NotPanics(t, func() {
displayDaemonSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "node not ready")
}
// --- displayJobStatusProgress ---
func TestDisplayJobStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "JOB")
}
func TestDisplayJobStatusProgress_Active(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{
StatusGeneration: 1,
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "ACTIVE")
}
func TestDisplayJobStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, true)
var prev job.JobStatus
status := job.JobStatus{
IsFailed: true,
FailedReason: "BackoffLimitExceeded",
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "BackoffLimitExceeded")
}
func TestDisplayJobStatusProgress_WithWaitingMessage(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{
WaitingForMessages: []string{"succeeded 0->1"},
Pods: map[string]pod.PodStatus{
"myjob-abc": {ReadyContainers: 0, TotalContainers: 1},
},
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "Waiting for:")
assert.Contains(t, out, "succeeded 0->1")
}
// --- displayChildPodsStatusProgress ---
func TestDisplayChildPodsStatusProgress_Empty(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
// With no pods, only the header should be rendered
prev := deployment.DeploymentStatus{}
status := deployment.DeploymentStatus{
Pods: map[string]pod.PodStatus{},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
// No POD sub-table header when pods is empty
out := buf.String()
assert.NotContains(t, out, "POD")
}
func TestDisplayChildPodsStatusProgress_NewPodSet(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
// Two pods: one new, one old
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: map[string]pod.PodStatus{
"pod-new-abc": {ReadyContainers: 0, TotalContainers: 1},
"pod-old-xyz": {ReadyContainers: 1, TotalContainers: 1},
},
NewPodsNames: []string{"pod-new-abc"},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "pod-new-abc")
assert.Contains(t, out, "pod-old-xyz")
}
func TestDisplayChildPodsStatusProgress_ManyPodsO1Check(t *testing.T) {
// Verifies O(1) new-pod detection works correctly for many pods
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
pods := make(map[string]pod.PodStatus)
newNames := make([]string, 0, 10)
for i := 0; i < 20; i++ {
name := strings.Repeat("a", i+1)
pods[name] = pod.PodStatus{ReadyContainers: 1, TotalContainers: 1}
if i%2 == 0 {
newNames = append(newNames, name)
}
}
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: pods,
NewPodsNames: newNames,
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
assert.NotEmpty(t, buf.String())
}
// --- displayCanaryStatus ---
func TestDisplayCanaryStatus_Normal(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, false)
view := CanaryStatusView{Phase: "Progressing", Age: "1m"}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Progressing")
assert.Contains(t, out, "1m")
}
func TestDisplayCanaryStatus_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, true)
view := CanaryStatusView{Phase: "Failed", IsFailed: true}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Failed")
}
func TestDisplayCanaryStatus_Succeeded(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", true, false)
view := CanaryStatusView{Phase: "Succeeded"}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Succeeded")
}
func TestDisplayCanaryStatus_EmptyPhaseAndAge(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, false)
view := CanaryStatusView{}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
// Should still produce output (at least the caption + newline)
assert.NotEmpty(t, buf.String())
}
// --- writeOut ---
func TestWriteOut(t *testing.T) {
var buf bytes.Buffer
writeOut(&buf, "hello world")
assert.Equal(t, "hello world", buf.String())
}
func TestWriteOut_Empty(t *testing.T) {
var buf bytes.Buffer
writeOut(&buf, "")
assert.Equal(t, "", buf.String())
}

View File

@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/werf/kubedog/pkg/display"
"github.com/werf/kubedog/pkg/informer"
"github.com/werf/kubedog/pkg/tracker"
"github.com/werf/kubedog/pkg/tracker/canary"
@ -234,6 +235,7 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
ParentContext: ctx,
Timeout: t.trackOptions.Timeout,
LogsFromTime: time.Now().Add(-t.trackOptions.LogsSince),
IgnoreLogs: !t.trackOptions.Logs,
}
var wg sync.WaitGroup
@ -312,13 +314,35 @@ func (t *Tracker) runDeploymentTracker(ctx context.Context, tr *deployment.Track
}
func (t *Tracker) waitDeploymentTracker(ctx context.Context, tr *deployment.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus deployment.DeploymentStatus
out := statusOutput()
resourceName := fmt.Sprintf("deploy/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
t.logger.Debugf("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName)
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("deployment %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("deploy/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("deploy/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message)
case <-tr.AddedReplicaSet:
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
@ -338,13 +362,34 @@ func (t *Tracker) runStatefulSetTracker(ctx context.Context, tr *statefulset.Tra
}
func (t *Tracker) waitStatefulSetTracker(ctx context.Context, tr *statefulset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus statefulset.StatefulSetStatus
out := statusOutput()
resourceName := fmt.Sprintf("sts/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
t.logger.Debugf("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName)
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("statefulset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("sts/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("sts/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
@ -364,13 +409,34 @@ func (t *Tracker) runDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker
}
func (t *Tracker) waitDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus daemonset.DaemonSetStatus
out := statusOutput()
resourceName := fmt.Sprintf("ds/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
t.logger.Debugf("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName)
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("daemonset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("ds/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("ds/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
@ -390,13 +456,34 @@ func (t *Tracker) runJobTracker(ctx context.Context, tr *job.Tracker, errCh chan
}
func (t *Tracker) waitJobTracker(ctx context.Context, tr *job.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus job.JobStatus
out := statusOutput()
resourceName := fmt.Sprintf("job/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Succeeded:
t.logger.Debugf("Job %s/%s succeeded", tr.Namespace, tr.ResourceName)
displayJobStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("Job %s/%s succeeded", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("job %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("job/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("job/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
@ -416,13 +503,44 @@ func (t *Tracker) runCanaryTracker(ctx context.Context, tr *canary.Tracker, errC
}
func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
out := statusOutput()
resourceName := fmt.Sprintf("canary/%s", tr.ResourceName)
var lastView CanaryStatusView
for {
select {
case status := <-tr.Added:
view := CanaryStatusView{
Phase: string(status.CanaryStatus.Phase),
IsFailed: status.IsFailed,
}
displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view)
lastView = view
case <-tr.Succeeded:
t.logger.Debugf("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName)
displayCanaryStatus(out, formatResourceCaption(resourceName, true, false), CanaryStatusView{Phase: lastView.Phase})
t.logger.Infof("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayCanaryStatus(out, formatResourceCaption(resourceName, false, true), CanaryStatusView{
Phase: status.FailedReason,
IsFailed: true,
})
return fmt.Errorf("canary %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
view := CanaryStatusView{
Phase: func() string {
if status.StatusIndicator != nil {
return status.StatusIndicator.Value
}
return ""
}(),
Age: status.Age,
IsFailed: status.IsFailed,
}
displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view)
lastView = view
case msg := <-tr.EventMsg:
t.logger.Infof("canary/%s: %s", tr.ResourceName, msg)
case err := <-trackErrCh:
return err
case <-doneCh:
@ -433,6 +551,12 @@ func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, tra
}
}
func (t *Tracker) logPodLogChunk(podName string, logLines []display.LogLine) {
for _, line := range logLines {
t.logger.Infof("po/%s [%s] %s", podName, line.Timestamp, line.Message)
}
}
func (t *Tracker) buildTargets(resources []*resource.Resource) []trackTarget {
var targets []trackTarget
for _, res := range resources {

View File

@ -1,6 +1,9 @@
package state
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -9,8 +12,10 @@ import (
"testing"
"go.uber.org/zap"
helmchart "helm.sh/helm/v3/pkg/chart"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/runtime"
"github.com/helmfile/helmfile/pkg/yaml"
)
@ -645,3 +650,462 @@ dependencies:
t.Errorf("expected original dependency repository %q, got %q", wantRepository, chartMeta.Dependencies[0].Repository)
}
}
// TestRewriteChartDependencies_RefreshesChartLock verifies that when Chart.yaml has
// its file:// dependencies rewritten to absolute paths, an existing Chart.lock is
// also updated in the temp copy: the digest is recomputed (otherwise `helm dep
// build` would error with "lock out of sync") and matching file:// repository URLs
// are mirrored over from the rewritten Chart.yaml (otherwise `helm dep build` would
// resolve the lock's relative file:// path against the temp directory and fail).
// Locked versions are preserved verbatim.
func TestRewriteChartDependencies_RefreshesChartLock(t *testing.T) {
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
- name: remote-dep
repository: https://example.com/charts
version: "*"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
chartLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
- name: remote-dep
repository: https://example.com/charts
version: 1.2.3
digest: ` + originalDigest + `
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
if rewrittenPath == tempDir {
t.Fatalf("expected a temp copy to be created, got original path %q", rewrittenPath)
}
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
} `yaml:"dependencies"`
Digest string `yaml:"digest"`
Generated string `yaml:"generated"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if lock.Digest == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", lock.Digest)
}
if !strings.HasPrefix(lock.Digest, "sha256:") {
t.Errorf("expected sha256 digest, got %q", lock.Digest)
}
if len(lock.Dependencies) != 2 {
t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies))
}
// The local file:// dependency's repository must be mirrored to the absolute
// path so `helm dep build` can resolve it from the temp chart directory.
localDep := lock.Dependencies[0]
if localDep.Name != "local-dep" {
t.Fatalf("expected first lock dep name 'local-dep', got %q", localDep.Name)
}
if !filepath.IsAbs(strings.TrimPrefix(localDep.Repository, "file://")) {
t.Errorf("expected local-dep repository to be an absolute file:// path, got %q", localDep.Repository)
}
if localDep.Version != "1.0.0" {
t.Errorf("expected local-dep version preserved as 1.0.0, got %q", localDep.Version)
}
// Remote (non-file://) deps must be untouched.
remoteDep := lock.Dependencies[1]
if remoteDep.Repository != "https://example.com/charts" {
t.Errorf("expected remote dep repository unchanged, got %q", remoteDep.Repository)
}
if remoteDep.Version != "1.2.3" {
t.Errorf("expected remote dep version preserved as 1.2.3, got %q", remoteDep.Version)
}
// The original Chart.lock on disk must be untouched.
originalLock, err := os.ReadFile(filepath.Join(tempDir, "Chart.lock"))
if err != nil {
t.Fatalf("reading original Chart.lock: %v", err)
}
if string(originalLock) != chartLock {
t.Errorf("original Chart.lock was modified; expected unchanged content")
}
}
// TestRewriteChartDependencies_RefreshesChartLockWithExtraFields verifies that
// Chart.lock digest recomputation includes all dependency fields (alias, condition,
// tags, import-values, enabled) — not just name/repository/version — so the digest
// stays compatible with Helm's resolver.HashReq for charts using those fields.
// It proves field coverage by running two chart variants under a shared root
// (so file:// paths resolve to the same absolute location) and asserting the
// digests differ only due to extra fields.
func TestRewriteChartDependencies_RefreshesChartLockWithExtraFields(t *testing.T) {
// Use a shared root so both chart variants resolve file://../local-dep to the
// same absolute path — isolating the digest difference to field content only.
sharedRoot := t.TempDir()
chartDir := filepath.Join(sharedRoot, "parent")
if err := os.MkdirAll(chartDir, 0755); err != nil {
t.Fatalf("creating chart dir: %v", err)
}
// Run rewriteChartDependencies for a given Chart.yaml and return the recomputed digest.
getDigest := func(t *testing.T, chartYaml, chartLock string) string {
t.Helper()
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: chartDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
return lock.Digest
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
baseLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
digest: ` + originalDigest + `
generated: "2024-01-01T00:00:00Z"
`
// Chart.yaml with extra fields (alias, condition, tags, import-values).
chartYamlWithExtras := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
condition: local-dep.enabled
tags:
- frontend
- optional
import-values:
- child: config
parent: global.config
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
`
// Same chart without condition/tags/import-values — only alias remains.
chartYamlWithoutExtras := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
`
digestWith := getDigest(t, chartYamlWithExtras, baseLock)
digestWithout := getDigest(t, chartYamlWithoutExtras, baseLock)
if !strings.HasPrefix(digestWith, "sha256:") {
t.Errorf("expected sha256 digest, got %q", digestWith)
}
if digestWith == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", digestWith)
}
if digestWith == digestWithout {
t.Errorf("digest should differ when extra fields (condition, tags, import-values) are present, but both are %q", digestWith)
}
// Also verify alias-based matching: both deps have name "local-dep" but
// different aliases; both should get their file:// paths rewritten.
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYamlWithExtras), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(baseLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: chartDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
Alias string `yaml:"alias"`
} `yaml:"dependencies"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if len(lock.Dependencies) != 2 {
t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies))
}
dep1 := lock.Dependencies[0]
if dep1.Alias != "my-local" {
t.Errorf("expected first lock dep alias 'my-local', got %q", dep1.Alias)
}
if !filepath.IsAbs(strings.TrimPrefix(dep1.Repository, "file://")) {
t.Errorf("expected first dep repository to be an absolute file:// path, got %q", dep1.Repository)
}
dep2 := lock.Dependencies[1]
if dep2.Alias != "my-local-alt" {
t.Errorf("expected second lock dep alias 'my-local-alt', got %q", dep2.Alias)
}
if !filepath.IsAbs(strings.TrimPrefix(dep2.Repository, "file://")) {
t.Errorf("expected second dep repository to be an absolute file:// path, got %q", dep2.Repository)
}
}
// TestRewriteChartDependencies_GoYamlV2ImportValues verifies that Chart.lock
// refresh works under go-yaml v2 (HELMFILE_GO_YAML_V3=false), where nested
// maps in import-values decode as map[interface{}]interface{} which json.Marshal
// cannot handle without normalization.
func TestRewriteChartDependencies_GoYamlV2ImportValues(t *testing.T) {
prev := runtime.GoYamlV3
runtime.GoYamlV3 = false
t.Cleanup(func() {
runtime.GoYamlV3 = prev
})
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
import-values:
- child: config
parent: global.config
`
chartLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
import-values:
- child: config
parent: global.config
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if !strings.HasPrefix(lock.Digest, "sha256:") {
t.Errorf("expected sha256 digest, got %q", lock.Digest)
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
if lock.Digest == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", lock.Digest)
}
}
// TestRewriteChartDependencies_DigestMatchesHelmHashReq verifies the recomputed
// digest matches what Helm's resolver.HashReq would produce for a known input.
// This guards against producing a digest that is "different" but still rejected
// by `helm dependency build`.
func TestRewriteChartDependencies_DigestMatchesHelmHashReq(t *testing.T) {
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: test-chart
version: 1.0.0
dependencies:
- name: dep-a
repository: file://../dep-a
version: 2.0.0
condition: dep-a.enabled
tags:
- backend
`
chartLock := `dependencies:
- name: dep-a
repository: file://../dep-a
version: 2.0.0
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []*helmchart.Dependency `yaml:"dependencies"`
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
// Compute the expected digest independently using Helm's HashReq algorithm:
// sha256(json.Marshal([2][]*chart.Dependency{req, lock}))
// where req = rewritten Chart.yaml deps, lock = rewritten Chart.lock deps.
absDepA, err := filepath.Abs(filepath.Join(tempDir, "../dep-a"))
if err != nil {
t.Fatalf("resolving absolute path: %v", err)
}
req := []*helmchart.Dependency{
{
Name: "dep-a",
Repository: "file://" + absDepA,
Version: "2.0.0",
Condition: "dep-a.enabled",
Tags: []string{"backend"},
},
}
lockDeps := []*helmchart.Dependency{
{
Name: "dep-a",
Repository: "file://" + absDepA,
Version: "2.0.0",
},
}
payload, err := json.Marshal([2][]*helmchart.Dependency{req, lockDeps})
if err != nil {
t.Fatalf("marshaling expected digest payload: %v", err)
}
sum := sha256.Sum256(payload)
expectedDigest := "sha256:" + hex.EncodeToString(sum[:])
if lock.Digest != expectedDigest {
t.Errorf("digest mismatch with Helm's HashReq algorithm:\n got: %s\n want: %s", lock.Digest, expectedDigest)
}
}

View File

@ -448,7 +448,8 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp
for _, d := range release.Dependencies {
chart := d.Chart
if st.fs.DirectoryExistsAt(chart) {
normalizedChart := normalizeChart(st.basePath, chart)
if st.fs.DirectoryExistsAt(normalizedChart) {
var err error
// Otherwise helm-dependency-up on the temporary chart generated by chartify ends up errors like:

View File

@ -0,0 +1,138 @@
package state
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/filesystem"
)
// TestLocalDependencyChartPathNormalization tests that relative chart paths in
// release dependencies (like "../chart") are normalized to absolute paths
// relative to basePath before checking if the directory exists.
// This is a regression test for issue #2596.
//
// Background: When helmfile.d/ contains multiple release files and one release
// has a local chart dependency (chart: ../chart), the dependency chart path was
// passed to DirectoryExistsAt without normalization, causing it to be resolved
// relative to the CWD instead of basePath. This made helmfile fail to detect
// the local chart and instead try to resolve it as a remote repo, resulting in
// "failed reading adhoc dependencies: no helm list entry found for repository".
func TestLocalDependencyChartPathNormalization(t *testing.T) {
tempDir := t.TempDir()
chartDir := filepath.Join(tempDir, "chart")
require.NoError(t, os.MkdirAll(chartDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: test-chart
version: 0.1.0
`), 0644))
helmfileDir := filepath.Join(tempDir, "helmfile.d")
require.NoError(t, os.MkdirAll(helmfileDir, 0755))
tests := []struct {
name string
chartPath string
basePath string
expectLocal bool
}{
{
name: "relative path ../chart normalized from helmfile.d",
chartPath: "../chart",
basePath: helmfileDir,
expectLocal: true,
},
{
name: "absolute path works unchanged",
chartPath: chartDir,
basePath: helmfileDir,
expectLocal: true,
},
{
name: "non-existent relative path not detected as local",
chartPath: "../nonexistent",
basePath: helmfileDir,
expectLocal: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalizedChart := normalizeChart(tt.basePath, tt.chartPath)
fs := filesystem.DefaultFileSystem()
isLocal := fs.DirectoryExistsAt(normalizedChart)
assert.Equal(t, tt.expectLocal, isLocal,
"normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v",
tt.basePath, tt.chartPath, normalizedChart, isLocal, tt.expectLocal)
})
}
}
// TestDependencyChartPathResolutionWithPrepareChartify verifies that the dependency
// chart path is normalized using basePath before calling DirectoryExistsAt,
// which is the core of the fix for issue #2596.
func TestDependencyChartPathResolutionWithPrepareChartify(t *testing.T) {
tempDir := t.TempDir()
chartDir := filepath.Join(tempDir, "chart")
require.NoError(t, os.MkdirAll(chartDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: test-chart
version: 0.1.0
`), 0644))
helmfileDir := filepath.Join(tempDir, "helmfile.d")
require.NoError(t, os.MkdirAll(helmfileDir, 0755))
fs := filesystem.DefaultFileSystem()
tests := []struct {
name string
depChartPath string
basePath string
expectDetected bool
}{
{
name: "relative ../chart from helmfile.d detected as local",
depChartPath: "../chart",
basePath: helmfileDir,
expectDetected: true,
},
{
name: "absolute path detected as local",
depChartPath: chartDir,
basePath: helmfileDir,
expectDetected: true,
},
{
name: "non-existent relative path not detected",
depChartPath: "../nonexistent",
basePath: helmfileDir,
expectDetected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalizedChart := normalizeChart(tt.basePath, tt.depChartPath)
isLocal := fs.DirectoryExistsAt(normalizedChart)
assert.Equal(t, tt.expectDetected, isLocal,
"normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v",
tt.basePath, tt.depChartPath, normalizedChart, isLocal, tt.expectDetected)
if tt.expectDetected && !filepath.IsAbs(tt.depChartPath) {
absChart, err := filepath.Abs(filepath.Join(tt.basePath, tt.depChartPath))
require.NoError(t, err)
assert.Equal(t, absChart, normalizedChart,
"normalized path should match expected absolute path")
}
})
}
}

View File

@ -193,13 +193,18 @@ func (r ReleaseSpec) ExecuteTemplateExpressions(renderer *tmpl.FileRenderer) (*R
}
result.SetValuesTemplate[i].File = s.String()
}
for j, ts := range val.Values {
for j, tv := range val.Values {
// values
s, err := renderer.RenderTemplateContentToBuffer([]byte(ts))
if err != nil {
return nil, fmt.Errorf("failed executing template expressions in release \"%s\".set[%d].values[%d] = \"%s\": %v", r.Name, i, j, ts, err)
switch ts := tv.(type) {
case string:
s, err := renderer.RenderTemplateContentToBuffer([]byte(ts))
if err != nil {
return nil, fmt.Errorf("failed executing template expressions in release \"%s\".set[%d].values[%d] = \"%s\": %v", r.Name, i, j, ts, err)
}
result.SetValuesTemplate[i].Values[j] = s.String()
default:
result.SetValuesTemplate[i].Values[j] = ts
}
result.SetValuesTemplate[i].Values[j] = s.String()
}
}

View File

@ -4,7 +4,9 @@ import (
"bytes"
gocontext "context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
@ -27,6 +29,7 @@ import (
"github.com/helmfile/vals"
"github.com/tatsushid/go-prettytable"
"go.uber.org/zap"
helmchart "helm.sh/helm/v3/pkg/chart"
cliv3 "helm.sh/helm/v3/pkg/cli"
cliv4 "helm.sh/helm/v4/pkg/cli"
@ -92,6 +95,11 @@ type ReleaseSetSpec struct {
Templates map[string]TemplateSpec `yaml:"templates"`
// DefaultInherit is a list of template names that all releases inherit by default.
// Each release will automatically inherit these templates unless it already explicitly
// inherits from the same template.
DefaultInherit DefaultInherits `yaml:"defaultInherit,omitempty"`
Env environment.Environment `yaml:"-"`
// If set to "Error", return an error when a subhelmfile points to a
@ -511,6 +519,45 @@ func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}
type DefaultInherits []string
func (r *DefaultInherits) UnmarshalYAML(unmarshal func(any) error) error {
var list []string
if err := unmarshal(&list); err == nil {
*r = normalizeDefaultInherits(list)
return nil
}
var single string
if err := unmarshal(&single); err != nil {
return err
}
*r = normalizeDefaultInherits([]string{single})
return nil
}
// normalizeDefaultInherits trims names, drops empty entries, and returns nil for an empty result.
func normalizeDefaultInherits(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, name := range in {
name = strings.TrimSpace(name)
if name == "" {
continue
}
out = append(out, name)
}
if len(out) == 0 {
return nil
}
return out
}
// ChartPathOrName returns ChartPath if it is non-empty, and returns Chart otherwise.
// This is useful to redirect helm commands like `helm template`, `helm dependency update`, `helm diff`, and `helm upgrade --install` to
// our modified version of the chart, in case the user configured Helmfile to do modify the chart before being passed to Helm.
@ -529,10 +576,10 @@ type Release struct {
// SetValue are the key values to set on a helm release
type SetValue struct {
Name string `yaml:"name,omitempty"`
Value string `yaml:"value,omitempty"`
File string `yaml:"file,omitempty"`
Values []string `yaml:"values,omitempty"`
Name string `yaml:"name,omitempty"`
Value string `yaml:"value,omitempty"`
File string `yaml:"file,omitempty"`
Values []any `yaml:"values,omitempty"`
}
// AffectedReleases hold the list of released that where updated, deleted, or in error
@ -1528,6 +1575,121 @@ func (st *HelmState) rewriteChartDependencies(chartPath string) (string, func(),
st.logger.Debugf("Rewrote Chart.yaml with absolute dependency paths at %s", tempChartYamlPath)
// Rewriting Chart.yaml invalidates Chart.lock's digest, since helm computes the
// digest over the JSON-marshaled dependencies block. If the lock isn't refreshed,
// downstream `helm dependency build` errors with "lock file is out of sync with
// the dependencies file" and falls back to `dependency update`, which re-resolves
// version constraints (e.g. `version: "*"`) against the chart repo and silently
// pulls newer dependency versions. The version pins in the lock are still the
// intended truth — only the rewritten file:// repository URL changed. Mirror the
// rewrite into the lock and recompute the digest so `dep build` accepts it.
tempChartLockPath := filepath.Join(tempDir, "Chart.lock")
lockData, lockErr := st.fs.ReadFile(tempChartLockPath)
if lockErr != nil && !os.IsNotExist(lockErr) {
st.logger.Warnf("Failed to read Chart.lock at %s: %v", tempChartLockPath, lockErr)
}
if lockErr == nil {
var lock struct {
Dependencies []*helmchart.Dependency `yaml:"dependencies,omitempty"`
Digest string `yaml:"digest,omitempty"`
Generated string `yaml:"generated,omitempty"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
st.logger.Warnf("Failed to parse Chart.lock at %s: %v", tempChartLockPath, err)
} else {
// Build the request slice (rewritten Chart.yaml dependencies) using helm's
// own chart.Dependency type so the JSON used for hashing matches helm's
// exactly. All supported fields must be mapped, not just name/repository/
// version, because helm's digest algorithm hashes the full Dependency struct.
req := make([]*helmchart.Dependency, 0, len(chartMeta.Dependencies))
for _, d := range chartMeta.Dependencies {
dep := &helmchart.Dependency{
Name: d.Name,
Repository: d.Repository,
}
if v, ok := d.Data["version"].(string); ok {
dep.Version = v
}
if v, ok := d.Data["condition"].(string); ok {
dep.Condition = v
}
if v, ok := d.Data["alias"].(string); ok {
dep.Alias = v
}
if v, ok := d.Data["enabled"].(bool); ok {
dep.Enabled = v
}
if v, ok := d.Data["tags"].([]interface{}); ok {
tags := make([]string, 0, len(v))
for _, t := range v {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
dep.Tags = tags
}
if v, ok := d.Data["import-values"].([]interface{}); ok {
normalized, err := maputil.RecursivelyStringifyMapKey(v)
if err != nil {
st.logger.Warnf("Failed to normalize import-values for dependency %s: %v", d.Name, err)
} else {
dep.ImportValues = normalized.([]interface{})
}
}
req = append(req, dep)
}
// Mirror the rewritten file:// repository URLs onto matching lock entries.
// Without this, `helm dependency build` would resolve the lock's relative
// file:// paths against the (moved) chart directory and fail with
// "directory ... not found". Versions in the lock are left untouched.
// Match on Name + Alias to handle charts with duplicate dependency names
// distinguished by alias.
for _, ld := range lock.Dependencies {
if !strings.HasPrefix(ld.Repository, "file://") {
continue
}
for _, rd := range req {
if rd.Name == ld.Name && rd.Alias == ld.Alias && strings.HasPrefix(rd.Repository, "file://") {
ld.Repository = rd.Repository
break
}
}
}
// Normalize lock.Dependencies ImportValues to avoid json.Marshal failures
// when go-yaml v2 decodes nested maps as map[interface{}]interface{}.
for _, ld := range lock.Dependencies {
if ld.ImportValues != nil {
normalized, err := maputil.RecursivelyStringifyMapKey(ld.ImportValues)
if err != nil {
st.logger.Warnf("Failed to normalize import-values in Chart.lock for dependency %s: %v", ld.Name, err)
} else {
ld.ImportValues = normalized.([]interface{})
}
}
}
// Replicates helm's resolver.HashReq:
// json.Marshal([2][]*chart.Dependency{req, lock}) → sha256 hex.
// resolver.HashReq lives in helm.sh/helm/v3/internal/resolver, so we
// inline the (small, stable) algorithm rather than importing it.
if payload, err := json.Marshal([2][]*helmchart.Dependency{req, lock.Dependencies}); err != nil {
st.logger.Warnf("Failed to marshal deps for Chart.lock digest at %s: %v", tempChartLockPath, err)
} else {
sum := sha256.Sum256(payload)
lock.Digest = "sha256:" + hex.EncodeToString(sum[:])
if updated, err := yaml.Marshal(&lock); err != nil {
st.logger.Warnf("Failed to marshal Chart.lock at %s: %v", tempChartLockPath, err)
} else if err := st.fs.WriteFile(tempChartLockPath, updated, 0644); err != nil {
st.logger.Warnf("Failed to write Chart.lock at %s: %v", tempChartLockPath, err)
} else {
st.logger.Debugf("Refreshed Chart.lock digest at %s after Chart.yaml rewrite", tempChartLockPath)
}
}
}
}
cleanup := func() {
if removeErr := st.fs.RemoveAll(tempDir); removeErr != nil {
st.logger.Warnf("Failed to remove temp chart directory %s: %v", tempDir, removeErr)
@ -4548,7 +4710,7 @@ func (st *HelmState) setFlags(setValues []SetValue) ([]string, error) {
} else if set.File != "" {
flags = append(flags, "--set-file", fmt.Sprintf("%s=%s", escape(set.Name), st.storage().normalizeSetFilePath(set.File, runtime.GOOS)))
} else if len(set.Values) > 0 {
renderedValues, err := renderValsSecrets(st.valsRuntime, set.Values...)
renderedValues, err := renderValsSecretsAny(st.valsRuntime, set.Values)
if err != nil {
return nil, err
}
@ -4576,7 +4738,7 @@ func (st *HelmState) setStringFlags(setValues []SetValue) ([]string, error) {
}
flags = append(flags, "--set-string", fmt.Sprintf("%s=%s", escape(set.Name), escape(renderedValue[0])))
} else if len(set.Values) > 0 {
renderedValues, err := renderValsSecrets(st.valsRuntime, set.Values...)
renderedValues, err := renderValsSecretsAny(st.valsRuntime, set.Values)
if err != nil {
return nil, err
}
@ -4613,6 +4775,43 @@ func renderValsSecrets(e vals.Evaluator, input ...string) ([]string, error) {
return output, nil
}
// renderValsSecretsAny renders 'ref+.*' secrets in a slice of any-typed values.
// Map values are serialized to JSON; string values are rendered via vals.
func renderValsSecretsAny(e vals.Evaluator, input []any) ([]string, error) {
output := make([]string, len(input))
if len(input) == 0 {
return output, nil
}
strInputs := make([]string, 0, len(input))
strIndexMap := make([]int, 0, len(input))
for i, v := range input {
switch tv := v.(type) {
case string:
strInputs = append(strInputs, tv)
strIndexMap = append(strIndexMap, i)
default:
jsonBytes, err := json.Marshal(tv)
if err != nil {
return nil, fmt.Errorf("failed to marshal set value at index %d: %w", i, err)
}
output[i] = string(jsonBytes)
}
}
if len(strInputs) > 0 {
rendered, err := renderValsSecrets(e, strInputs...)
if err != nil {
return nil, err
}
for idx, renderedIdx := range strIndexMap {
output[renderedIdx] = rendered[idx]
}
}
return output, nil
}
func hideChartCredentials(chartCredentials string) (string, error) {
u, err := url.Parse(chartCredentials)
if err != nil {

View File

@ -85,7 +85,10 @@ func (st *HelmState) ExecuteTemplates() (*HelmState, error) {
vals := st.Values()
for i, rt := range st.Releases {
release, err := st.releaseWithInheritedTemplate(&rt, nil)
rtWithDefaults := rt
rtWithDefaults.Inherit = st.applyDefaultInherit(rt.Inherit)
release, err := st.releaseWithInheritedTemplate(&rtWithDefaults, nil)
if err != nil {
var cyclicInheritanceErr CyclicReleaseTemplateInheritanceError
if errors.As(err, &cyclicInheritanceErr) {
@ -224,3 +227,36 @@ func (st *HelmState) releaseWithInheritedTemplate(r *ReleaseSpec, inheritancePat
return &merged, nil
}
// applyDefaultInherit prepends default inherit templates to the release's inherit list.
// Templates that are already explicitly referenced by the release are not duplicated.
func (st *HelmState) applyDefaultInherit(releaseInherit Inherits) Inherits {
if len(st.DefaultInherit) == 0 {
return releaseInherit
}
// Build the deduplication set and filter out blank entries in one pass.
existing := make(map[string]bool, len(releaseInherit))
filtered := make(Inherits, 0, len(releaseInherit))
for _, inh := range releaseInherit {
if name := strings.TrimSpace(inh.Template); name != "" {
existing[name] = true
filtered = append(filtered, inh)
}
}
result := make(Inherits, 0, len(st.DefaultInherit)+len(filtered))
for _, name := range st.DefaultInherit {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !existing[name] {
result = append(result, Inherit{Template: name})
existing[name] = true
}
}
result = append(result, filtered...)
return result
}

View File

@ -7,9 +7,12 @@ import (
"testing"
"github.com/go-test/deep"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/runtime"
"github.com/helmfile/helmfile/pkg/yaml"
)
func boolPtrToString(ptr *bool) string {
@ -93,7 +96,7 @@ func TestHelmState_executeTemplates(t *testing.T) {
SetValuesTemplate: []SetValue{
{Name: "val1", Value: "{{ .Release.Name }}-val1"},
{Name: "val2", File: "{{ .Release.Name }}.yml"},
{Name: "val3", Values: []string{"{{ .Release.Name }}-val2", "{{ .Release.Name }}-val3"}},
{Name: "val3", Values: []any{"{{ .Release.Name }}-val2", "{{ .Release.Name }}-val3"}},
{Name: "val4", Value: "{{ .Release.Chart }}-{{ .Release.ChartVersion}}"},
},
},
@ -105,7 +108,7 @@ func TestHelmState_executeTemplates(t *testing.T) {
SetValues: []SetValue{
{Name: "val1", Value: "test-app-val1"},
{Name: "val2", File: "test-app.yml"},
{Name: "val3", Values: []string{"test-app-val2", "test-app-val3"}},
{Name: "val3", Values: []any{"test-app-val2", "test-app-val3"}},
{Name: "val4", Value: "test-charts/chart-1.5"},
},
},
@ -294,3 +297,210 @@ func TestHelmState_recursiveRefsTemplates(t *testing.T) {
})
}
}
func TestApplyDefaultInherit(t *testing.T) {
tests := []struct {
name string
defaultInherit DefaultInherits
releaseInherit Inherits
want Inherits
}{
{
name: "no default inherit",
defaultInherit: nil,
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "foo"}},
},
{
name: "default inherit prepended",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
{
name: "default inherit already in release inherit is not duplicated",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: "default"}, {Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
{
name: "multiple default inherits",
defaultInherit: DefaultInherits{"a", "b"},
releaseInherit: Inherits{{Template: "c"}},
want: Inherits{{Template: "a"}, {Template: "b"}, {Template: "c"}},
},
{
name: "release inherit empty with defaults",
defaultInherit: DefaultInherits{"default"},
releaseInherit: nil,
want: Inherits{{Template: "default"}},
},
{
name: "default inherit deduplicates and skips empty values",
defaultInherit: DefaultInherits{"default", " ", "default", "ops"},
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "ops"}, {Template: "foo"}},
},
{
// Whitespace-only template names in releaseInherit are used verbatim for dedup
// (trimmed for map lookup), so the user's explicit entry is preserved in the output
// and the default is not prepended again.
name: "release inherit with whitespace template is deduplicated correctly",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: " default "}, {Template: "foo"}},
want: Inherits{{Template: " default "}, {Template: "foo"}},
},
{
name: "release inherit with blank template is skipped",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: ""}, {Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
st := &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
DefaultInherit: tt.defaultInherit,
},
}
got := st.applyDefaultInherit(tt.releaseInherit)
if len(got) != len(tt.want) {
t.Fatalf("expected %d inherits, got %d", len(tt.want), len(got))
}
for j := range got {
if got[j].Template != tt.want[j].Template {
t.Errorf("inherit[%d]: expected template %q, got %q", j, tt.want[j].Template, got[j].Template)
}
if len(got[j].Except) != len(tt.want[j].Except) {
t.Errorf("inherit[%d]: expected %d except, got %d", j, len(tt.want[j].Except), len(got[j].Except))
}
}
})
}
}
func TestHelmState_executeTemplatesWithDefaultTemplates(t *testing.T) {
logger := zap.NewNop().Sugar()
state := &HelmState{
logger: logger,
fs: &filesystem.FileSystem{
Glob: func(s string) ([]string, error) { return nil, nil },
},
basePath: ".",
ReleaseSetSpec: ReleaseSetSpec{
HelmDefaults: HelmSpec{
KubeContext: "test_context",
},
Env: environment.Environment{Name: "test_env"},
Templates: map[string]TemplateSpec{
"default": {
ReleaseSpec: ReleaseSpec{
Namespace: "default-ns",
Labels: map[string]string{"managed": "true"},
},
},
},
DefaultInherit: DefaultInherits{"default"},
Releases: []ReleaseSpec{
{
Name: "app1",
Chart: "test-chart",
},
{
Name: "app2",
Chart: "test-chart-2",
Inherit: Inherits{
{Template: "default", Except: []string{"labels"}},
},
},
},
},
RenderedValues: map[string]any{},
}
r, err := state.ExecuteTemplates()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
app1 := r.Releases[0]
if app1.Namespace != "default-ns" {
t.Errorf("app1: expected namespace %q, got %q", "default-ns", app1.Namespace)
}
if app1.Labels["managed"] != "true" {
t.Errorf("app1: expected label managed=true, got %v", app1.Labels)
}
app2 := r.Releases[1]
if app2.Namespace != "default-ns" {
t.Errorf("app2: expected namespace %q, got %q", "default-ns", app2.Namespace)
}
if _, ok := app2.Labels["managed"]; ok {
t.Errorf("app2: expected labels to be excluded, but got %v", app2.Labels)
}
}
func TestDefaultInherits_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
input string
want DefaultInherits
}{
{
name: "single string",
input: `default`,
want: DefaultInherits{"default"},
},
{
name: "list of strings",
input: `["a", "b"]`,
want: DefaultInherits{"a", "b"},
},
{
name: "null value",
input: `null`,
want: nil,
},
{
name: "empty string value",
input: `""`,
want: nil,
},
{
name: "list trims and drops empty names",
input: `[" a ", "", " ", "b"]`,
want: DefaultInherits{"a", "b"},
},
}
for _, enableGoYamlV3 := range []bool{true, false} {
t.Run(fmt.Sprintf("GoYamlV3=%t", enableGoYamlV3), func(t *testing.T) {
prev := runtime.GoYamlV3
runtime.GoYamlV3 = enableGoYamlV3
defer func() {
runtime.GoYamlV3 = prev
}()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got DefaultInherits
err := yaml.Unmarshal([]byte(tt.input), &got)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("expected %d items, got %d", len(tt.want), len(got))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("item[%d]: expected %q, got %q", i, tt.want[i], got[i])
}
}
})
}
})
}
}

View File

@ -2096,7 +2096,7 @@ func TestHelmState_SyncReleases(t *testing.T) {
SetValues: []SetValue{
{
Name: "foo.bar[0]",
Values: []string{
Values: []any{
"A",
"B",
},
@ -2107,6 +2107,26 @@ func TestHelmState_SyncReleases(t *testing.T) {
helm: &exectest.Helm{},
wantReleases: []exectest.Release{{Name: "releaseName", Flags: []string{"--set", "foo.bar[0]={A,B}", "--reset-values"}}},
},
{
name: "set array of map values",
releases: []ReleaseSpec{
{
Name: "releaseName",
Chart: "foo",
SetValues: []SetValue{
{
Name: "source.helm.parameters",
Values: []any{
map[string]any{"name": "demo"},
map[string]any{"version": "v2"},
},
},
},
},
},
helm: &exectest.Helm{},
wantReleases: []exectest.Release{{Name: "releaseName", Flags: []string{"--set", "source.helm.parameters={\\{\"name\":\"demo\"\\},\\{\"version\":\"v2\"\\}}", "--reset-values"}}},
},
{
name: "post renderer helm 3",
releases: []ReleaseSpec{
@ -2709,7 +2729,7 @@ func TestHelmState_DiffReleases(t *testing.T) {
SetValues: []SetValue{
{
Name: "foo.bar[0]",
Values: []string{
Values: []any{
"A",
"B",
},
@ -5698,7 +5718,7 @@ func TestHelmState_setStringFlags(t *testing.T) {
setStringValues: []SetValue{
{
Name: "key",
Values: []string{"value1", "value2"},
Values: []any{"value1", "value2"},
},
},
want: []string{"--set-string", "key={value1,value2}"},

View File

@ -27,7 +27,7 @@ export HELM_DATA_HOME="${helm_dir}/data"
export HELM_HOME="${HELM_DATA_HOME}"
export HELM_PLUGINS="${HELM_DATA_HOME}/plugins"
export HELM_CONFIG_HOME="${helm_dir}/config"
HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.3}"
HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.7}"
HELM_GIT_VERSION="${HELM_GIT_VERSION:-1.4.1}"
HELM_SECRETS_VERSION="${HELM_SECRETS_VERSION:-4.7.4}"
export GNUPGHOME="${PWD}/${dir}/.gnupg"
@ -143,6 +143,8 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes
. ${dir}/test-cases/issue-2424-sequential-values-paths.sh
. ${dir}/test-cases/issue-2431.sh
. ${dir}/test-cases/issue-2544.sh
. ${dir}/test-cases/issue-2596-local-deps-multiple-files.sh
. ${dir}/test-cases/issue-2599-default-inherit.sh
. ${dir}/test-cases/kubedog-tracking.sh
# ALL DONE -----------------------------------------------------------------------------------------------------------

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Test for issue #2271: lookup function should work with strategicMergePatches
# Test for issue #2271: lookup function should work with strategicMergePatches and jsonPatches
# Without this fix, helm template runs client-side and lookup() returns empty values
issue_2271_input_dir="${cases_dir}/issue-2271/input"
@ -8,7 +8,7 @@ issue_2271_tmp_dir=$(mktemp -d)
cd "${issue_2271_input_dir}"
test_start "issue-2271: lookup function with strategicMergePatches"
test_start "issue-2271: lookup function with strategicMergePatches and jsonPatches"
# Test 1: Install chart without kustomize patches
info "Installing chart without kustomize patches"
@ -36,26 +36,44 @@ fi
info "ConfigMap value updated to: $current_value"
assert_lookup_preserved() {
local label="$1"
local helmfile_path="$2"
local output_path="$3"
local code
info "Testing diff with ${label} - lookup should preserve value"
${helmfile} -f "${helmfile_path}" diff > "${output_path}" 2>&1
code=$?
if [ $code -ne 0 ] && [ $code -ne 2 ]; then
cat "${output_path}"
rm -rf "${issue_2271_tmp_dir}"
fail "Unexpected error during diff with ${label}"
fi
# Check if the diff contains the preserved value (not "initial-value")
if grep -q "preserved-value.*test-preserved-value" "${output_path}"; then
info "SUCCESS: lookup function preserved the value with ${label}"
elif grep -q "preserved-value.*initial-value" "${output_path}"; then
cat "${output_path}"
rm -rf "${issue_2271_tmp_dir}"
fail "Issue #2271 regression: lookup function returned empty value with ${label}"
else
# No diff for ConfigMap means value is perfectly preserved
info "SUCCESS: No ConfigMap changes detected for ${label} (value perfectly preserved)"
fi
}
# Test 3: Diff with strategicMergePatches should preserve the lookup value
info "Testing diff with strategicMergePatches - lookup should preserve value"
assert_lookup_preserved "strategicMergePatches" "helmfile.yaml" "${issue_2271_tmp_dir}/test-2271-strategic-diff.txt"
${helmfile} -f helmfile.yaml diff > "${issue_2271_tmp_dir}/test-2271-diff.txt" 2>&1
code=$?
# Check if the diff contains the preserved value (not "initial-value")
if grep -q "preserved-value.*test-preserved-value" "${issue_2271_tmp_dir}/test-2271-diff.txt"; then
info "SUCCESS: lookup function preserved the value with kustomize patches"
elif grep -q "preserved-value.*initial-value" "${issue_2271_tmp_dir}/test-2271-diff.txt"; then
cat "${issue_2271_tmp_dir}/test-2271-diff.txt"
rm -rf "${issue_2271_tmp_dir}"
fail "Issue #2271 regression: lookup function returned empty value with kustomize"
else
# No diff for ConfigMap means value is perfectly preserved
info "SUCCESS: No ConfigMap changes detected (value perfectly preserved)"
fi
# Test 4: Diff with jsonPatches should preserve the lookup value
assert_lookup_preserved "jsonPatches" "helmfile-jsonpatch.yaml" "${issue_2271_tmp_dir}/test-2271-json-diff.txt"
# Cleanup
${helm} uninstall test-release-2271 --namespace default 2>/dev/null || true
rm -rf "${issue_2271_tmp_dir}"
test_pass "issue-2271: lookup function with strategicMergePatches"
test_pass "issue-2271: lookup function with strategicMergePatches and jsonPatches"

View File

@ -0,0 +1,16 @@
releases:
- name: test-release-2271
namespace: default
chart: ./test-chart
installed: true
jsonPatches:
- target:
group: apps
version: v1
kind: Deployment
name: test-release-2271-app
namespace: default
patch:
- op: add
path: /spec/template/metadata/labels/hello
value: world

View File

@ -0,0 +1,49 @@
# Integration test for issue #2596: Local dependencies with multiple release files
# https://github.com/helmfile/helmfile/issues/2596
# Reproduction: https://github.com/vgivanov/helmfile-deps-local-chart
#
# This test uses the exact same structure as the reproduction repo:
# chart/Chart.yaml
# helmfile.d/release1.yaml (chart: ../chart with dependencies)
# helmfile.d/release2.yaml (chart: ../chart without dependencies)
#
# Before the fix, running `helmfile template` from this directory would fail with:
# "failed reading adhoc dependencies: no helm list entry found for repository"
# because the relative dependency chart path "../chart" was not normalized against
# basePath before calling DirectoryExistsAt.
issue_2596_input_dir="${cases_dir}/issue-2596-local-deps-multiple-files/input"
issue_2596_tmp=""
cleanup_issue_2596() {
if [ -n "${issue_2596_tmp}" ] && [ -d "${issue_2596_tmp}" ]; then
rm -rf "${issue_2596_tmp}"
fi
}
trap cleanup_issue_2596 EXIT
issue_2596_tmp=$(mktemp -d)
helmfile_real="$(pwd)/${helmfile}"
test_start "issue #2596: local deps with multiple release files"
info "Testing helmfile template with local chart dependencies across multiple release files"
cd "${issue_2596_input_dir}"
${helmfile_real} template > "${issue_2596_tmp}/output.yaml" 2>&1
result=$?
cd - > /dev/null
if [ $result -ne 0 ]; then
cat "${issue_2596_tmp}/output.yaml"
fail "helmfile template with local chart dependencies should not fail"
fi
info "Local chart dependencies with multiple release files works correctly"
cleanup_issue_2596
trap - EXIT
test_pass "issue #2596: local deps with multiple release files"

View File

@ -0,0 +1,24 @@
apiVersion: v2
name: chart
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View File

@ -0,0 +1,8 @@
---
releases:
- name: release1
chart: ../chart
version: "*"
dependencies:
- chart: ../chart
version: "*"

View File

@ -0,0 +1,5 @@
---
releases:
- name: release2
chart: ../chart
version: "*"

View File

@ -0,0 +1,75 @@
# Issue #2599: Test that defaultInherit applies template inheritance to all releases
# https://github.com/helmfile/helmfile/issues/2599
#
# This test verifies that:
# - defaultInherit as a single string applies the template to all releases
# - Releases without explicit inherit still get the template
# - Releases with explicit inherit + except are not duplicated
# - Non-existent template in defaultInherit produces a clear error
issue_2599_input_dir="${cases_dir}/issue-2599-default-inherit/input"
issue_2599_tmp=""
cleanup_issue_2599() {
if [ -n "${issue_2599_tmp}" ] && [ -d "${issue_2599_tmp}" ]; then
rm -rf "${issue_2599_tmp}"
fi
}
trap cleanup_issue_2599 EXIT
issue_2599_tmp=$(mktemp -d)
test_start "issue 2599 default inherit"
# Test 1: defaultInherit applies template to all releases
info "Running helmfile build with defaultInherit"
${helmfile} -f "${issue_2599_input_dir}/helmfile.yaml" build \
> "${issue_2599_tmp}/output.log" 2>&1 \
|| { cat "${issue_2599_tmp}/output.log"; fail "helmfile build with defaultInherit shouldn't fail"; }
# Verify namespace from template is applied to both releases
grep -q "namespace: default-ns" "${issue_2599_tmp}/output.log" \
|| fail "namespace from default template should be applied"
# Verify both releases are processed
grep -q "app1" "${issue_2599_tmp}/output.log" \
|| fail "release app1 should be in output"
grep -q "app2" "${issue_2599_tmp}/output.log" \
|| fail "release app2 should be in output"
grep -q "^templates:" "${issue_2599_tmp}/output.log" \
|| fail "templates section should be in build output"
# Verify inherited values and labels per release
sed -n '/name: app1/,/name: app2/p' "${issue_2599_tmp}/output.log" > "${issue_2599_tmp}/app1.log"
sed -n '/name: app2/,/^templates:/{/^templates:/!p}' "${issue_2599_tmp}/output.log" > "${issue_2599_tmp}/app2.log"
[ -s "${issue_2599_tmp}/app1.log" ] || fail "failed to extract release app1 section from build output"
[ -s "${issue_2599_tmp}/app2.log" ] || fail "failed to extract release app2 section from build output"
grep -Eq 'managed:[[:space:]]*"?true"?([[:space:]]|$)' "${issue_2599_tmp}/app1.log" \
|| fail "release app1 should inherit managed label from default template"
grep -q "common.yaml" "${issue_2599_tmp}/app1.log" \
|| fail "release app1 should inherit values from common.yaml"
grep -q "common.yaml" "${issue_2599_tmp}/app2.log" \
|| fail "release app2 should inherit values from common.yaml"
if grep -Eq 'managed:[[:space:]]*"?true"?([[:space:]]|$)' "${issue_2599_tmp}/app2.log"; then
fail "release app2 should not inherit managed label due to except"
fi
# Test 2: non-existent template in defaultInherit should fail
info "Running helmfile build with non-existent defaultInherit template"
cat > "${issue_2599_tmp}/bad-helmfile.yaml" <<EOF
defaultInherit: nonexistent
releases:
- name: app1
chart: ${dir}/charts/raw
EOF
${helmfile} -f "${issue_2599_tmp}/bad-helmfile.yaml" build \
> "${issue_2599_tmp}/error.log" 2>&1 \
&& fail "helmfile build with non-existent defaultInherit template should fail"
grep -q "inexistent release template" "${issue_2599_tmp}/error.log" \
|| fail "error message should mention inexistent release template"
test_pass "issue 2599 default inherit"

View File

@ -0,0 +1 @@
testKey: testValue

View File

@ -0,0 +1,19 @@
templates:
default:
namespace: default-ns
labels:
managed: "true"
values:
- common.yaml
defaultInherit: default
releases:
- name: app1
chart: ../../../charts/raw
- name: app2
chart: ../../../charts/raw
inherit:
- template: default
except:
- labels