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>
This commit is contained in:
Dominik Schmidt 2026-05-22 17:20:52 +02:00
parent 2a1574b383
commit ad45f0a1b9
12 changed files with 1168 additions and 12 deletions

View File

@ -4,6 +4,8 @@ import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -81,6 +83,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); err != nil {
return nil, fmt.Errorf("registering selector flag completion: %w", err)
}
flags.ParseErrorsAllowlist.UnknownFlags = true
globalImpl := config.NewGlobalImpl(globalConfig)
@ -120,6 +126,62 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
return cmd, nil
}
// 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 `=`.
// Other selector keys/values are too open-ended to enumerate, so the function
// suppresses default file completion in that case.
func selectorFlagCompletion(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
const dirPrefix = "dir="
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 := strings.TrimPrefix(last, dirPrefix)
var scanDir, matchPrefix string
switch {
case partial == "":
scanDir, matchPrefix = ".", ""
case strings.HasSuffix(partial, "/"):
scanDir, matchPrefix = filepath.FromSlash(partial), ""
default:
scanDir = filepath.FromSlash(filepath.Dir(partial))
matchPrefix = filepath.Base(partial)
}
entries, err := os.ReadDir(scanDir)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
suggestions := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, matchPrefix) {
continue
}
joined := name
if scanDir != "." {
joined = filepath.Join(scanDir, name)
}
suggestions = append(suggestions, leading+dirPrefix+filepath.ToSlash(joined))
}
return suggestions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalOptions) {
fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", app.DefaultHelmBinary, "Path to the helm binary")
fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", app.DefaultKustomizeBinary, "Path to the kustomize binary")
@ -146,7 +208,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" selector key is special: it matches by directory prefix against the location of the defining helmfile, relative to the root helmfile. Used as "--selector dir=apps/opencloud", it ALSO short-circuits sub-helmfile traversal so non-matching branches are not parsed or templated. The negative form "dir!=apps/opencloud" filters releases post-load but does not skip traversal.`)
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.`)

View File

@ -2,8 +2,11 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/helmfile/helmfile/pkg/config"
@ -70,3 +73,66 @@ func TestToCLIError(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(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"} {
assert.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
assert.NoError(t, os.WriteFile(filepath.Join(root, "helmfile.yaml"), []byte("releases: []"), 0o644))
prev, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, os.Chdir(root))
t.Cleanup(func() { _ = os.Chdir(prev) })
suggestions, _ := selectorFlagCompletion(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"} {
assert.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
prev, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, os.Chdir(root))
t.Cleanup(func() { _ = os.Chdir(prev) })
suggestions, _ := selectorFlagCompletion(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()
assert.NoError(t, os.MkdirAll(filepath.Join(root, "apps"), 0o755))
prev, err := os.Getwd()
assert.NoError(t, err)
assert.NoError(t, os.Chdir(root))
t.Cleanup(func() { _ = os.Chdir(prev) })
suggestions, _ := selectorFlagCompletion(nil, nil, "name=foo,dir=")
assert.ElementsMatch(t, []string{"name=foo,dir=apps"}, suggestions)
}
func TestSelectorFlagCompletion_NonexistentDirReturnsNoSuggestions(t *testing.T) {
suggestions, dir := selectorFlagCompletion(nil, nil, "dir=/does/not/exist/")
assert.Nil(t, suggestions)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, dir)
}

View File

@ -58,7 +58,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" selector key is special: it matches by directory prefix against the location of the defining helmfile, relative to the root helmfile. Used as "--selector dir=apps/opencloud", it ALSO short-circuits sub-helmfile traversal so non-matching branches are not parsed or templated. The negative form "dir!=apps/opencloud" filters releases post-load but does not skip traversal.
--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).

View File

@ -213,6 +213,22 @@ 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 helmfile (the one passed via `-f` or auto-discovered in the current directory). 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 that cannot possibly contain 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: helmfile will refuse to load any state whose `commonLabels` or per-release `labels` declares a key named `dir`, since the auto-populated value would silently shadow it.
#### 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.
* 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=…` does not apply to that branch.
* 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.
* 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.

View File

@ -61,6 +61,9 @@ type App struct {
helms map[helmKey]helmexec.Interface
helmsMutex sync.Mutex
rootHelmfileDir string
rootHelmfileDirResolved bool
ctx goContext.Context
}
@ -1035,6 +1038,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
@ -1101,6 +1105,9 @@ func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converg
// It returns true if any nested helmfile successfully found matching releases,
// which is used to update the caller's noMatchInHelmfiles tracking.
func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) (bool, error) {
rootDir := st.RootDir()
stateDir := absd
anyMatched := false
for i, m := range st.Helmfiles {
if subhelmfileSelectorsConflict(a.Selectors, m, a.Logger) {
@ -1108,16 +1115,33 @@ 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 rootDir != "" && !remote.IsRemote(m.Path) {
if dirTargets := extractDirSelectorTargets(effectiveSelectors); len(dirTargets) > 0 {
if !shouldDescendForDirFilter(rootDir, stateDir, m.Path, dirTargets) {
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,
}
if err := a.visitStatesWithContext(m.Path, optsForNestedState, converge, sharedCtx); err != nil {
@ -1256,6 +1280,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)
@ -1468,6 +1493,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; computeRoot uses
// the process CWD at command start, which is the user's intent.
a.resolveRootHelmfileDir()
opts := LoadOpts{
Selectors: a.Selectors,
}

171
pkg/app/dir_filter.go Normal file
View File

@ -0,0 +1,171 @@
package app
import (
"path/filepath"
"strings"
"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() {
if a.rootHelmfileDirResolved {
return
}
a.rootHelmfileDirResolved = true
a.rootHelmfileDir = a.computeRootHelmfileDir()
}
func (a *App) computeRootHelmfileDir() string {
if a.FileOrDir == "" {
wd, err := a.fs.Getwd()
if err != nil {
return ""
}
return wd
}
if remote.IsRemote(a.FileOrDir) {
return ""
}
absPath, err := a.fs.Abs(a.FileOrDir)
if err != nil {
return ""
}
if a.fs.DirectoryExistsAt(absPath) {
return absPath
}
return filepath.Dir(absPath)
}
// 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
}
// shouldDescendForDirFilter returns true when the sub-helmfile at entryPath
// could contain a release matching at least one of the dir-selector groups.
// On any path-computation failure or when entryPath escapes rootDir, returns
// true: skipping a branch we cannot reason about would silently drop work.
func shouldDescendForDirFilter(rootDir, stateDir, entryPath string, groups []dirSelectorGroup) bool {
if len(groups) == 0 {
return true
}
rel, ok := relativeEntryPath(rootDir, stateDir, entryPath)
if !ok {
return true
}
parent := filepath.ToSlash(filepath.Dir(rel))
if parent == "." || parent == "" {
return true
}
for _, g := range groups {
if len(g.targets) == 0 {
return true
}
matched := true
for _, target := range g.targets {
if !couldContainDirMatch(rel, parent, target) {
matched = false
break
}
}
if matched {
return true
}
}
return false
}
// relativeEntryPath resolves entryPath (possibly relative to stateDir, or
// already absolute) into a path relative to rootDir, in slash form. Returns
// (_, false) when the entry escapes rootDir (rel begins with "..") or when
// path computation fails, signaling the caller to descend conservatively.
func relativeEntryPath(rootDir, stateDir, entryPath string) (string, bool) {
abs := entryPath
if !filepath.IsAbs(abs) {
base := stateDir
if !filepath.IsAbs(base) {
absBase, err := filepath.Abs(base)
if err != nil {
return "", false
}
base = absBase
}
abs = filepath.Join(base, entryPath)
}
rel, err := filepath.Rel(rootDir, abs)
if err != nil {
return "", false
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "..") {
return "", false
}
return rel, true
}
// couldContainDirMatch reports whether a release defined under the file at
// entryPath (with parent directory entryParent) could possibly match a
// dir=target selector, given target's directory-prefix semantics:
//
// - target matches anything at or below target/, so descending into a
// branch under target is required.
// - the user's target may live below the current entry's parent (e.g. the
// entry is an aggregator file at apps/helmfile.yaml and target is
// apps/x); in that case descending may reach it.
func couldContainDirMatch(entryPath, entryParent, target string) bool {
if entryPath == target || entryParent == target {
return true
}
return strings.HasPrefix(entryPath, target+"/") ||
strings.HasPrefix(target+"/", entryParent+"/")
}

312
pkg/app/dir_filter_test.go Normal file
View File

@ -0,0 +1,312 @@
package app
import (
"path/filepath"
"strings"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"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 TestCouldContainDirMatch(t *testing.T) {
tests := []struct {
name string
entryPath string
entryParent string
target string
want bool
}{
{"entry is inside target subtree", "apps/x/sub/helmfile.yaml", "apps/x/sub", "apps/x", true},
{"target is below entry parent", "apps/helmfile.yaml", "apps", "apps/x/sub", true},
{"target equals entry parent", "apps/x/helmfile.yaml", "apps/x", "apps/x", true},
{"entry parent equals target exactly", "apps/x", "apps", "apps", true},
{"entry is sibling of target", "apps/y/helmfile.yaml", "apps/y", "apps/x", false},
{"name-collision is not a match", "apps/xenial/helmfile.yaml", "apps/xenial", "apps/x", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, couldContainDirMatch(tt.entryPath, tt.entryParent, tt.target))
})
}
}
func TestShouldDescendForDirFilter(t *testing.T) {
rootDir, err := filepath.Abs("/workspace/proj")
assert.NoError(t, err)
stateDir := filepath.Join(rootDir, "opendesk")
tests := []struct {
name string
entry string
groups []dirSelectorGroup
wantSkip bool
}{
{
name: "no groups means caller skipped check; treat as descend",
entry: "helmfile/apps/openproject/helmfile-child.yaml.gotmpl",
groups: nil,
},
{
name: "target inside entry subtree → descend",
entry: "helmfile/apps/openproject/helmfile-child.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/openproject"}}},
},
{
name: "sibling service → skip",
entry: "helmfile/apps/xwiki/helmfile-child.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/openproject"}}},
wantSkip: true,
},
{
name: "entry is ancestor of target → descend",
entry: "helmfile_generic.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/openproject"}}},
},
{
name: "group with no targets is permissive",
entry: "helmfile/apps/xwiki/helmfile-child.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: nil}, {targets: []string{"opendesk/helmfile/apps/openproject"}}},
},
{
name: "multiple targets in one group: all must allow",
entry: "helmfile/apps/openproject/helmfile-child.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/openproject", "opendesk/helmfile/apps/xwiki"}}},
wantSkip: true,
},
{
name: "OR across groups: either matches descends",
entry: "helmfile/apps/openproject/helmfile-child.yaml.gotmpl",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/xwiki"}}, {targets: []string{"opendesk/helmfile/apps/openproject"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldDescendForDirFilter(rootDir, stateDir, tt.entry, tt.groups)
assert.Equal(t, !tt.wantSkip, got)
})
}
}
// 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
`,
}
testFs := testhelper.NewTestFs(files)
testFs.Cwd = "/workspace"
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
assert.NoError(t, err)
app := &App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: newAppTestLogger(),
valsRuntime: valsRuntime,
FileOrDir: "/workspace/helmfile.yaml",
Selectors: []string{"dir=opendesk/apps/openproject"},
}
app = injectFs(app, testFs)
expectNoCallsToHelm(app)
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
`,
}
testFs := testhelper.NewTestFs(files)
testFs.Cwd = "/workspace"
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
assert.NoError(t, err)
app := &App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: newAppTestLogger(),
valsRuntime: valsRuntime,
FileOrDir: "/workspace/root.yaml",
Selectors: []string{"dir=apps/openproject"},
}
app = injectFs(app, testFs)
expectNoCallsToHelm(app)
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) {
testFs := testhelper.NewTestFs(files)
testFs.Cwd = "/workspace"
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
assert.NoError(t, err)
app := &App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: newAppTestLogger(),
valsRuntime: valsRuntime,
FileOrDir: "/workspace/root.yaml",
Selectors: []string{selector},
}
app = injectFs(app, testFs)
expectNoCallsToHelm(app)
err = app.ForEachState(Noop, false, SetFilter(true))
assert.Error(t, err, "dir=. and equivalents should be rejected at parse time")
})
}
}

