mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-12 04:15:40 +02:00
* feat: add dir= selector for path-based release filtering and traversal skip Introduce a new selector key `dir=<path>` to the -l/--selector flag with two effects: it filters releases by the directory of their defining helmfile relative to the root helmfile (using directory-prefix matching, so dir=apps/foo matches both apps/foo and apps/foo/sub), and it short-circuits sub-helmfile traversal when paired with positive dir= constraints, so non-matching branches in the helmfiles: tree are not parsed, templated, or fetched. The motivation is consuming aggregator-style upstreams that the operator does not control (opendesk being the immediate example), where the user cannot restructure the helmfile layout but still wants to act on a subset. The dir label is auto-populated at filter time only; user-facing label output is unchanged. The label key "dir" is reserved at state load. Selectors that escape the root via .. or absolute paths, and the bare ".", are rejected at parse time. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * refactor: use sync.Once for root dir resolution, extract skipForDirFilter Add a unit test covering the per-entry skip decision: match descends, sibling skipped, remote never short-circuited. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * cmd: shorten dir selector help text, link to docs Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * docs: highlight selectors inheritance caveat, add bases example for dir label Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * feat: warn instead of fail on user-defined dir label Hard-failing on an existing dir label is a breaking change. Log a deprecation warning for now and turn it into an error in a later release. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * refactor: apply go fix modernizers to dir selector code Upstream CI now fails when go fix would rewrite code. Use maps.Copy in injectLabel and drop the explicit embedded ReleaseSetSpec in test literals. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * fix: tighten dir selector edge cases found in review - Always shadow a user-defined dir label at match time, also when the release has no auto-populated value (remote or outside-root helmfiles). Previously the user value was matched there despite the warning. - Skip sibling helmfiles entries that are directories, not only files. - Warn about a dir label declared in a release template, and warn only once when it comes from a bases file. - Share the root-relative path and at-or-below checks between the release filter and the traversal skip; a directory whose name merely starts with two dots is no longer treated as escaping the root. - Log when the root directory cannot be resolved. - Tests: require for setup steps, t.Chdir, shared app fixture. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * docs: sync dir selector CLI help, document anchor and traversal assumption docs/cli.md still carried the long help text. Also spell out what the dir value is relative to, that the traversal skip assumes nested helmfiles stay below their parent's directory, and that user-defined dir labels are ignored for releases without an auto-populated value as well. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * fix: complete dir selector values relative to the root helmfile Completion listed directories relative to the working directory, while dir values are relative to the root helmfile directory. Resolve the root from -f or HELMFILE_FILE_PATH with the same logic the app uses, and offer nothing for values the selector would reject (absolute paths, paths that escape the root) or for a remote root helmfile. Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> * refactor(state): flatten nested conditionals in label filter parsing and matching - LabelFilter.Match: extract labelMatches helper; positive/negative loops become single-level guards. Also removes the pre-existing empty then-branch (if !ok {} else if) in the negative-label loop and the redundant len()>0 guards around the loops - positiveLabelsCompatibleWith: extract positiveLabelPairCompatible so the double loop holds one guard instead of a nested if chain - ParseLabels: extract parseOneLabel (separator detection, dir= value validation, split) so the three-level regex/dir/error nesting becomes flat; drop the vestigial trailing 'var err error' return No behavior change; state/app/cmd tests incl. -race and golangci-lint pass. Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de> Signed-off-by: yxxhero <aiopsclub@163.com> Co-authored-by: yxxhero <aiopsclub@163.com>
261 lines
14 KiB
Go
261 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
stderrors "errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.szostok.io/version/extension"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/helmfile/helmfile/pkg/app"
|
|
"github.com/helmfile/helmfile/pkg/app/version"
|
|
"github.com/helmfile/helmfile/pkg/config"
|
|
"github.com/helmfile/helmfile/pkg/envvar"
|
|
"github.com/helmfile/helmfile/pkg/errors"
|
|
"github.com/helmfile/helmfile/pkg/filesystem"
|
|
"github.com/helmfile/helmfile/pkg/helmexec"
|
|
"github.com/helmfile/helmfile/pkg/runtime"
|
|
"github.com/helmfile/helmfile/pkg/state"
|
|
"github.com/helmfile/helmfile/pkg/telemetry"
|
|
)
|
|
|
|
var logger *zap.SugaredLogger
|
|
var globalUsage = "Declaratively deploy your Kubernetes manifests, Kustomize configs, and Charts as Helm releases in one shot\n" + runtime.Info()
|
|
|
|
func toCLIError(g *config.GlobalImpl, err error) error {
|
|
if err != nil {
|
|
if exitErr, ok := stderrors.AsType[helmexec.ExitError](err); ok {
|
|
return errors.NewExitError(exitErr.Error(), exitErr.ExitStatus())
|
|
}
|
|
switch e := err.(type) {
|
|
case *app.NoMatchingHelmfileError:
|
|
noMatchingExitCode := 3
|
|
if g.AllowNoMatchingRelease {
|
|
noMatchingExitCode = 0
|
|
}
|
|
return errors.NewExitError(e.Error(), noMatchingExitCode)
|
|
case *app.MultiError:
|
|
return errors.NewExitError(e.Error(), 1)
|
|
case *app.Error:
|
|
return errors.NewExitError(e.Error(), e.Code())
|
|
default:
|
|
return errors.NewExitError(fmt.Sprintf("unexpected error: %T: %v", e, e), 1)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// 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,
|
|
Long: globalUsage,
|
|
Version: version.Version(),
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
PersistentPreRunE: func(c *cobra.Command, args []string) error {
|
|
// Valid levels:
|
|
// https://github.com/uber-go/zap/blob/7e7e266a8dbce911a49554b945538c5b950196b8/zapcore/level.go#L126
|
|
logLevel := globalImpl.LogLevel()
|
|
switch {
|
|
case globalImpl.Debug():
|
|
logLevel = "debug"
|
|
case globalImpl.Quiet():
|
|
logLevel = "warn"
|
|
}
|
|
|
|
// If the log output is not set, default to stderr.
|
|
logOut := globalConfig.LogOutput
|
|
if logOut == nil {
|
|
logOut = os.Stderr
|
|
}
|
|
logger = helmexec.NewLogger(logOut, logLevel)
|
|
globalConfig.SetLogger(logger)
|
|
|
|
// OpenTelemetry tracing (experimental). Setup and StartCommandSpan
|
|
// are no-ops when tracing is disabled, so the default path is
|
|
// unchanged. Configuration beyond the on/off switch comes from the
|
|
// standard OTEL_* environment variables.
|
|
cmdName := "helmfile " + c.Name()
|
|
telemetry.Setup(c.Context(), telemetry.Options{
|
|
Enabled: globalImpl.OtelTracing(),
|
|
Version: version.Version(),
|
|
Logger: logger,
|
|
})
|
|
telemetry.StartCommandSpan(cmdName, commandSpanAttributes(cmdName, globalImpl)...)
|
|
|
|
return nil
|
|
},
|
|
}
|
|
flags := cmd.PersistentFlags()
|
|
|
|
// Set the global options for the root command.
|
|
setGlobalOptionsForRootCmd(flags, globalConfig)
|
|
|
|
if err := cmd.RegisterFlagCompletionFunc("selector", selectorFlagCompletion(globalConfig)); err != nil {
|
|
return nil, fmt.Errorf("registering selector flag completion: %w", err)
|
|
}
|
|
|
|
flags.ParseErrorsAllowlist.UnknownFlags = true
|
|
|
|
// when set environment HELMFILE_UPGRADE_NOTICE_DISABLED any value, skip upgrade notice.
|
|
var versionOpts []extension.CobraOption
|
|
if os.Getenv(envvar.UpgradeNoticeDisabled) == "" {
|
|
versionOpts = append(versionOpts, extension.WithUpgradeNotice("helmfile", "helmfile"))
|
|
}
|
|
|
|
cmd.AddCommand(
|
|
NewCreateCmd(globalImpl),
|
|
NewInitCmd(globalImpl),
|
|
NewApplyCmd(globalImpl),
|
|
NewBuildCmd(globalImpl),
|
|
NewCacheCmd(globalImpl),
|
|
NewDepsCmd(globalImpl),
|
|
NewDestroyCmd(globalImpl),
|
|
NewDiffCmd(globalImpl),
|
|
NewDoctorCmd(globalImpl),
|
|
NewFetchCmd(globalImpl),
|
|
NewListCmd(globalImpl),
|
|
NewReposCmd(globalImpl),
|
|
NewLintCmd(globalImpl),
|
|
NewWriteValuesCmd(globalImpl),
|
|
NewTestCmd(globalImpl),
|
|
NewUnittestCmd(globalImpl),
|
|
NewTemplateCmd(globalImpl),
|
|
NewSyncCmd(globalImpl),
|
|
NewStatusCmd(globalImpl),
|
|
NewShowDAGCmd(globalImpl),
|
|
NewPrintEnvCmd(globalImpl),
|
|
extension.NewVersionCobraCmd(
|
|
versionOpts...,
|
|
),
|
|
)
|
|
|
|
return cmd, nil
|
|
}
|
|
|
|
// commandSpanAttributes builds the root-span attributes from the resolved
|
|
// global options. Values that can be overridden per release appear again on
|
|
// child spans; the service identity (service.name/service.version) lives on
|
|
// the OTel resource, not on spans.
|
|
func commandSpanAttributes(cmdName string, g *config.GlobalImpl) []attribute.KeyValue {
|
|
return []attribute.KeyValue{
|
|
attribute.String("helmfile.command", cmdName),
|
|
// FileOrDir may be a remote go-getter reference with credentials.
|
|
attribute.String("helmfile.file", helmexec.RedactedRef(g.FileOrDir())),
|
|
attribute.String("helmfile.environment", g.Env()),
|
|
attribute.StringSlice("helmfile.selectors", g.Selectors()),
|
|
}
|
|
}
|
|
|
|
// selectorFlagCompletion provides shell completion for `-l/--selector`.
|
|
// When the user types a value whose last comma-separated condition starts with
|
|
// `dir=`, this enumerates directories matching the partial path after `=`,
|
|
// relative to the root helmfile directory like the selector itself.
|
|
// Other selector keys/values are too open-ended to enumerate, so the function
|
|
// suppresses default file completion in that case.
|
|
func selectorFlagCompletion(globalOptions *config.GlobalOptions) cobra.CompletionFunc {
|
|
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
const dirPrefix = state.DirLabel + "="
|
|
|
|
groups := strings.Split(toComplete, ",")
|
|
last := groups[len(groups)-1]
|
|
if !strings.HasPrefix(last, dirPrefix) {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
leading := strings.Join(groups[:len(groups)-1], ",")
|
|
if leading != "" {
|
|
leading += ","
|
|
}
|
|
|
|
partial := filepath.FromSlash(strings.TrimPrefix(last, dirPrefix))
|
|
if partial != "" && !filepath.IsLocal(partial) {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
fileOrDir := config.NewGlobalImpl(globalOptions).FileOrDir()
|
|
rootDir, err := app.DirSelectorRoot(filesystem.DefaultFileSystem(), fileOrDir)
|
|
if err != nil || rootDir == "" {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
scanDir, matchPrefix := filepath.Split(partial)
|
|
|
|
entries, err := os.ReadDir(filepath.Join(rootDir, scanDir))
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
|
|
suggestions := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), matchPrefix) {
|
|
continue
|
|
}
|
|
suggestions = append(suggestions, leading+dirPrefix+filepath.ToSlash(filepath.Join(scanDir, entry.Name())))
|
|
}
|
|
|
|
return suggestions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
}
|
|
|
|
func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalOptions) {
|
|
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; escape a comma in a value with \\,). Used to override .Values within the helmfile template (not values template).")
|
|
fs.StringArrayVar(&globalOptions.StateValuesSetString, "state-values-set-string", nil, "set state STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2; escape a comma in a value with \\,). Used to override .Values within the helmfile template (not values template).")
|
|
fs.StringArrayVar(&globalOptions.StateValuesFile, "state-values-file", nil, "specify state values in a YAML file. Used to override .Values within the helmfile template (not values template).")
|
|
fs.BoolVar(&globalOptions.SkipDeps, "skip-deps", false, `skip running "helm repo update" and "helm dependency build"`)
|
|
fs.BoolVar(&globalOptions.SkipRefresh, "skip-refresh", false, `skip running "helm repo update"`)
|
|
fs.BoolVar(&globalOptions.AllowFailedReleases, "allow-failed-releases", false, `continue preparing charts for other releases when chart preparation fails for a release; failed releases are skipped and all failures are reported at the end`)
|
|
fs.BoolVar(&globalOptions.StripArgsValuesOnExitError, "strip-args-values-on-exit-error", true, `Strip the potential secret values of the helm command args contained in a helmfile error message`)
|
|
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.IntVar(&globalOptions.RepoRetry, "repo-retries", -1, `Number of times to retry "helm repo add/update" and "helm registry login" on failure, with exponential backoff (1s, 2s, 4s, ..., capped at 30s). Set to 0 to disable retries. Overrides "HELMFILE_REPO_RETRIES" OS environment variable when specified`)
|
|
// The actual default is -1 (a sentinel meaning "flag not set, fall back to
|
|
// the env var"); display "0" in --help to match the documented default and
|
|
// avoid confusing users with a negative value.
|
|
if f := fs.Lookup("repo-retries"); f != nil {
|
|
f.DefValue = "0"
|
|
}
|
|
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. 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. 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.
|
|
"--selector tier=frontend,tier!=proxy --selector tier=backend" will match all frontend, non-proxy releases AND all backend releases.
|
|
The name of a release can be used as a label: "--selector name=myrelease".
|
|
The "dir" key matches by directory prefix of the defining helmfile and skips non-matching sub-helmfiles; see https://helmfile.readthedocs.io/en/latest/#the-dir-selector`)
|
|
fs.BoolVar(&globalOptions.AllowNoMatchingRelease, "allow-no-matching-release", false, `Do not exit with an error code if the provided selector has no matching releases.`)
|
|
fs.BoolVar(&globalOptions.EnableLiveOutput, "enable-live-output", globalOptions.EnableLiveOutput, `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.`)
|
|
fs.BoolVarP(&globalOptions.Interactive, "interactive", "i", false, "Request confirmation before attempting to modify clusters")
|
|
fs.BoolVar(&globalOptions.SequentialHelmfiles, "sequential-helmfiles", false, `Process helmfile.d files sequentially in alphabetical order instead of in parallel.
|
|
Useful when file order matters for dependencies (e.g., databases before applications).
|
|
When processing multiple files, paths are resolved without changing the process working directory,
|
|
so relative environment variables like KUBECONFIG work correctly.`)
|
|
fs.BoolVar(&globalOptions.OtelTracing, "otel-tracing", globalOptions.OtelTracing, `Enable OpenTelemetry tracing and metrics (experimental).
|
|
Configure exporters with standard OTEL_* environment variables (e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER).
|
|
Overrides "HELMFILE_OTEL_TRACING" OS environment variable when specified. See docs/otel.md`)
|
|
// avoid 'pflag: help requested' error (#251)
|
|
fs.BoolP("help", "h", false, "help for helmfile")
|
|
}
|