diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b7d4fcbb..23668f5a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 40876dbc..6089aa8a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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. diff --git a/cmd/sync.go b/cmd/sync.go index 4b8985ca..cd735e3f 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -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 } diff --git a/docs/cli.md b/docs/cli.md index cc264720..0636d4fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 7fd94b0a..0ffc5bf9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/templating.md b/docs/templating.md index d994c8f1..5c1d1b96 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -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. diff --git a/docs/writing-helmfile.md b/docs/writing-helmfile.md index e0bc2f8c..a9e0d9ce 100644 --- a/docs/writing-helmfile.md +++ b/docs/writing-helmfile.md @@ -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`. diff --git a/go.mod b/go.mod index 366266b6..0f540211 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 913fc734..bc0c7dd7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/app/app.go b/pkg/app/app.go index 64f12b68..f26f8941 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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) { diff --git a/pkg/app/app_sync_test.go b/pkg/app/app_sync_test.go index ce38e77e..7f10f30e 100644 --- a/pkg/app/app_sync_test.go +++ b/pkg/app/app_sync_test.go @@ -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 `, }, }) diff --git a/pkg/app/app_template_test.go b/pkg/app/app_template_test.go index f2e5f50c..c468c22d 100644 --- a/pkg/app/app_template_test.go +++ b/pkg/app/app_template_test.go @@ -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"`, + }) + }) +} diff --git a/pkg/config/global.go b/pkg/config/global.go index f17ea2d9..b7378f10 100644 --- a/pkg/config/global.go +++ b/pkg/config/global.go @@ -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") diff --git a/pkg/config/global_test.go b/pkg/config/global_test.go index d59e6ca8..15547309 100644 --- a/pkg/config/global_test.go +++ b/pkg/config/global_test.go @@ -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") + }) + } +} diff --git a/pkg/config/sync.go b/pkg/config/sync.go index a838cc1f..4b9b58af 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -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) { diff --git a/pkg/envvar/const.go b/pkg/envvar/const.go index cd4d52bc..7a2e4542 100644 --- a/pkg/envvar/const.go +++ b/pkg/envvar/const.go @@ -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" diff --git a/pkg/kubedog/display.go b/pkg/kubedog/display.go new file mode 100644 index 00000000..79a08436 --- /dev/null +++ b/pkg/kubedog/display.go @@ -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 +} diff --git a/pkg/kubedog/display_test.go b/pkg/kubedog/display_test.go new file mode 100644 index 00000000..254f3522 --- /dev/null +++ b/pkg/kubedog/display_test.go @@ -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()) +} diff --git a/pkg/kubedog/tracker.go b/pkg/kubedog/tracker.go index 867116f8..cc4e11e5 100644 --- a/pkg/kubedog/tracker.go +++ b/pkg/kubedog/tracker.go @@ -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 { diff --git a/pkg/state/chart_dependencies_rewrite_test.go b/pkg/state/chart_dependencies_rewrite_test.go index d185b650..be1c51dc 100644 --- a/pkg/state/chart_dependencies_rewrite_test.go +++ b/pkg/state/chart_dependencies_rewrite_test.go @@ -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) + } +} diff --git a/pkg/state/helmx.go b/pkg/state/helmx.go index e3a40a34..1869c459 100644 --- a/pkg/state/helmx.go +++ b/pkg/state/helmx.go @@ -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: diff --git a/pkg/state/issue_2596_test.go b/pkg/state/issue_2596_test.go new file mode 100644 index 00000000..f8685bce --- /dev/null +++ b/pkg/state/issue_2596_test.go @@ -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") + } + }) + } +} diff --git a/pkg/state/release.go b/pkg/state/release.go index c1b5c437..8f395215 100644 --- a/pkg/state/release.go +++ b/pkg/state/release.go @@ -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() } } diff --git a/pkg/state/state.go b/pkg/state/state.go index d1273b60..c06ed7f3 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -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 { diff --git a/pkg/state/state_exec_tmpl.go b/pkg/state/state_exec_tmpl.go index 2a8e3877..db32af84 100644 --- a/pkg/state/state_exec_tmpl.go +++ b/pkg/state/state_exec_tmpl.go @@ -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 +} diff --git a/pkg/state/state_exec_tmpl_test.go b/pkg/state/state_exec_tmpl_test.go index 33a0a47c..01956a0f 100644 --- a/pkg/state/state_exec_tmpl_test.go +++ b/pkg/state/state_exec_tmpl_test.go @@ -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]) + } + } + }) + } + }) + } +} diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index d188cd57..be883d32 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -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}"}, diff --git a/test/integration/run.sh b/test/integration/run.sh index 2637ae0c..bf9bcf25 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -27,7 +27,7 @@ export HELM_DATA_HOME="${helm_dir}/data" export HELM_HOME="${HELM_DATA_HOME}" export HELM_PLUGINS="${HELM_DATA_HOME}/plugins" export HELM_CONFIG_HOME="${helm_dir}/config" -HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.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 ----------------------------------------------------------------------------------------------------------- diff --git a/test/integration/test-cases/issue-2271.sh b/test/integration/test-cases/issue-2271.sh index 660af0c1..0aa58d1f 100755 --- a/test/integration/test-cases/issue-2271.sh +++ b/test/integration/test-cases/issue-2271.sh @@ -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" diff --git a/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml b/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml new file mode 100644 index 00000000..5a6e62fb --- /dev/null +++ b/test/integration/test-cases/issue-2271/input/helmfile-jsonpatch.yaml @@ -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 diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh b/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh new file mode 100644 index 00000000..84a06443 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files.sh @@ -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" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml new file mode 100644 index 00000000..df2d97f9 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/chart/Chart.yaml @@ -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" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml new file mode 100644 index 00000000..f2fbfe41 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release1.yaml @@ -0,0 +1,8 @@ +--- +releases: + - name: release1 + chart: ../chart + version: "*" + dependencies: + - chart: ../chart + version: "*" diff --git a/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml new file mode 100644 index 00000000..4182b9c6 --- /dev/null +++ b/test/integration/test-cases/issue-2596-local-deps-multiple-files/input/helmfile.d/release2.yaml @@ -0,0 +1,5 @@ +--- +releases: + - name: release2 + chart: ../chart + version: "*" diff --git a/test/integration/test-cases/issue-2599-default-inherit.sh b/test/integration/test-cases/issue-2599-default-inherit.sh new file mode 100644 index 00000000..c579668c --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit.sh @@ -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" < "${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" diff --git a/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml b/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml new file mode 100644 index 00000000..80b68919 --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit/input/common.yaml @@ -0,0 +1 @@ +testKey: testValue diff --git a/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml b/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml new file mode 100644 index 00000000..65d475bd --- /dev/null +++ b/test/integration/test-cases/issue-2599-default-inherit/input/helmfile.yaml @@ -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