Files
helmfile/pkg/state/storage.go
T
hoppla20andClaude Opus 5 daa43e1081 feat(remote): support wildcards in remote values/secrets file selectors (#2787)
Extend git-getter style remote references (git::, s3::, https://, ...) used
in release and environment values/secrets to support glob patterns in the
file selector, e.g.:

  git::https://github.com/org/repo.git@config/*.yaml?ref=main

Remote.Fetch already downloads the whole repository/directory and joins the
"@<file>" selector onto it verbatim, so a wildcard selector already survives
untouched; the only missing piece was that Storage.resolveFile checked the
result with FileExistsAt instead of expanding it as a glob.

- pkg/remote/remote.go: add HasGlobPattern to detect a wildcard in the file
  selector (checking only the selector, not the raw URL, so "?ref=main" and
  IPv6/placeholder brackets elsewhere are not mistaken for wildcards). Reject
  wildcards in Fetch for getter shapes that can never expand one: plain
  http(s)/s3 (single object), non-archive forced s3:: (single object), and
  any getter used without an explicit "@" selector (Dir/File cannot be
  reliably split from the pattern otherwise).
- pkg/state/storage.go: resolveFile now globs the fetched cache path with the
  same st.fs.Glob/sort.Strings used for local values-file globs when the
  selector is a pattern, filtering out directory matches. A literal, existing
  path is still resolved directly. Fixed an existing err-shadowing hazard in
  the same code path while restructuring it.
- docs/environments.md: document the new wildcard support, its syntax
  (filepath.Match, no recursive **), and its getter/selector requirements.
- Tests: new cases in pkg/remote/remote_test.go (glob detection, Fetch
  wildcard expansion and cache-key sharing, rejected getter shapes) and
  pkg/state/storage_test.go (a real end-to-end wildcard fetch against a
  pinned upstream tag, plus a hermetic fan-out/sorting/missing-file test with
  no network access).

Release values/secrets keep their existing "glob patterns ... not supported
yet" restriction for multi-file matches (pkg/state/state.go), unchanged by
this commit and applying equally to local and remote globs. helmfiles: entries
are out of scope.

Signed-off-by: Vincent Cui <privat@vincentcui.de>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 09:16:26 +08:00

209 lines
6.6 KiB
Go

package state
import (
"errors"
"fmt"
"net/url"
"path/filepath"
"sort"
"strings"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/remote"
)
type Storage struct {
logger *zap.SugaredLogger
FilePath string
basePath string
fs *filesystem.FileSystem
}
func NewStorage(forFile string, logger *zap.SugaredLogger, fs *filesystem.FileSystem) *Storage {
return &Storage{
FilePath: forFile,
basePath: filepath.Dir(forFile),
logger: logger,
fs: fs,
}
}
type resolveFileConfig struct {
IgnoreMissingGitBranch bool
}
type resolveFileOption func(*resolveFileConfig)
func ignoreMissingGitBranch(v bool) func(c *resolveFileConfig) {
return func(c *resolveFileConfig) {
c.IgnoreMissingGitBranch = v
}
}
func (st *Storage) resolveFile(missingFileHandler *string, tpe, path string, opts ...resolveFileOption) ([]string, bool, error) {
title := fmt.Sprintf("%s file", tpe)
var (
files []string
err error
conf resolveFileConfig
)
for _, o := range opts {
o(&conf)
}
if remote.IsRemote(path) {
r := remote.NewRemote(st.logger, "", st.fs)
// Named fetchErr, not err: err is declared in the outer scope above and
// checked again after this if-block. Reusing that name here would shadow
// it with a new, block-local variable (fetchedFilePath is new, so ":="
// can't reuse the outer err), silently discarding any fetch error that
// isn't returned or explicitly ignored below.
fetchedFilePath, fetchErr := r.Fetch(path, "values")
if fetchErr != nil {
// https://github.com/helmfile/helmfile/issues/392
if conf.IgnoreMissingGitBranch && strings.Contains(fetchErr.Error(), "' did not match any file(s) known to git") {
st.logger.Debugf("Ignored missing git branch error: %v", fetchErr)
} else {
return nil, false, fetchErr
}
}
switch {
case fetchedFilePath == "":
// Fetch failed and the failure was ignored above (ignoreMissingGitBranch).
// Leave files empty and let the missing file handler below decide.
case st.fs.FileExistsAt(fetchedFilePath):
// A literal, existing file. Checked before glob-expanding so that a
// file name which happens to contain "[" or "?" (valid in
// filepath.Match patterns but also valid in plain file names) still
// resolves to itself when it exists.
files = []string{fetchedFilePath}
case remote.HasGlobPattern(path):
// Fetch joins the "@<file>" selector onto the local cache directory
// verbatim (see Remote.Fetch), so a wildcard selector is expanded
// here, against the fetched directory, using the same glob syntax as
// local values files (st.ExpandPaths / filepath.Match).
//
// st.ExpandPaths itself is not reused: its normalizePath would
// incorrectly prefix the helmfile's basePath onto this
// already-absolute cache path whenever remote.CacheDir() falls back
// to the relative ".helmfile" directory.
matches, globErr := st.fs.Glob(fetchedFilePath)
if globErr != nil {
// filepath.Glob's only documented error is ErrBadPattern (e.g. an
// unclosed "["). Before wildcard support, a selector containing "["
// was checked with FileExistsAt and simply treated as missing if it
// didn't exist, so a malformed pattern should fall back to the same
// missingFileHandler-driven "no matches" handling below rather than
// becoming an unconditional hard error, which would be a regression
// for existing Info/Warn/Debug users referencing such a file.
if !errors.Is(globErr, filepath.ErrBadPattern) {
return nil, false, fmt.Errorf("failed processing %s: %v", path, globErr)
}
st.logger.Debugf("Treating invalid glob pattern as no match for %s: %v", path, globErr)
}
sort.Strings(matches)
for _, m := range matches {
// Keep the same "regular files only" contract as the non-glob
// case above: a glob can also match directories.
if st.fs.FileExistsAt(m) {
files = append(files, m)
}
}
}
} else {
files, err = st.ExpandPaths(path)
}
if err != nil {
return nil, false, err
}
var handlerId string
if missingFileHandler != nil {
handlerId = *missingFileHandler
} else {
handlerId = MissingFileHandlerError
}
if len(files) == 0 {
switch handlerId {
case MissingFileHandlerError:
return nil, false, fmt.Errorf("%s matching \"%s\" does not exist in \"%s\"", title, path, st.basePath)
case MissingFileHandlerWarn:
st.logger.Warnf("skipping missing %s matching \"%s\"", title, path)
return nil, true, nil
case MissingFileHandlerInfo:
st.logger.Infof("skipping missing %s matching \"%s\"", title, path)
return nil, true, nil
case MissingFileHandlerDebug:
st.logger.Debugf("skipping missing %s matching \"%s\"", title, path)
return nil, true, nil
default:
available := []string{
MissingFileHandlerError,
MissingFileHandlerWarn,
MissingFileHandlerInfo,
MissingFileHandlerDebug,
}
return nil, false, fmt.Errorf("invalid missing file handler \"%s\" while processing \"%s\" in \"%s\": it must be one of %s", handlerId, path, st.FilePath, available)
}
}
return files, false, nil
}
func (st *Storage) ExpandPaths(globPattern string) ([]string, error) {
result := []string{}
absPathPattern := st.normalizePath(globPattern)
matches, err := st.fs.Glob(absPathPattern)
if err != nil {
return nil, fmt.Errorf("failed processing %s: %v", globPattern, err)
}
sort.Strings(matches)
result = append(result, matches...)
return result, nil
}
// normalizes relative path to absolute one
func (st *Storage) normalizePath(path string) string {
u, _ := url.Parse(path)
if u != nil && (u.Scheme != "" || filepath.IsAbs(path)) {
return path
}
// Avoid double-prefixing when the path already starts with basePath.
// This can happen when normalizePath is called multiple times on the same path
// (e.g. once in generateVanillaValuesFiles and again in resolveFile/ExpandPaths).
if st.basePath != "" && st.basePath != "." && strings.HasPrefix(path, st.basePath+string(filepath.Separator)) {
return path
}
return st.JoinBase(path)
}
// JoinBase returns an absolute path in the form basePath/relative
// Helm's setFiles command does not support unescaped filepath separators (\) on Windows.
// Instead, it requires double backslashes (\\) as filepath separators.
// See https://github.com/helm/helm/issues/9537
func (st *Storage) JoinBase(relPath string) string {
path := filepath.Join(st.basePath, relPath)
return path
}
func (st *Storage) normalizeSetFilePath(path, goos string) string {
normalizedPath := st.normalizePath(path)
if goos == "windows" {
return strings.ReplaceAll(normalizedPath, "\\", "\\\\")
}
return normalizedPath
}