View File

@ -223,6 +223,10 @@ func (c *StateCreator) ParseAndLoad(content []byte, baseDir, file string, envNam
}
state.RenderedValues = vals
if err := state.validateReservedLabels(); err != nil {
return nil, err
}
return state, nil
}

View File

@ -2,6 +2,8 @@ package state
import (
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
)
@ -25,6 +27,12 @@ func (l LabelFilter) Match(r ReleaseSpec) bool {
for _, element := range l.positiveLabels {
k := element[0]
v := element[1]
if k == DirLabel {
if !matchDirPrefix(r.Labels[DirLabel], v) {
return false
}
continue
}
if rVal, ok := r.Labels[k]; !ok {
return false
} else if rVal != v {
@ -37,6 +45,12 @@ func (l LabelFilter) Match(r ReleaseSpec) bool {
for _, element := range l.negativeLabels {
k := element[0]
v := element[1]
if k == DirLabel {
if matchDirPrefix(r.Labels[DirLabel], v) {
return false
}
continue
}
if rVal, ok := r.Labels[k]; !ok {
} else if rVal == v {
@ -47,6 +61,74 @@ func (l LabelFilter) Match(r ReleaseSpec) bool {
return true
}
// 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"
// injectLabel returns a copy of r with the Labels map extended by a single
// key/value. Used to attach state-derived labels (currently only "dir") for
// matching, without mutating the source release or surfacing the value in
// user-facing label output.
func injectLabel(r ReleaseSpec, key, value string) ReleaseSpec {
cloned := r
cloned.Labels = make(map[string]string, len(r.Labels)+1)
for k, v := range r.Labels {
cloned.Labels[k] = v
}
cloned.Labels[key] = value
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; `dir=.` selects only releases defined at the
// root helmfile itself. 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
}
dirLabel = NormalizeDirValue(dirLabel)
value = NormalizeDirValue(value)
if dirLabel == value {
return true
}
return strings.HasPrefix(dirLabel, value+"/")
}
// 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.
@ -93,11 +175,22 @@ 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 a[0] != b[0] {
continue
}
if a[0] == DirLabel {
if !dirsCompatible(a[1], b[1]) {
return false
}
continue
}
if a[1] != b[1] {
return false
}
}
@ -105,6 +198,18 @@ func (l LabelFilter) positiveLabelsCompatibleWith(other LabelFilter) bool {
return true
}
// 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)
if a == b {
return true
}
return strings.HasPrefix(a, b+"/") || strings.HasPrefix(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_\.\/\+-]+$`)
@ -120,9 +225,19 @@ func ParseLabels(l string) (LabelFilter, error) {
for _, label := range labels {
if match := reLabelMismatch.MatchString(label); match {
kv := strings.Split(label, "!=")
if kv[0] == DirLabel {
if err := validateDirSelectorValue(kv[1]); err != nil {
return lf, err
}
}
lf.negativeLabels = append(lf.negativeLabels, kv)
} else if match := reLabelMatch.MatchString(label); match {
kv := strings.Split(label, "=")
if kv[0] == DirLabel {
if err := validateDirSelectorValue(kv[1]); err != nil {
return lf, err
}
}
lf.positiveLabels = append(lf.positiveLabels, kv)
} else {
return lf, fmt.Errorf("malformed label: %s. Expected label in form k=v or k!=v", label)
@ -130,3 +245,23 @@ func ParseLabels(l string) (LabelFilter, error) {
}
return lf, err
}
// 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 n == ".." || strings.HasPrefix(n, "../") {
return fmt.Errorf("dir= selector value %q escapes the root helmfile directory", v)
}
return nil
}

View File

@ -169,3 +169,207 @@ 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)
})
}
}

View File

@ -136,6 +136,12 @@ type HelmState struct {
basePath string
FilePath string
// 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
@ -156,6 +162,19 @@ 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
}
// RootDir returns the absolute directory of the top-level helmfile, or "" if
// it has not been set (e.g. for remote roots).
func (st *HelmState) RootDir() string {
return st.rootDir
}
// SubHelmfileSpec defines the subhelmfile path and options
type SubHelmfileSpec struct {
//path or glob pattern for the sub helmfiles
@ -3181,16 +3200,70 @@ func (st *HelmState) GetReleasesWithLabels() []ReleaseSpec {
return rs
}
// validateReservedLabels rejects user-defined labels that collide with keys
// the selector machinery auto-populates. Without this check, a user-defined
// "dir" label would be silently shadowed at match time.
func (st *HelmState) validateReservedLabels() error {
if _, ok := st.CommonLabels[DirLabel]; ok {
return fmt.Errorf("commonLabels uses reserved key %q (auto-populated for dir-based filtering); rename it", DirLabel)
}
for _, r := range st.Releases {
if _, ok := r.Labels[DirLabel]; ok {
return fmt.Errorf("release %q uses reserved label key %q (auto-populated for dir-based filtering); rename it", r.Name, DirLabel)
}
}
return nil
}
// 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, err := filepath.Rel(st.rootDir, base)
if err != nil {
return ""
}
if strings.HasPrefix(rel, "..") {
return ""
}
return NormalizeDirValue(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 {
@ -3202,8 +3275,12 @@ func markExcludedReleases(releases []ReleaseSpec, selectors []string, values map
}
for _, r := range releases {
var filterMatch bool
matchTarget := r
if dirLabel != "" {
matchTarget = injectLabel(r, DirLabel, dirLabel)
}
for _, f := range filters {
if f.Match(r) {
if f.Match(matchTarget) {
filterMatch = true
break
}

View File

@ -6155,3 +6155,80 @@ 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: ""},
}
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_validateReservedLabels(t *testing.T) {
tests := []struct {
name string
state *HelmState
wantErr bool
}{
{
name: "no labels",
state: &HelmState{},
},
{
name: "commonLabels uses dir",
state: &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
CommonLabels: map[string]string{DirLabel: "backend"},
},
},
wantErr: true,
},
{
name: "release labels use dir",
state: &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
Releases: []ReleaseSpec{
{Name: "foo", Labels: map[string]string{DirLabel: "backend"}},
},
},
},
wantErr: true,
},
{
name: "unrelated labels are fine",
state: &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
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) {
err := tt.state.validateReservedLabels()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}