mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-01 17:01:29 +02:00
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>
209 lines
6.6 KiB
Go
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
|
|
}
|