mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-14 23:35:43 +02:00
feat: dir= selector for path-based release filtering and traversal skip (#2609)
* 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>
This commit is contained in:
co-authored by
yxxhero
parent
c25d16a438
commit
443cef0dc1
@@ -5,6 +5,11 @@
|
||||
- Add opt-in OpenTelemetry tracing and metrics (`--otel-tracing` / `HELMFILE_OTEL_TRACING`, experimental): command/state-load/release/hook spans with one span per helm subprocess, plus `helmfile.helm.exec.duration`, `helmfile.release.duration`, and `helmfile.release.count` metrics (per-release dimensions opt-in via `HELMFILE_OTEL_METRICS_PER_RELEASE`), exported via standard `OTEL_*` environment variables. See [docs/otel.md](docs/otel.md) and the [design proposal](docs/proposals/otel-tracing.md) (#2767)
|
||||
- Add support for `conditionTemplate` and allow `condition` to be set directly to `true` or `false`.
|
||||
- Add `--allow-failed-releases` global flag to continue preparing charts for the remaining releases when chart preparation fails for a release; failed releases are skipped and all failures are reported at the end (#2616)
|
||||
- Add the `dir=` selector for path-based release filtering and sub-helmfile traversal skip (#2609)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The label key `dir` is now reserved for the `dir=` selector. User-defined `dir` labels in `commonLabels` or release `labels` log a warning and are ignored for selector matching; they will be rejected with an error in a future release (#2609)
|
||||
|
||||
## [1.4.1] - 2026-03-03
|
||||
|
||||
|
||||
+61
-1
@@ -4,6 +4,8 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -16,8 +18,10 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -97,6 +101,10 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
|
||||
// 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.
|
||||
@@ -149,6 +157,57 @@ func commandSpanAttributes(cmdName string, g *config.GlobalImpl) []attribute.Key
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -183,7 +242,8 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO
|
||||
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 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.`)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -101,3 +105,119 @@ func TestTrackLogsUntilReadyFlag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_NonDirKey(t *testing.T) {
|
||||
// For any selector value that is not a dir= prefix, completion should
|
||||
// suppress file completion and return no suggestions.
|
||||
cases := []string{"", "name=", "name=foo", "tier=backend,name", "dir"}
|
||||
for _, in := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
suggestions, dir := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, in)
|
||||
assert.Nil(t, suggestions)
|
||||
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_DirEnumeratesDirectories(t *testing.T) {
|
||||
// Set up a temp tree with two subdirs and one file. Completion against
|
||||
// `dir=` should suggest only the subdirs, prefixed with `dir=`.
|
||||
root := t.TempDir()
|
||||
for _, sub := range []string{"apps", "infra"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "helmfile.yaml"), []byte("releases: []"), 0o644))
|
||||
|
||||
t.Chdir(root)
|
||||
|
||||
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "dir=")
|
||||
assert.ElementsMatch(t, []string{"dir=apps", "dir=infra"}, suggestions)
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_DirPartialPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, sub := range []string{"apps/opencloud", "apps/openproject", "apps/xwiki"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
|
||||
}
|
||||
t.Chdir(root)
|
||||
|
||||
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "dir=apps/op")
|
||||
assert.ElementsMatch(t, []string{"dir=apps/opencloud", "dir=apps/openproject"}, suggestions)
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_DirCarriesOverPriorGroups(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "apps"), 0o755))
|
||||
t.Chdir(root)
|
||||
|
||||
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "name=foo,dir=")
|
||||
assert.ElementsMatch(t, []string{"name=foo,dir=apps"}, suggestions)
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_UnmatchableValuesReturnNoSuggestions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "apps"), 0o755))
|
||||
t.Chdir(filepath.Join(root, "apps"))
|
||||
|
||||
for _, in := range []string{"dir=does/not/exist/", "dir=" + filepath.ToSlash(root) + "/", "dir=../", "dir=../ap"} {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
suggestions, dir := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, in)
|
||||
assert.Nil(t, suggestions)
|
||||
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_DirIsRelativeToRootHelmfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, sub := range []string{"deploy/apps/opencloud", "deploy/infra", "unrelated"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "deploy", "helmfile.yaml"), []byte("releases: []"), 0o644))
|
||||
t.Chdir(root)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
envFile string
|
||||
toComplete string
|
||||
want []string
|
||||
}{
|
||||
{name: "file flag", file: "deploy/helmfile.yaml", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
|
||||
{name: "file flag with nested partial", file: "deploy/helmfile.yaml", toComplete: "dir=apps/", want: []string{"dir=apps/opencloud"}},
|
||||
{name: "directory flag", file: "deploy", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
|
||||
{name: "absolute file flag", file: filepath.Join(root, "deploy", "helmfile.yaml"), toComplete: "dir=i", want: []string{"dir=infra"}},
|
||||
{name: "file from environment", envFile: "deploy/helmfile.yaml", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
|
||||
{name: "no file uses working directory", toComplete: "dir=", want: []string{"dir=deploy", "dir=unrelated"}},
|
||||
{name: "remote file has no local root", file: "git::https://github.com/example/repo.git@helmfile.yaml?ref=main", toComplete: "dir=", want: nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("HELMFILE_FILE_PATH", tt.envFile)
|
||||
|
||||
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{File: tt.file})(nil, nil, tt.toComplete)
|
||||
assert.ElementsMatch(t, tt.want, suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorFlagCompletion_ThroughRootCmdHonorsFileFlag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, sub := range []string{"deploy/apps", "unrelated"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
|
||||
}
|
||||
t.Chdir(root)
|
||||
t.Setenv("HELMFILE_FILE_PATH", "")
|
||||
|
||||
rootCmd, err := NewRootCmd(&config.GlobalOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var out bytes.Buffer
|
||||
rootCmd.SetOut(&out)
|
||||
rootCmd.SetArgs([]string{cobra.ShellCompNoDescRequestCmd, "--file", "deploy/helmfile.yaml", "sync", "--selector", "dir="})
|
||||
require.NoError(t, rootCmd.Execute())
|
||||
|
||||
assert.Contains(t, out.String(), "dir=apps\n")
|
||||
assert.NotContains(t, out.String(), "dir=deploy")
|
||||
assert.NotContains(t, out.String(), "dir=unrelated")
|
||||
}
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ Flags:
|
||||
-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.
|
||||
The name of a release can be used as a label: "--selector name=myrelease"
|
||||
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
|
||||
--skip-deps skip running "helm repo update" and "helm dependency build"
|
||||
--state-values-file stringArray specify state values in a YAML file. Used to override .Values within the helmfile template (not values template).
|
||||
--state-values-set stringArray 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).
|
||||
|
||||
@@ -215,6 +215,25 @@ The `selector` parameter can be specified multiple times. Each parameter is reso
|
||||
|
||||
In addition to user supplied labels, the name, the namespace, and the chart are available to be used as selectors. The chart will just be the chart name excluding the repository (Example `stable/filebeat` would be selected using `--selector chart=filebeat`).
|
||||
|
||||
### The `dir` selector
|
||||
|
||||
The `dir=` selector key matches releases by the directory of their defining helmfile, relative to the root directory: the directory of the file passed via `-f` (or that directory itself when `-f` points to one), or the current directory when no `-f` is given. Unlike other selectors, `dir=` uses directory-prefix matching: `--selector dir=apps/opencloud` matches both releases defined directly in `apps/opencloud/` and those in any subdirectory below it. Values are normalized (trailing slashes and `./` prefixes are stripped). Absolute paths, `..` segments that escape the root, and the bare `.` are rejected at parse time; omit the selector entirely to match every release.
|
||||
|
||||
The `dir` selector has a second, more important effect: **it short-circuits sub-helmfile traversal**. When `dir=` is set, helmfile inspects each `helmfiles:` entry's path *before* loading or templating it, and skips any branch whose location rules out a matching release. For projects that aggregate many services via nested helmfiles (e.g. opendesk's `helmfile_generic.yaml.gotmpl` lists ~13 services), running `helmfile -l dir=apps/<single-service> sync` becomes roughly as fast as targeting that single service directly, without the cost of parsing and templating the other branches.
|
||||
|
||||
The label key `dir` is reserved. If `commonLabels`, a release's `labels` or a release template declares a key named `dir`, helmfile logs a warning and the user-defined value is ignored for selector matching (the auto-populated value shadows it, also for releases that have no auto-populated value). **Deprecation:** this will become a hard error in a future release, so rename such labels now.
|
||||
|
||||
#### Interactions worth knowing
|
||||
|
||||
* Releases loaded from remote (`git::…`) helmfiles do not receive a `dir` label and the optimization does not engage for them; they are always descended into. The same applies to sub-helmfiles located outside the root directory (e.g. `helmfiles: [../shared/helmfile.yaml]`).
|
||||
* The skip decision only looks at the location of the `helmfiles:` entry itself, so it assumes that nested helmfiles stay at or below their parent's directory. A branch that reaches the target via `..` (e.g. `stacks/prod/helmfile.yaml` listing `../../apps/foo/helmfile.yaml`) is skipped for `-l dir=apps/foo`, and its releases are not found.
|
||||
* Without `-f`, an auto-discovered `helmfile.d/` is anchored at the current directory, so its releases match `dir=helmfile.d`. With `-f helmfile.d` the anchor is `helmfile.d` itself.
|
||||
* **A nested `helmfiles:` entry that declares its own `selectors:` block replaces the CLI selectors for its sub-tree** per the existing inheritance model, so a CLI `-l dir=…` neither filters releases in that branch nor skips traversal below it.
|
||||
* Releases declared in a file loaded via `bases:` are merged into the *including* helmfile, so their `dir` value reflects the including helmfile's location rather than the base file's.
|
||||
For example, with `apps/web/helmfile.yaml` containing `bases: [../../common/releases.yaml]`, every release declared in `common/releases.yaml` gets `dir=apps/web`, not `dir=common`; `-l dir=common` matches nothing.
|
||||
* Globs in `helmfiles:` (e.g. `apps/**/helmfile.yaml`) are expanded before the dir-skip check, so a missing-file error in an entry that the dir filter would have skipped still fails the parent load. Use `missingFileHandler: Warn` if this is a problem.
|
||||
* Cross-branch transitive `needs:` are not auto-resurrected across pruned branches. If a release in `apps/x` depends on one in `apps/y`, run with a `dir=` covering both subtrees.
|
||||
|
||||
`commonLabels` can be used when you want to apply the same label to all releases and use [templating](templating.md) based on that.
|
||||
For instance, you install a number of charts on every customer but need to provide different values file per customer.
|
||||
|
||||
|
||||
+28
-5
@@ -67,6 +67,9 @@ type App struct {
|
||||
helms map[helmKey]helmexec.Interface
|
||||
helmsMutex sync.Mutex
|
||||
|
||||
rootHelmfileDir string
|
||||
rootHelmfileDirOnce sync.Once
|
||||
|
||||
ctx goContext.Context
|
||||
}
|
||||
|
||||
@@ -1087,6 +1090,7 @@ func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converg
|
||||
}
|
||||
|
||||
st.Selectors = opts.Selectors
|
||||
st.SetRootDir(a.RootHelmfileDir())
|
||||
|
||||
// Track whether any releases matched across nested helmfiles and converge.
|
||||
// Aggregate into a single send to matchChan to avoid overfilling the buffer
|
||||
@@ -1168,16 +1172,29 @@ func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, def
|
||||
continue
|
||||
}
|
||||
|
||||
// Selectors that govern this sub-tree. Matches the inheritance model:
|
||||
// CLI (parent) selectors flow down when the entry has none of its own
|
||||
// (and explicit-selector-inheritance is off), or when SelectorsInherited
|
||||
// is set; otherwise the entry's own selectors take over. Used both for
|
||||
// the dir-skip decision below and for the nested LoadOpts.
|
||||
var effectiveSelectors []string
|
||||
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
|
||||
effectiveSelectors = opts.Selectors
|
||||
} else {
|
||||
effectiveSelectors = m.Selectors
|
||||
}
|
||||
|
||||
if a.skipForDirFilter(absd, m.Path, effectiveSelectors) {
|
||||
a.Logger.Debugf("skipping subhelmfile %q: outside dir= selector scope", m.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
optsForNestedState := LoadOpts{
|
||||
CalleePath: filepath.Join(absd, file),
|
||||
Environment: m.Environment,
|
||||
Reverse: defOpts.Reverse,
|
||||
RetainValuesFiles: defOpts.RetainValuesFiles,
|
||||
}
|
||||
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
|
||||
optsForNestedState.Selectors = opts.Selectors
|
||||
} else {
|
||||
optsForNestedState.Selectors = m.Selectors
|
||||
Selectors: effectiveSelectors,
|
||||
}
|
||||
|
||||
// Carry parent config requested via `inherits:`, and parent repo names
|
||||
@@ -1327,6 +1344,7 @@ func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converg
|
||||
}
|
||||
|
||||
st.Selectors = opts.Selectors
|
||||
st.SetRootDir(a.RootHelmfileDir())
|
||||
|
||||
if !opts.Reverse && len(st.Helmfiles) > 0 {
|
||||
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
|
||||
@@ -1638,6 +1656,11 @@ type Opts struct {
|
||||
}
|
||||
|
||||
func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error {
|
||||
// Resolve the root helmfile directory anchor for dir= filtering before
|
||||
// any chdir or goroutine fan-out happens further down; it uses the
|
||||
// process CWD at command start, which is the user's intent.
|
||||
a.resolveRootHelmfileDir()
|
||||
|
||||
opts := LoadOpts{
|
||||
Selectors: a.Selectors,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/helmfile/helmfile/pkg/filesystem"
|
||||
"github.com/helmfile/helmfile/pkg/remote"
|
||||
"github.com/helmfile/helmfile/pkg/state"
|
||||
)
|
||||
|
||||
// RootHelmfileDir returns the absolute directory of the top-level helmfile,
|
||||
// or "" when the root is a remote URL or otherwise unresolvable. Callers
|
||||
// treat "" as "dir-based filtering not available". The value is cached by
|
||||
// resolveRootHelmfileDir; it must be invoked before any goroutine fan-out
|
||||
// or working-directory change so that the CWD-sensitive resolution lands
|
||||
// against the process CWD at command start, not the (possibly chdir'd)
|
||||
// directory of a leaf helmfile being processed.
|
||||
func (a *App) RootHelmfileDir() string {
|
||||
return a.rootHelmfileDir
|
||||
}
|
||||
|
||||
// resolveRootHelmfileDir computes and caches the root directory anchor for
|
||||
// dir-based filtering. Idempotent; safe to call multiple times. Must be
|
||||
// called from the top-level entry of each command, before within() or any
|
||||
// goroutine spawn.
|
||||
func (a *App) resolveRootHelmfileDir() {
|
||||
a.rootHelmfileDirOnce.Do(func() {
|
||||
a.rootHelmfileDir = a.computeRootHelmfileDir()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) computeRootHelmfileDir() string {
|
||||
dir, err := DirSelectorRoot(a.fs, a.FileOrDir)
|
||||
if err != nil {
|
||||
a.Logger.Debugf("dir selector unavailable: cannot resolve root helmfile directory: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// DirSelectorRoot returns the absolute directory that dir= selector values
|
||||
// are relative to for the given -f value: the working directory when it is
|
||||
// empty, the directory itself when it points to one, the file's directory
|
||||
// otherwise. Returns "" for a remote helmfile.
|
||||
func DirSelectorRoot(fs *filesystem.FileSystem, fileOrDir string) (string, error) {
|
||||
if fileOrDir == "" {
|
||||
return fs.Getwd()
|
||||
}
|
||||
if remote.IsRemote(fileOrDir) {
|
||||
return "", nil
|
||||
}
|
||||
absPath, err := fs.Abs(fileOrDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fs.DirectoryExistsAt(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
return filepath.Dir(absPath), nil
|
||||
}
|
||||
|
||||
// dirSelectorGroup holds the positive dir= target values inside one -l
|
||||
// argument. An empty targets slice means the group has no dir= constraint
|
||||
// and is therefore path-permissive during traversal-skip.
|
||||
type dirSelectorGroup struct {
|
||||
targets []string
|
||||
}
|
||||
|
||||
// extractDirSelectorTargets returns one dirSelectorGroup per -l argument
|
||||
// with that group's positive dir= values. Negative dir!= is intentionally
|
||||
// dropped: skipping a branch on a negative constraint would require proving
|
||||
// it contains only excluded releases, which generally requires loading it.
|
||||
// Malformed groups are treated as path-permissive so traversal does not
|
||||
// short-circuit on selector strings the filter will later report on.
|
||||
// Returns nil when no positive dir= appears, so callers can skip the check.
|
||||
func extractDirSelectorTargets(selectors []string) []dirSelectorGroup {
|
||||
if len(selectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
groups := make([]dirSelectorGroup, 0, len(selectors))
|
||||
anyDir := false
|
||||
for _, s := range selectors {
|
||||
targets, err := state.PositiveDirTargets(s)
|
||||
if err != nil {
|
||||
groups = append(groups, dirSelectorGroup{})
|
||||
continue
|
||||
}
|
||||
groups = append(groups, dirSelectorGroup{targets: targets})
|
||||
if len(targets) > 0 {
|
||||
anyDir = true
|
||||
}
|
||||
}
|
||||
if !anyDir {
|
||||
return nil
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// skipForDirFilter reports whether a `helmfiles:` entry can be skipped for
|
||||
// the given selectors. Remote entries and unresolvable roots always descend.
|
||||
func (a *App) skipForDirFilter(stateDir, entryPath string, selectors []string) bool {
|
||||
rootDir := a.RootHelmfileDir()
|
||||
if rootDir == "" || remote.IsRemote(entryPath) {
|
||||
return false
|
||||
}
|
||||
groups := extractDirSelectorTargets(selectors)
|
||||
if len(groups) == 0 {
|
||||
return false
|
||||
}
|
||||
entryDir, ok := a.entryDirRelativeToRoot(rootDir, stateDir, entryPath)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return !shouldDescendForDirFilter(entryDir, groups)
|
||||
}
|
||||
|
||||
// entryDirRelativeToRoot returns the directory holding the helmfile(s) of a
|
||||
// `helmfiles:` entry, relative to rootDir: the entry itself when it is a
|
||||
// directory, its parent otherwise. Returns false when the entry lies outside
|
||||
// rootDir, signaling the caller to descend conservatively.
|
||||
func (a *App) entryDirRelativeToRoot(rootDir, stateDir, entryPath string) (string, bool) {
|
||||
abs := entryPath
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(stateDir, entryPath)
|
||||
}
|
||||
if !a.fs.DirectoryExistsAt(abs) {
|
||||
abs = filepath.Dir(abs)
|
||||
}
|
||||
return state.DirRelativeToRoot(rootDir, abs)
|
||||
}
|
||||
|
||||
// shouldDescendForDirFilter returns true when a sub-helmfile located in
|
||||
// entryDir (relative to the root) could contain a release matching at least
|
||||
// one of the dir-selector groups. A branch is relevant for a dir=target when
|
||||
// it lives at or below target, or when target lives below it (e.g. an
|
||||
// aggregator at apps/helmfile.yaml for target apps/x). The decision only
|
||||
// looks at the entry's own location, so it assumes nested entries stay inside
|
||||
// their parent's directory.
|
||||
func shouldDescendForDirFilter(entryDir string, groups []dirSelectorGroup) bool {
|
||||
if len(groups) == 0 || entryDir == "." {
|
||||
return true
|
||||
}
|
||||
for _, g := range groups {
|
||||
conflicts := slices.ContainsFunc(g.targets, func(target string) bool {
|
||||
return !state.DirsCompatible(entryDir, target)
|
||||
})
|
||||
if !conflicts {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/helmfile/vals"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/helmfile/helmfile/pkg/testhelper"
|
||||
)
|
||||
|
||||
func TestExtractDirSelectorTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selectors []string
|
||||
wantNil bool
|
||||
wantPer []int
|
||||
}{
|
||||
{
|
||||
name: "nil selectors",
|
||||
selectors: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "no dir selectors",
|
||||
selectors: []string{"name=foo", "namespace=bar"},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "single dir selector",
|
||||
selectors: []string{"dir=apps/x"},
|
||||
wantPer: []int{1},
|
||||
},
|
||||
{
|
||||
name: "dir with companion in same group",
|
||||
selectors: []string{"dir=apps/x,name=foo"},
|
||||
wantPer: []int{1},
|
||||
},
|
||||
{
|
||||
name: "dir spread across groups",
|
||||
selectors: []string{"dir=apps/x", "dir=apps/y"},
|
||||
wantPer: []int{1, 1},
|
||||
},
|
||||
{
|
||||
name: "negative dir is ignored",
|
||||
selectors: []string{"dir!=apps/x"},
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "mixed group with no dir and group with dir",
|
||||
selectors: []string{"name=foo", "dir=apps/x"},
|
||||
wantPer: []int{0, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
groups := extractDirSelectorTargets(tt.selectors)
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, groups)
|
||||
return
|
||||
}
|
||||
assert.Len(t, groups, len(tt.wantPer))
|
||||
for i, want := range tt.wantPer {
|
||||
assert.Len(t, groups[i].targets, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldDescendForDirFilter(t *testing.T) {
|
||||
const target = "opendesk/helmfile/apps/openproject"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entryDir string
|
||||
groups []dirSelectorGroup
|
||||
wantSkip bool
|
||||
}{
|
||||
{
|
||||
name: "no groups means caller skipped check; treat as descend",
|
||||
entryDir: "opendesk/helmfile/apps/openproject",
|
||||
groups: nil,
|
||||
},
|
||||
{
|
||||
name: "entry in target dir → descend",
|
||||
entryDir: "opendesk/helmfile/apps/openproject",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
},
|
||||
{
|
||||
name: "entry below target → descend",
|
||||
entryDir: "opendesk/helmfile/apps/openproject/sub",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
},
|
||||
{
|
||||
name: "sibling service → skip",
|
||||
entryDir: "opendesk/helmfile/apps/xwiki",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "name-collision is not a match → skip",
|
||||
entryDir: "opendesk/helmfile/apps/openproject-extras",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "entry is ancestor of target → descend",
|
||||
entryDir: "opendesk",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
},
|
||||
{
|
||||
name: "entry in root dir → descend",
|
||||
entryDir: ".",
|
||||
groups: []dirSelectorGroup{{targets: []string{target}}},
|
||||
},
|
||||
{
|
||||
name: "group with no targets is permissive",
|
||||
entryDir: "opendesk/helmfile/apps/xwiki",
|
||||
groups: []dirSelectorGroup{{targets: nil}, {targets: []string{target}}},
|
||||
},
|
||||
{
|
||||
name: "multiple targets in one group: all must allow",
|
||||
entryDir: "opendesk/helmfile/apps/openproject",
|
||||
groups: []dirSelectorGroup{{targets: []string{target, "opendesk/helmfile/apps/xwiki"}}},
|
||||
wantSkip: true,
|
||||
},
|
||||
{
|
||||
name: "OR across groups: either matches descends",
|
||||
entryDir: "opendesk/helmfile/apps/openproject",
|
||||
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/xwiki"}}, {targets: []string{target}}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, !tt.wantSkip, shouldDescendForDirFilter(tt.entryDir, tt.groups))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newDirFilterTestApp(t *testing.T, files map[string]string, fileOrDir string, selectors []string) (*App, *testhelper.TestFs) {
|
||||
t.Helper()
|
||||
|
||||
testFs := testhelper.NewTestFs(files)
|
||||
testFs.Cwd = "/workspace"
|
||||
|
||||
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
|
||||
require.NoError(t, err)
|
||||
|
||||
app := &App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
OverrideKubeContext: "default",
|
||||
DisableKubeVersionAutoDetection: true,
|
||||
Env: "default",
|
||||
Logger: newAppTestLogger(),
|
||||
valsRuntime: valsRuntime,
|
||||
FileOrDir: fileOrDir,
|
||||
Selectors: selectors,
|
||||
}
|
||||
app = injectFs(app, testFs)
|
||||
expectNoCallsToHelm(app)
|
||||
return app, testFs
|
||||
}
|
||||
|
||||
// TestProcessNestedHelmfiles_DirFilterSkipsSiblings drives the full visit
|
||||
// chain against an opendesk-shaped fixture: a root helmfile that includes an
|
||||
// aggregator, which in turn lists several sibling service helmfiles. With
|
||||
// `-l dir=…/openproject` only the matching service helmfile should be opened,
|
||||
// asserted by counting how often a helmfile-child.yaml file is read. The
|
||||
// release names also let us confirm the post-load filter sees only the
|
||||
// expected entry.
|
||||
func TestProcessNestedHelmfiles_DirFilterSkipsSiblings(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/workspace/helmfile.yaml": `
|
||||
helmfiles:
|
||||
- opendesk/helmfile_generic.yaml
|
||||
`,
|
||||
"/workspace/opendesk/helmfile_generic.yaml": `
|
||||
helmfiles:
|
||||
- apps/openproject/helmfile-child.yaml
|
||||
- apps/xwiki/helmfile-child.yaml
|
||||
- apps/jitsi/helmfile-child.yaml
|
||||
`,
|
||||
"/workspace/opendesk/apps/openproject/helmfile-child.yaml": `
|
||||
releases:
|
||||
- name: openproject-web
|
||||
chart: stable/openproject
|
||||
`,
|
||||
"/workspace/opendesk/apps/xwiki/helmfile-child.yaml": `
|
||||
releases:
|
||||
- name: xwiki
|
||||
chart: stable/xwiki
|
||||
`,
|
||||
"/workspace/opendesk/apps/jitsi/helmfile-child.yaml": `
|
||||
releases:
|
||||
- name: jitsi
|
||||
chart: stable/jitsi
|
||||
`,
|
||||
}
|
||||
|
||||
app, testFs := newDirFilterTestApp(t, files, "/workspace/helmfile.yaml", []string{"dir=opendesk/apps/openproject"})
|
||||
|
||||
err := app.ForEachState(Noop, false, SetFilter(true))
|
||||
assert.NoError(t, err)
|
||||
|
||||
childReads := 0
|
||||
for _, r := range testFs.SuccessfulReads() {
|
||||
if strings.HasSuffix(r, "helmfile-child.yaml") {
|
||||
childReads++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, childReads, "exactly one helmfile-child.yaml should be read (the matching one), got reads=%v", testFs.SuccessfulReads())
|
||||
}
|
||||
|
||||
// TestProcessNestedHelmfiles_DirFilterExcludesTopLevelReleases verifies that a
|
||||
// dir filter targeting a nested subtree excludes releases declared directly
|
||||
// in the root helmfile (whose dirLabel is ".") while still descending into the
|
||||
// matching sub-helmfile. The root file itself must still be read because its
|
||||
// `helmfiles:` directive is what points to the nested branch. Uses distinct
|
||||
// filenames per level so the read log is unambiguous.
|
||||
func TestProcessNestedHelmfiles_DirFilterExcludesTopLevelReleases(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/workspace/root.yaml": `
|
||||
releases:
|
||||
- name: root-release
|
||||
chart: stable/root
|
||||
helmfiles:
|
||||
- apps/openproject/leaf.yaml
|
||||
`,
|
||||
"/workspace/apps/openproject/leaf.yaml": `
|
||||
releases:
|
||||
- name: openproject-web
|
||||
chart: stable/openproject
|
||||
`,
|
||||
}
|
||||
|
||||
app, testFs := newDirFilterTestApp(t, files, "/workspace/root.yaml", []string{"dir=apps/openproject"})
|
||||
|
||||
err := app.ForEachState(Noop, false, SetFilter(true))
|
||||
assert.NoError(t, err)
|
||||
|
||||
reads := testFs.SuccessfulReads()
|
||||
readSet := map[string]bool{}
|
||||
for _, r := range reads {
|
||||
readSet[r] = true
|
||||
}
|
||||
assert.True(t, readSet["root.yaml"], "root helmfile must be parsed to learn its helmfiles: list, reads=%v", reads)
|
||||
assert.True(t, readSet["leaf.yaml"], "matching nested helmfile must be descended into, reads=%v", reads)
|
||||
}
|
||||
|
||||
// TestProcessNestedHelmfiles_DirDotIsRejected pins down the current behavior
|
||||
// that `dir=.` and equivalent forms (`dir=./`, `dir=apps/..`) are rejected at
|
||||
// selector parse time. Semantics for `.` were under discussion: matching
|
||||
// everything (no-op), matching only root-level releases (useful but uneven),
|
||||
// or rejecting outright. Outright rejection is the current decision; this
|
||||
// test makes any change to that policy a deliberate code change.
|
||||
func TestProcessNestedHelmfiles_DirDotIsRejected(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/workspace/root.yaml": `
|
||||
releases:
|
||||
- name: root-release
|
||||
chart: stable/root
|
||||
`,
|
||||
}
|
||||
for _, selector := range []string{"dir=.", "dir=./", "dir=apps/.."} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
app, _ := newDirFilterTestApp(t, files, "/workspace/root.yaml", []string{selector})
|
||||
|
||||
err := app.ForEachState(Noop, false, SetFilter(true))
|
||||
assert.Error(t, err, "dir=. and equivalents should be rejected at parse time")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipForDirFilter(t *testing.T) {
|
||||
const rootDir = "/workspace"
|
||||
const stateDir = "/workspace/opendesk"
|
||||
selectors := []string{"dir=opendesk/apps/openproject"}
|
||||
|
||||
testFs := testhelper.NewTestFs(map[string]string{
|
||||
"/workspace/opendesk/apps/openproject/helmfile.yaml": "",
|
||||
"/workspace/opendesk/apps/xwiki/helmfile.yaml": "",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entryPath string
|
||||
selectors []string
|
||||
rootDir string
|
||||
skip bool
|
||||
}{
|
||||
{name: "matching entry descends", entryPath: "apps/openproject/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: false},
|
||||
{name: "sibling entry is skipped", entryPath: "apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: true},
|
||||
{name: "remote entry is never short-circuited", entryPath: "git::https://github.com/example/xwiki.git@helmfile.yaml?ref=main", selectors: selectors, rootDir: rootDir, skip: false},
|
||||
{name: "matching directory entry descends", entryPath: "apps/openproject", selectors: selectors, rootDir: rootDir, skip: false},
|
||||
{name: "sibling directory entry is skipped", entryPath: "apps/xwiki", selectors: selectors, rootDir: rootDir, skip: true},
|
||||
{name: "ancestor directory entry descends", entryPath: "apps", selectors: selectors, rootDir: rootDir, skip: false},
|
||||
{name: "absolute sibling entry is skipped", entryPath: "/workspace/opendesk/apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: true},
|
||||
{name: "entry outside root descends", entryPath: "../../shared/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: false},
|
||||
{name: "no dir selector descends", entryPath: "apps/xwiki/helmfile.yaml", selectors: []string{"tier=frontend"}, rootDir: rootDir, skip: false},
|
||||
{name: "negative dir selector does not prune", entryPath: "apps/xwiki/helmfile.yaml", selectors: []string{"dir!=opendesk/apps/xwiki"}, rootDir: rootDir, skip: false},
|
||||
{name: "unresolvable root descends", entryPath: "apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: "", skip: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app := injectFs(&App{rootHelmfileDir: tc.rootDir}, testFs)
|
||||
assert.Equal(t, tc.skip, app.skipForDirFilter(stateDir, tc.entryPath, tc.selectors))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessNestedHelmfiles_DirFilterSkipsSiblingDirectoryEntries(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/workspace/helmfile.yaml": `
|
||||
helmfiles:
|
||||
- apps/openproject
|
||||
- apps/xwiki
|
||||
`,
|
||||
"/workspace/apps/openproject/leaf.yaml": `
|
||||
releases:
|
||||
- name: openproject-web
|
||||
chart: stable/openproject
|
||||
`,
|
||||
"/workspace/apps/xwiki/leaf.yaml": `
|
||||
releases:
|
||||
- name: xwiki
|
||||
chart: stable/xwiki
|
||||
`,
|
||||
}
|
||||
|
||||
app, testFs := newDirFilterTestApp(t, files, "/workspace/helmfile.yaml", []string{"dir=apps/openproject"})
|
||||
for _, dir := range []string{"/workspace/apps/openproject", "/workspace/apps/xwiki"} {
|
||||
testFs.GlobFixtures[dir] = []string{dir}
|
||||
}
|
||||
|
||||
var selected []string
|
||||
err := app.ForEachState(func(run *Run) (bool, []error) {
|
||||
releases, err := run.state.GetSelectedReleases(false)
|
||||
if err != nil {
|
||||
return false, []error{err}
|
||||
}
|
||||
for _, r := range releases {
|
||||
selected = append(selected, r.Name)
|
||||
}
|
||||
return len(releases) > 0, nil
|
||||
}, false, SetFilter(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
leafReads := 0
|
||||
for _, r := range testFs.SuccessfulReads() {
|
||||
if strings.HasSuffix(r, "leaf.yaml") {
|
||||
leafReads++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, leafReads, "only the matching directory entry should be read, got reads=%v", testFs.SuccessfulReads())
|
||||
assert.Equal(t, []string{"openproject-web"}, selected)
|
||||
}
|
||||
@@ -223,6 +223,12 @@ func (c *StateCreator) ParseAndLoad(content []byte, baseDir, file string, envNam
|
||||
}
|
||||
state.RenderedValues = vals
|
||||
|
||||
if evaluateBases {
|
||||
for _, w := range state.reservedLabelWarnings() {
|
||||
c.logger.Warnf("WARNING: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
|
||||
+191
-34
@@ -2,6 +2,9 @@ package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -22,32 +25,121 @@ type LabelFilter struct {
|
||||
|
||||
// Match will match a release that has the same labels as the filter
|
||||
func (l LabelFilter) Match(r ReleaseSpec) bool {
|
||||
if len(l.positiveLabels) > 0 {
|
||||
for _, element := range l.positiveLabels {
|
||||
k := element[0]
|
||||
v := element[1]
|
||||
if rVal, ok := r.Labels[k]; !ok {
|
||||
return false
|
||||
} else if rVal != v {
|
||||
return false
|
||||
}
|
||||
for _, element := range l.positiveLabels {
|
||||
if !labelMatches(r.Labels, element[0], element[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(l.negativeLabels) > 0 {
|
||||
for _, element := range l.negativeLabels {
|
||||
k := element[0]
|
||||
v := element[1]
|
||||
if rVal, ok := r.Labels[k]; !ok {
|
||||
|
||||
} else if rVal == v {
|
||||
return false
|
||||
}
|
||||
for _, element := range l.negativeLabels {
|
||||
if labelMatches(r.Labels, element[0], element[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// labelMatches reports whether the release's labels satisfy the single k=v
|
||||
// constraint: positive callers require a match, negative callers require no
|
||||
// match. The reserved "dir" key matches by directory prefix instead of
|
||||
// strict equality.
|
||||
func labelMatches(labels map[string]string, k, v string) bool {
|
||||
if k == DirLabel {
|
||||
return matchDirPrefix(labels[DirLabel], v)
|
||||
}
|
||||
val, ok := labels[k]
|
||||
return ok && val == v
|
||||
}
|
||||
|
||||
// DirLabel is the reserved selector key for path-based release filtering.
|
||||
// Releases get this label injected at match time with the directory of their
|
||||
// defining helmfile, relative to the root helmfile. It uses directory-prefix
|
||||
// semantics in LabelFilter rather than the strict equality of other keys.
|
||||
const DirLabel = "dir"
|
||||
|
||||
// withDirLabel returns r with the "dir" label set to the auto-populated dir
|
||||
// for matching, without mutating the source release or surfacing the value in
|
||||
// user-facing label output. A user-defined "dir" label is always shadowed:
|
||||
// when dir is empty (remote or outside-root helmfiles) it is dropped, so such
|
||||
// releases never take part in dir-based filtering.
|
||||
func withDirLabel(r ReleaseSpec, dir string) ReleaseSpec {
|
||||
if _, userDefined := r.Labels[DirLabel]; dir == "" && !userDefined {
|
||||
return r
|
||||
}
|
||||
cloned := r
|
||||
cloned.Labels = make(map[string]string, len(r.Labels)+1)
|
||||
maps.Copy(cloned.Labels, r.Labels)
|
||||
if dir == "" {
|
||||
delete(cloned.Labels, DirLabel)
|
||||
} else {
|
||||
cloned.Labels[DirLabel] = dir
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// matchDirPrefix reports whether a release's dirLabel falls under the user's
|
||||
// target value, using directory-prefix semantics: the label must equal the
|
||||
// target or live under target/. Inputs are normalized so trailing slashes and
|
||||
// `./` prefixes do not matter. Empty dirLabel never matches (releases from
|
||||
// remote helmfiles or outside-root branches have no anchor).
|
||||
func matchDirPrefix(dirLabel, value string) bool {
|
||||
if dirLabel == "" {
|
||||
return false
|
||||
}
|
||||
return dirAtOrBelow(NormalizeDirValue(dirLabel), NormalizeDirValue(value))
|
||||
}
|
||||
|
||||
func dirAtOrBelow(dir, ancestor string) bool {
|
||||
return dir == ancestor || strings.HasPrefix(dir, ancestor+"/")
|
||||
}
|
||||
|
||||
func escapesRoot(normalizedDir string) bool {
|
||||
return normalizedDir == ".." || strings.HasPrefix(normalizedDir, "../")
|
||||
}
|
||||
|
||||
// DirRelativeToRoot returns absDir relative to rootDir in normalized slash
|
||||
// form, or false when absDir lies outside rootDir.
|
||||
func DirRelativeToRoot(rootDir, absDir string) (string, bool) {
|
||||
rel, err := filepath.Rel(rootDir, absDir)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
rel = NormalizeDirValue(rel)
|
||||
if escapesRoot(rel) {
|
||||
return "", false
|
||||
}
|
||||
return rel, true
|
||||
}
|
||||
|
||||
// NormalizeDirValue canonicalizes a dir= selector value or a "dir" auto-label
|
||||
// to slash form with no trailing slash and no redundant elements. Empty input
|
||||
// stays empty. Examples: `apps/x/` → `apps/x`, `./apps/x` → `apps/x`,
|
||||
// `apps//x` → `apps/x`, `.` → `.`.
|
||||
func NormalizeDirValue(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Clean(filepath.ToSlash(v))
|
||||
}
|
||||
|
||||
// PositiveDirTargets parses one selector group string and returns the
|
||||
// normalized positive dir= values declared in it. Returns the same error as
|
||||
// ParseLabels on malformed input so callers can choose to skip the group.
|
||||
// Used by traversal-skip logic to peek at dir constraints without exposing
|
||||
// LabelFilter internals.
|
||||
func PositiveDirTargets(selector string) ([]string, error) {
|
||||
lf, err := ParseLabels(selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dirs []string
|
||||
for _, kv := range lf.positiveLabels {
|
||||
if kv[0] == DirLabel {
|
||||
dirs = append(dirs, NormalizeDirValue(kv[1]))
|
||||
}
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// SelectorsAreCompatible checks whether any pair of selectors from two sets could
|
||||
// potentially match the same release. It compares only positive labels (key=value):
|
||||
// two selectors conflict if they require the same key to have different values.
|
||||
@@ -92,11 +184,13 @@ func parseLabelFilters(selectors []string) ([]LabelFilter, error) {
|
||||
}
|
||||
|
||||
// positiveLabelsCompatibleWith returns true if the positive labels of two filters
|
||||
// do not conflict (i.e., no shared key with a different value).
|
||||
// do not conflict (i.e., no shared key with a different value). The "dir" key
|
||||
// uses directory-prefix semantics, so two `dir=` constraints are compatible
|
||||
// when one path is at or below the other; only siblings genuinely conflict.
|
||||
func (l LabelFilter) positiveLabelsCompatibleWith(other LabelFilter) bool {
|
||||
for _, a := range l.positiveLabels {
|
||||
for _, b := range other.positiveLabels {
|
||||
if a[0] == b[0] && a[1] != b[1] {
|
||||
if !positiveLabelPairCompatible(a, b) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -104,6 +198,28 @@ func (l LabelFilter) positiveLabelsCompatibleWith(other LabelFilter) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// positiveLabelPairCompatible reports whether two positive k=v constraints
|
||||
// could be satisfied by the same release. The "dir" key compares with
|
||||
// directory-prefix semantics, so overlapping subtrees are compatible.
|
||||
func positiveLabelPairCompatible(a, b []string) bool {
|
||||
if a[0] != b[0] {
|
||||
return true
|
||||
}
|
||||
if a[0] == DirLabel {
|
||||
return DirsCompatible(a[1], b[1])
|
||||
}
|
||||
return a[1] == b[1]
|
||||
}
|
||||
|
||||
// DirsCompatible reports whether two dir= values could both be satisfied by
|
||||
// the same release. Compatible when one is at-or-below the other in the
|
||||
// directory hierarchy; siblings or unrelated subtrees conflict.
|
||||
func DirsCompatible(a, b string) bool {
|
||||
a = NormalizeDirValue(a)
|
||||
b = NormalizeDirValue(b)
|
||||
return dirAtOrBelow(a, b) || dirAtOrBelow(b, a)
|
||||
}
|
||||
|
||||
var (
|
||||
reLabelMismatch = regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+!=[a-zA-Z0-9_\.\/\+-]+$`)
|
||||
reLabelMatch = regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+=[a-zA-Z0-9_\.\/\+-]+$`)
|
||||
@@ -111,21 +227,62 @@ var (
|
||||
|
||||
// ParseLabels takes a label in the form foo=bar,baz!=bat and returns a LabelFilter that will match the labels
|
||||
func ParseLabels(l string) (LabelFilter, error) {
|
||||
lf := LabelFilter{}
|
||||
lf.positiveLabels = [][]string{}
|
||||
lf.negativeLabels = [][]string{}
|
||||
var err error
|
||||
labels := strings.SplitSeq(l, ",")
|
||||
for label := range labels {
|
||||
if match := reLabelMismatch.MatchString(label); match {
|
||||
kv := strings.Split(label, "!=")
|
||||
lf := LabelFilter{
|
||||
positiveLabels: [][]string{},
|
||||
negativeLabels: [][]string{},
|
||||
}
|
||||
for label := range strings.SplitSeq(l, ",") {
|
||||
kv, negative, err := parseOneLabel(label)
|
||||
if err != nil {
|
||||
return lf, err
|
||||
}
|
||||
if negative {
|
||||
lf.negativeLabels = append(lf.negativeLabels, kv)
|
||||
} else if match := reLabelMatch.MatchString(label); match {
|
||||
kv := strings.Split(label, "=")
|
||||
lf.positiveLabels = append(lf.positiveLabels, kv)
|
||||
} else {
|
||||
return lf, fmt.Errorf("malformed label: %s. Expected label in form k=v or k!=v", label)
|
||||
lf.positiveLabels = append(lf.positiveLabels, kv)
|
||||
}
|
||||
}
|
||||
return lf, err
|
||||
return lf, nil
|
||||
}
|
||||
|
||||
// parseOneLabel parses a single k=v or k!=v label. Values of the reserved
|
||||
// "dir" key are validated so unusable targets fail fast.
|
||||
func parseOneLabel(label string) (kv []string, negative bool, err error) {
|
||||
sep := ""
|
||||
if reLabelMismatch.MatchString(label) {
|
||||
sep = "!="
|
||||
} else if reLabelMatch.MatchString(label) {
|
||||
sep = "="
|
||||
}
|
||||
if sep == "" {
|
||||
return nil, false, fmt.Errorf("malformed label: %s. Expected label in form k=v or k!=v", label)
|
||||
}
|
||||
kv = strings.Split(label, sep)
|
||||
if kv[0] != DirLabel {
|
||||
return kv, sep == "!=", nil
|
||||
}
|
||||
if err := validateDirSelectorValue(kv[1]); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return kv, sep == "!=", nil
|
||||
}
|
||||
|
||||
// validateDirSelectorValue rejects dir= and dir!= values that cannot be
|
||||
// matched against the auto-populated dir label: absolute paths (labels are
|
||||
// always root-relative), paths that escape the root via "..", and the bare
|
||||
// "." which would either be a no-op or carry surprising "root-only" semantics
|
||||
// depending on interpretation. Callers should omit dir= entirely to select
|
||||
// every release.
|
||||
func validateDirSelectorValue(v string) error {
|
||||
n := NormalizeDirValue(v)
|
||||
if n == "." {
|
||||
return fmt.Errorf("dir= selector value %q is not allowed; omit -l dir= entirely to match every release", v)
|
||||
}
|
||||
if strings.HasPrefix(n, "/") {
|
||||
return fmt.Errorf("dir= selector value %q must be a path relative to the root helmfile, not absolute", v)
|
||||
}
|
||||
if escapesRoot(n) {
|
||||
return fmt.Errorf("dir= selector value %q escapes the root helmfile directory", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,3 +169,229 @@ func TestSelectorsAreCompatible(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelFilterMatchDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
dir string
|
||||
want bool
|
||||
}{
|
||||
{name: "exact dir match", selector: "dir=apps/opencloud", dir: "apps/opencloud", want: true},
|
||||
{name: "prefix dir match", selector: "dir=apps/opencloud", dir: "apps/opencloud/sub", want: true},
|
||||
{name: "sibling does not match", selector: "dir=apps/opencloud", dir: "apps/xwiki", want: false},
|
||||
{name: "shallower target does not match deeper", selector: "dir=apps/opencloud/sub", dir: "apps/opencloud", want: false},
|
||||
{name: "ancestor prefix match", selector: "dir=apps", dir: "apps/opencloud/sub", want: true},
|
||||
{name: "missing dir label fails positive", selector: "dir=apps/x", dir: "", want: false},
|
||||
{name: "negative dir on different branch matches", selector: "dir!=apps/x", dir: "apps/y", want: true},
|
||||
{name: "negative dir on same branch excludes", selector: "dir!=apps/x", dir: "apps/x/foo", want: false},
|
||||
{name: "missing dir label still matches negative", selector: "dir!=apps/x", dir: "", want: true},
|
||||
{name: "partial-name prefix is not a dir match", selector: "dir=apps/open", dir: "apps/opencloud", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lf, err := ParseLabels(tt.selector)
|
||||
assert.NoError(t, err)
|
||||
release := ReleaseSpec{Labels: map[string]string{}}
|
||||
if tt.dir != "" {
|
||||
release.Labels[DirLabel] = tt.dir
|
||||
}
|
||||
assert.Equal(t, tt.want, lf.Match(release))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelFilterMatchDirComposesWithOtherKeys(t *testing.T) {
|
||||
lf, err := ParseLabels("dir=apps/opencloud,name=foo")
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
release ReleaseSpec
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "both dir and name match",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/opencloud", "name": "foo"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dir matches but name does not",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/opencloud", "name": "bar"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "name matches but dir does not",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/other", "name": "foo"}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, lf.Match(tt.release))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDirPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirLabel string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"empty label never matches", "", "any", false},
|
||||
{"exact equality", "apps/x", "apps/x", true},
|
||||
{"deeper is prefix", "apps/x/sub", "apps/x", true},
|
||||
{"sibling not matched", "apps/y", "apps/x", false},
|
||||
{"target deeper than label", "apps/x", "apps/x/sub", false},
|
||||
{"name-collision not a match", "apps/xenial", "apps/x", false},
|
||||
{"value with trailing slash normalizes", "apps/x", "apps/x/", true},
|
||||
{"label with trailing slash normalizes", "apps/x/", "apps/x", true},
|
||||
{"value with ./ prefix normalizes", "apps/x", "./apps/x", true},
|
||||
{"root label does not match nested target", ".", "apps/x", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, matchDirPrefix(tt.dirLabel, tt.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelFilterMatchDirCompoundWithNegative(t *testing.T) {
|
||||
lf, err := ParseLabels("name=foo,dir!=apps/excluded")
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
release ReleaseSpec
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "name matches and dir is not excluded",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/wanted", "name": "foo"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "name matches but dir falls under excluded subtree",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/excluded/sub", "name": "foo"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "name does not match and dir is fine",
|
||||
release: ReleaseSpec{Labels: map[string]string{DirLabel: "apps/wanted", "name": "bar"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no dir label still matches negative dir",
|
||||
release: ReleaseSpec{Labels: map[string]string{"name": "foo"}},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, lf.Match(tt.release))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDirValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{".", "."},
|
||||
{"apps/x", "apps/x"},
|
||||
{"apps/x/", "apps/x"},
|
||||
{"./apps/x", "apps/x"},
|
||||
{"apps//x", "apps/x"},
|
||||
{"apps/./x", "apps/x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, NormalizeDirValue(tt.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirsCompatible(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"identical paths", "apps/x", "apps/x", true},
|
||||
{"a is ancestor of b", "apps", "apps/x", true},
|
||||
{"b is ancestor of a", "apps/x/sub", "apps", true},
|
||||
{"siblings are incompatible", "apps/x", "apps/y", false},
|
||||
{"name-collision incompatible", "apps/x", "apps/xenial", false},
|
||||
{"identical after normalization", "apps/x/", "./apps/x", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, DirsCompatible(tt.a, tt.b))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelsRejectsInvalidDirValues(t *testing.T) {
|
||||
cases := []string{
|
||||
"dir=.",
|
||||
"dir=./",
|
||||
"dir=apps/..",
|
||||
"dir=/absolute/path",
|
||||
"dir=..",
|
||||
"dir=../escape",
|
||||
"dir!=.",
|
||||
"dir!=/absolute",
|
||||
}
|
||||
for _, sel := range cases {
|
||||
t.Run(sel, func(t *testing.T) {
|
||||
_, err := ParseLabels(sel)
|
||||
assert.Error(t, err, "expected ParseLabels to reject %q", sel)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelsAcceptsValidDirValues(t *testing.T) {
|
||||
cases := []string{
|
||||
"dir=apps/x",
|
||||
"dir=apps/x/sub",
|
||||
"dir=./apps/x",
|
||||
"dir=apps/x/",
|
||||
"dir!=apps/excluded",
|
||||
"dir=apps/x,name=foo",
|
||||
}
|
||||
for _, sel := range cases {
|
||||
t.Run(sel, func(t *testing.T) {
|
||||
_, err := ParseLabels(sel)
|
||||
assert.NoError(t, err, "expected ParseLabels to accept %q", sel)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirRelativeToRoot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
absDir string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "root itself", absDir: "/abs/root", want: ".", wantOK: true},
|
||||
{name: "nested", absDir: "/abs/root/apps/x", want: "apps/x", wantOK: true},
|
||||
{name: "dir name starting with ..", absDir: "/abs/root/..apps", want: "..apps", wantOK: true},
|
||||
{name: "sibling of root", absDir: "/abs/other", wantOK: false},
|
||||
{name: "parent of root", absDir: "/abs", wantOK: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DirRelativeToRoot("/abs/root", tt.absDir)
|
||||
assert.Equal(t, tt.wantOK, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+73
-3
@@ -154,6 +154,12 @@ type HelmState struct {
|
||||
// (see issue #2770). Set via SetCancelContext; nil falls back to Background.
|
||||
cancelCtx gocontext.Context
|
||||
|
||||
// rootDir is the absolute directory of the top-level helmfile. Set once
|
||||
// by the loader and inherited unchanged by nested states. Used as the
|
||||
// anchor for the "dir" auto-label applied to releases during selector
|
||||
// matching. Set via SetRootDir.
|
||||
rootDir string
|
||||
|
||||
ReleaseSetSpec `yaml:",inline"`
|
||||
|
||||
logger *zap.SugaredLogger
|
||||
@@ -190,6 +196,13 @@ func (st *HelmState) SetKubeconfig(kubeconfig string) {
|
||||
st.kubeconfig = kubeconfig
|
||||
}
|
||||
|
||||
// SetRootDir sets the absolute directory of the top-level helmfile. Used by
|
||||
// the app loader to anchor the "dir" auto-label so it stays stable across
|
||||
// nested states.
|
||||
func (st *HelmState) SetRootDir(rootDir string) {
|
||||
st.rootDir = rootDir
|
||||
}
|
||||
|
||||
// SubHelmfileSpec defines the subhelmfile path and options
|
||||
type SubHelmfileSpec struct {
|
||||
//path or glob pattern for the sub helmfiles
|
||||
@@ -3554,16 +3567,72 @@ func (st *HelmState) GetReleasesWithLabels() []ReleaseSpec {
|
||||
return rs
|
||||
}
|
||||
|
||||
// reservedLabelWarnings reports user-defined "dir" labels, which are shadowed
|
||||
// by the auto-populated value at match time. Scheduled to become an error.
|
||||
func (st *HelmState) reservedLabelWarnings() []string {
|
||||
var warnings []string
|
||||
if _, ok := st.CommonLabels[DirLabel]; ok {
|
||||
warnings = append(warnings, fmt.Sprintf("commonLabels uses reserved key %q (auto-populated for dir-based filtering); it is ignored for selectors and will be rejected in a future release, rename it", DirLabel))
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(st.Templates)) {
|
||||
if _, ok := st.Templates[name].Labels[DirLabel]; ok {
|
||||
warnings = append(warnings, fmt.Sprintf("template %q uses reserved label key %q (auto-populated for dir-based filtering); it is ignored for selectors and will be rejected in a future release, rename it", name, DirLabel))
|
||||
}
|
||||
}
|
||||
for _, r := range st.Releases {
|
||||
if _, ok := r.Labels[DirLabel]; ok {
|
||||
warnings = append(warnings, fmt.Sprintf("release %q uses reserved label key %q (auto-populated for dir-based filtering); it is ignored for selectors and will be rejected in a future release, rename it", r.Name, DirLabel))
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
// dirLabel returns the auto-populated "dir" label value: basePath relative
|
||||
// to rootDir in normalized slash form, or "" when either is unset or basePath
|
||||
// escapes rootDir via "..". Releases from such states (remote helmfiles,
|
||||
// outside-root sub-helmfiles loaded via `helmfiles: ../shared/...`) carry no
|
||||
// dir label and do not participate in dir-based filtering. basePath may be
|
||||
// "." in single-file mode; the caller has chdir'd to the state's directory
|
||||
// by then so filepath.Abs returns the correct absolute path.
|
||||
func (st *HelmState) dirLabel() string {
|
||||
if st.rootDir == "" || st.basePath == "" {
|
||||
return ""
|
||||
}
|
||||
base := st.basePath
|
||||
if !filepath.IsAbs(base) {
|
||||
abs, err := st.absPath(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base = abs
|
||||
}
|
||||
rel, ok := DirRelativeToRoot(st.rootDir, base)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// absPath resolves p against the state's filesystem abstraction so paths
|
||||
// like "." land in the active state directory both in production (where
|
||||
// within() has chdir'd) and in tests (which simulate CWD via a fake FS).
|
||||
func (st *HelmState) absPath(p string) (string, error) {
|
||||
if st.fs != nil && st.fs.Abs != nil {
|
||||
return st.fs.Abs(p)
|
||||
}
|
||||
return filepath.Abs(p)
|
||||
}
|
||||
|
||||
func (st *HelmState) SelectReleases(includeTransitiveNeeds bool) ([]Release, error) {
|
||||
values := st.Values()
|
||||
rs, err := markExcludedReleases(st.Releases, st.Selectors, values, includeTransitiveNeeds)
|
||||
rs, err := markExcludedReleases(st.Releases, st.Selectors, values, st.dirLabel(), includeTransitiveNeeds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func markExcludedReleases(releases []ReleaseSpec, selectors []string, values map[string]any, includeTransitiveNeeds bool) ([]Release, error) {
|
||||
func markExcludedReleases(releases []ReleaseSpec, selectors []string, values map[string]any, dirLabel string, includeTransitiveNeeds bool) ([]Release, error) {
|
||||
var filteredReleases []Release
|
||||
filters := []ReleaseFilter{}
|
||||
for _, label := range selectors {
|
||||
@@ -3575,8 +3644,9 @@ func markExcludedReleases(releases []ReleaseSpec, selectors []string, values map
|
||||
}
|
||||
for _, r := range releases {
|
||||
var filterMatch bool
|
||||
matchTarget := withDirLabel(r, dirLabel)
|
||||
for _, f := range filters {
|
||||
if f.Match(r) {
|
||||
if f.Match(matchTarget) {
|
||||
filterMatch = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -7153,3 +7153,109 @@ func TestResolveOCIAdhocDepChart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmState_dirLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rootDir string
|
||||
basePath string
|
||||
want string
|
||||
}{
|
||||
{name: "both empty", rootDir: "", basePath: "", want: ""},
|
||||
{name: "rootDir empty", rootDir: "", basePath: "/abs/apps/x", want: ""},
|
||||
{name: "basePath empty", rootDir: "/abs", basePath: "", want: ""},
|
||||
{name: "equal yields .", rootDir: "/abs/root", basePath: "/abs/root", want: "."},
|
||||
{name: "nested under root", rootDir: "/abs/root", basePath: "/abs/root/apps/x", want: "apps/x"},
|
||||
{name: "deeply nested", rootDir: "/abs/root", basePath: "/abs/root/apps/x/sub", want: "apps/x/sub"},
|
||||
{name: "escapes root via ..", rootDir: "/abs/root", basePath: "/abs/other", want: ""},
|
||||
{name: "dir name starting with .. stays inside root", rootDir: "/abs/root", basePath: "/abs/root/..apps", want: "..apps"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
st := &HelmState{rootDir: tt.rootDir, basePath: tt.basePath}
|
||||
assert.Equal(t, tt.want, st.dirLabel())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmState_reservedLabelWarnings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state *HelmState
|
||||
wantWarnings int
|
||||
}{
|
||||
{
|
||||
name: "no labels",
|
||||
state: &HelmState{},
|
||||
},
|
||||
{
|
||||
name: "commonLabels uses dir",
|
||||
state: &HelmState{
|
||||
CommonLabels: map[string]string{DirLabel: "backend"},
|
||||
},
|
||||
wantWarnings: 1,
|
||||
},
|
||||
{
|
||||
name: "release labels use dir",
|
||||
state: &HelmState{
|
||||
Releases: []ReleaseSpec{
|
||||
{Name: "foo", Labels: map[string]string{DirLabel: "backend"}},
|
||||
{Name: "bar", Labels: map[string]string{DirLabel: "frontend"}},
|
||||
},
|
||||
},
|
||||
wantWarnings: 2,
|
||||
},
|
||||
{
|
||||
name: "template labels use dir",
|
||||
state: &HelmState{
|
||||
Templates: map[string]TemplateSpec{
|
||||
"default": {ReleaseSpec: ReleaseSpec{Labels: map[string]string{DirLabel: "backend"}}},
|
||||
},
|
||||
},
|
||||
wantWarnings: 1,
|
||||
},
|
||||
{
|
||||
name: "unrelated labels are fine",
|
||||
state: &HelmState{
|
||||
CommonLabels: map[string]string{"tier": "backend"},
|
||||
Releases: []ReleaseSpec{
|
||||
{Name: "foo", Labels: map[string]string{"team": "data"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
warnings := tt.state.reservedLabelWarnings()
|
||||
assert.Len(t, warnings, tt.wantWarnings)
|
||||
for _, w := range warnings {
|
||||
assert.Contains(t, w, "will be rejected in a future release")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkExcludedReleases_UserDirLabelIsShadowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirLabel string
|
||||
selector string
|
||||
wantSelected bool
|
||||
}{
|
||||
{name: "no auto label: user value does not match dir=", dirLabel: "", selector: "dir=apps", wantSelected: false},
|
||||
{name: "no auto label: user value does not match dir!=", dirLabel: "", selector: "dir!=apps", wantSelected: true},
|
||||
{name: "auto label wins over user value for dir=", dirLabel: "infra", selector: "dir=apps", wantSelected: false},
|
||||
{name: "auto label is matched", dirLabel: "infra", selector: "dir=infra", wantSelected: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
releases := []ReleaseSpec{{Name: "foo", Labels: map[string]string{DirLabel: "apps/foo"}}}
|
||||
|
||||
got, err := markExcludedReleases(releases, []string{tt.selector}, nil, tt.dirLabel, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, tt.wantSelected, !got[0].Filtered)
|
||||
assert.Equal(t, "apps/foo", got[0].Labels[DirLabel], "user-facing labels stay untouched")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user