mirror of
https://github.com/helmfile/helmfile.git
synced 2026-10-18 08:46:25 +02:00
Merge branch 'main' into copilot/update-helm-v4-ci
This commit is contained in:
+266
-101
@@ -586,7 +586,7 @@ func (a *App) dag(r *Run) error {
|
||||
}
|
||||
|
||||
func (a *App) ListReleases(c ListConfigProvider) error {
|
||||
var releases []*HelmRelease
|
||||
releasesChan := make(chan []*HelmRelease, 100)
|
||||
|
||||
err := a.ForEachState(func(run *Run) (_ bool, errs []error) {
|
||||
var stateReleases []*HelmRelease
|
||||
@@ -612,15 +612,33 @@ func (a *App) ListReleases(c ListConfigProvider) error {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
releases = append(releases, stateReleases...)
|
||||
if len(stateReleases) > 0 {
|
||||
releasesChan <- stateReleases
|
||||
}
|
||||
|
||||
return
|
||||
}, false, SetFilter(true))
|
||||
|
||||
close(releasesChan)
|
||||
|
||||
// Collect all releases from channel
|
||||
var releases []*HelmRelease
|
||||
for rels := range releasesChan {
|
||||
releases = append(releases, rels...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sort releases to ensure deterministic output order regardless of parallel execution
|
||||
sort.Slice(releases, func(i, j int) bool {
|
||||
if releases[i].Namespace != releases[j].Namespace {
|
||||
return releases[i].Namespace < releases[j].Namespace
|
||||
}
|
||||
return releases[i].Name < releases[j].Name
|
||||
})
|
||||
|
||||
if c.Output() == "json" {
|
||||
err = FormatAsJson(releases)
|
||||
} else {
|
||||
@@ -743,6 +761,10 @@ func (a *App) visitStateFiles(fileOrDir string, opts LoadOpts, do func(string, s
|
||||
}
|
||||
|
||||
func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.HelmState, error) {
|
||||
return a.loadDesiredStateFromYamlWithBaseDir(file, "", opts...)
|
||||
}
|
||||
|
||||
func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, opts ...LoadOpts) (*state.HelmState, error) {
|
||||
var op LoadOpts
|
||||
if len(opts) > 0 {
|
||||
op = opts[0]
|
||||
@@ -755,6 +777,7 @@ func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.He
|
||||
chart: a.Chart,
|
||||
logger: a.Logger,
|
||||
remote: a.remote,
|
||||
baseDir: baseDir,
|
||||
|
||||
overrideKubeContext: a.OverrideKubeContext,
|
||||
overrideHelmBinary: a.OverrideHelmBinary,
|
||||
@@ -793,6 +816,9 @@ func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) {
|
||||
}
|
||||
|
||||
bin := st.DefaultHelmBinary
|
||||
if bin == "" {
|
||||
bin = state.DefaultHelmBinary
|
||||
}
|
||||
kubeconfig := a.Kubeconfig
|
||||
kubectx := st.HelmDefaults.KubeContext
|
||||
|
||||
@@ -814,117 +840,256 @@ func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) {
|
||||
}
|
||||
|
||||
func (a *App) visitStates(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error)) error {
|
||||
noMatchInHelmfiles := true
|
||||
return a.visitStatesWithContext(fileOrDir, defOpts, converge, nil)
|
||||
}
|
||||
|
||||
err := a.visitStateFiles(fileOrDir, defOpts, func(f, d string) (retErr error) {
|
||||
opts := defOpts.DeepCopy()
|
||||
func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context, errChan chan error, matchChan chan bool) {
|
||||
var file string
|
||||
var dir string
|
||||
if a.fs.DirectoryExistsAt(relPath) {
|
||||
file = relPath
|
||||
dir = relPath
|
||||
} else {
|
||||
file = filepath.Base(relPath)
|
||||
dir = filepath.Dir(relPath)
|
||||
}
|
||||
|
||||
if opts.CalleePath == "" {
|
||||
opts.CalleePath = f
|
||||
absd, errAbsDir := a.fs.Abs(dir)
|
||||
if errAbsDir != nil {
|
||||
errChan <- errAbsDir
|
||||
return
|
||||
}
|
||||
|
||||
opts := defOpts.DeepCopy()
|
||||
if opts.CalleePath == "" {
|
||||
opts.CalleePath = file
|
||||
}
|
||||
|
||||
st, err := a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts)
|
||||
if err != nil {
|
||||
switch stateLoadErr := err.(type) {
|
||||
case *state.StateLoadError:
|
||||
switch stateLoadErr.Cause.(type) {
|
||||
case *state.UndefinedEnvError:
|
||||
return
|
||||
default:
|
||||
errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
|
||||
st.Selectors = opts.Selectors
|
||||
|
||||
if len(st.Helmfiles) > 0 && !opts.Reverse {
|
||||
if err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx); err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
templated, err := st.ExecuteTemplates()
|
||||
if err != nil {
|
||||
errChan <- appError(fmt.Sprintf("in %s/%s: failed executing release templates in \"%s\"", dir, file, file), err)
|
||||
return
|
||||
}
|
||||
|
||||
var errs []error
|
||||
CleanWaitGroup.Add(1)
|
||||
var cleanErr error
|
||||
defer func() {
|
||||
defer CleanWaitGroup.Done()
|
||||
cleanErr = context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
|
||||
}()
|
||||
|
||||
processed, errs := converge(templated)
|
||||
|
||||
if len(errs) > 0 {
|
||||
errChan <- errs[0]
|
||||
return
|
||||
}
|
||||
if cleanErr != nil {
|
||||
errChan <- cleanErr
|
||||
return
|
||||
}
|
||||
|
||||
// Report if this file had matching releases
|
||||
if processed {
|
||||
matchChan <- true
|
||||
}
|
||||
|
||||
if opts.Reverse && len(st.Helmfiles) > 0 {
|
||||
if err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error {
|
||||
for i, m := range st.Helmfiles {
|
||||
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
|
||||
}
|
||||
|
||||
st, err := a.loadDesiredStateFromYaml(f, opts)
|
||||
if err := a.visitStatesWithContext(m.Path, optsForNestedState, converge, sharedCtx); err != nil {
|
||||
switch err.(type) {
|
||||
case *NoMatchingHelmfileError:
|
||||
default:
|
||||
return appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles}
|
||||
func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error {
|
||||
noMatchInHelmfiles := true
|
||||
|
||||
if err != nil {
|
||||
switch stateLoadErr := err.(type) {
|
||||
// Addresses https://github.com/roboll/helmfile/issues/279
|
||||
case *state.StateLoadError:
|
||||
switch stateLoadErr.Cause.(type) {
|
||||
case *state.UndefinedEnvError:
|
||||
return nil
|
||||
desiredStateFiles, err := a.findDesiredStateFiles(fileOrDir, defOpts)
|
||||
|
||||
if len(desiredStateFiles) > 1 {
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(desiredStateFiles))
|
||||
matchChan := make(chan bool, len(desiredStateFiles))
|
||||
|
||||
for _, relPath := range desiredStateFiles {
|
||||
wg.Add(1)
|
||||
go func(relPath string) {
|
||||
defer wg.Done()
|
||||
a.processStateFileParallel(relPath, defOpts, converge, sharedCtx, errChan, matchChan)
|
||||
}(relPath)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
close(matchChan)
|
||||
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any files had matching releases
|
||||
for range matchChan {
|
||||
noMatchInHelmfiles = false
|
||||
}
|
||||
} else {
|
||||
// Sequential processing for single file
|
||||
err = a.visitStateFiles(fileOrDir, defOpts, func(f, d string) (retErr error) {
|
||||
opts := defOpts.DeepCopy()
|
||||
|
||||
if opts.CalleePath == "" {
|
||||
opts.CalleePath = f
|
||||
}
|
||||
|
||||
st, err := a.loadDesiredStateFromYaml(f, opts)
|
||||
|
||||
ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles}
|
||||
|
||||
if err != nil {
|
||||
switch stateLoadErr := err.(type) {
|
||||
case *state.StateLoadError:
|
||||
switch stateLoadErr.Cause.(type) {
|
||||
case *state.UndefinedEnvError:
|
||||
return nil
|
||||
default:
|
||||
return ctx.wrapErrs(err)
|
||||
}
|
||||
default:
|
||||
return ctx.wrapErrs(err)
|
||||
}
|
||||
default:
|
||||
return ctx.wrapErrs(err)
|
||||
}
|
||||
}
|
||||
st.Selectors = opts.Selectors
|
||||
st.Selectors = opts.Selectors
|
||||
|
||||
visitSubHelmfiles := func() error {
|
||||
if len(st.Helmfiles) > 0 {
|
||||
noMatchInSubHelmfiles := true
|
||||
for i, m := range st.Helmfiles {
|
||||
optsForNestedState := LoadOpts{
|
||||
CalleePath: filepath.Join(d, f),
|
||||
Environment: m.Environment,
|
||||
Reverse: defOpts.Reverse,
|
||||
RetainValuesFiles: defOpts.RetainValuesFiles,
|
||||
}
|
||||
// assign parent selector to sub helm selector in legacy mode or do not inherit in experimental mode
|
||||
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
|
||||
optsForNestedState.Selectors = opts.Selectors
|
||||
} else {
|
||||
optsForNestedState.Selectors = m.Selectors
|
||||
}
|
||||
visitSubHelmfiles := func() error {
|
||||
if len(st.Helmfiles) > 0 {
|
||||
noMatchInSubHelmfiles := true
|
||||
for i, m := range st.Helmfiles {
|
||||
optsForNestedState := LoadOpts{
|
||||
CalleePath: filepath.Join(d, f),
|
||||
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
|
||||
}
|
||||
|
||||
if err := a.visitStates(m.Path, optsForNestedState, converge); err != nil {
|
||||
switch err.(type) {
|
||||
case *NoMatchingHelmfileError:
|
||||
|
||||
default:
|
||||
return appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
|
||||
if err := a.visitStates(m.Path, optsForNestedState, converge); err != nil {
|
||||
switch err.(type) {
|
||||
case *NoMatchingHelmfileError:
|
||||
default:
|
||||
return appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
|
||||
}
|
||||
} else {
|
||||
noMatchInSubHelmfiles = false
|
||||
}
|
||||
} else {
|
||||
noMatchInSubHelmfiles = false
|
||||
}
|
||||
noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles
|
||||
}
|
||||
noMatchInHelmfiles = noMatchInHelmfiles && noMatchInSubHelmfiles
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.Reverse {
|
||||
err = visitSubHelmfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
templated, tmplErr := st.ExecuteTemplates()
|
||||
if tmplErr != nil {
|
||||
return appError(fmt.Sprintf("failed executing release templates in \"%s\"", f), tmplErr)
|
||||
}
|
||||
|
||||
var (
|
||||
processed bool
|
||||
errs []error
|
||||
)
|
||||
|
||||
CleanWaitGroup.Add(1)
|
||||
defer func() {
|
||||
defer CleanWaitGroup.Done()
|
||||
cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
|
||||
if retErr == nil {
|
||||
retErr = cleanErr
|
||||
} else if cleanErr != nil {
|
||||
a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr)
|
||||
}
|
||||
}()
|
||||
|
||||
processed, errs = converge(templated)
|
||||
|
||||
noMatchInHelmfiles = noMatchInHelmfiles && !processed
|
||||
|
||||
if opts.Reverse {
|
||||
err = visitSubHelmfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.Reverse {
|
||||
err = visitSubHelmfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
templated, tmplErr := st.ExecuteTemplates()
|
||||
if tmplErr != nil {
|
||||
return appError(fmt.Sprintf("failed executing release templates in \"%s\"", f), tmplErr)
|
||||
}
|
||||
|
||||
var (
|
||||
processed bool
|
||||
errs []error
|
||||
)
|
||||
|
||||
// Ensure every temporary files and directories generated while running
|
||||
// the converge function is clean up before exiting this function in all the three cases below:
|
||||
// - This function returned nil
|
||||
// - This function returned an err
|
||||
// - Helmfile received SIGINT or SIGTERM while running this function
|
||||
// For the last case you also need a signal handler in main.go.
|
||||
// Ideally though, this CleanWaitGroup should gone and be replaced by a context cancellation propagation.
|
||||
// See https://github.com/helmfile/helmfile/pull/418 for more details.
|
||||
CleanWaitGroup.Add(1)
|
||||
defer func() {
|
||||
defer CleanWaitGroup.Done()
|
||||
cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
|
||||
if retErr == nil {
|
||||
retErr = cleanErr
|
||||
} else if cleanErr != nil {
|
||||
a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr)
|
||||
}
|
||||
}()
|
||||
|
||||
processed, errs = converge(templated)
|
||||
|
||||
noMatchInHelmfiles = noMatchInHelmfiles && !processed
|
||||
|
||||
if opts.Reverse {
|
||||
err = visitSubHelmfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -961,18 +1126,18 @@ var (
|
||||
|
||||
func (a *App) ForEachState(do func(*Run) (bool, []error), includeTransitiveNeeds bool, o ...LoadOption) error {
|
||||
ctx := NewContext()
|
||||
err := a.visitStatesWithSelectorsAndRemoteSupport(a.FileOrDir, func(st *state.HelmState) (bool, []error) {
|
||||
err := a.visitStatesWithSelectorsAndRemoteSupportWithContext(a.FileOrDir, func(st *state.HelmState) (bool, []error) {
|
||||
helm, err := a.getHelm(st)
|
||||
if err != nil {
|
||||
return false, []error{err}
|
||||
}
|
||||
|
||||
run, err := NewRun(st, helm, ctx)
|
||||
run, err := NewRun(st, helm, &ctx)
|
||||
if err != nil {
|
||||
return false, []error{err}
|
||||
}
|
||||
return do(run)
|
||||
}, includeTransitiveNeeds, o...)
|
||||
}, includeTransitiveNeeds, &ctx, o...)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1076,7 +1241,7 @@ type Opts struct {
|
||||
DAGEnabled bool
|
||||
}
|
||||
|
||||
func (a *App) visitStatesWithSelectorsAndRemoteSupport(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, opt ...LoadOption) error {
|
||||
func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error {
|
||||
opts := LoadOpts{
|
||||
Selectors: a.Selectors,
|
||||
}
|
||||
@@ -1126,7 +1291,7 @@ func (a *App) visitStatesWithSelectorsAndRemoteSupport(fileOrDir string, converg
|
||||
return f(st)
|
||||
}
|
||||
|
||||
return a.visitStates(fileOrDir, opts, fHelmStatsWithOverrides)
|
||||
return a.visitStatesWithContext(fileOrDir, opts, fHelmStatsWithOverrides, sharedCtx)
|
||||
}
|
||||
|
||||
func processFilteredReleases(st *state.HelmState, converge func(st *state.HelmState) []error, includeTransitiveNeeds bool) (bool, []error) {
|
||||
|
||||
@@ -415,4 +415,28 @@ releases:
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("show diff on changed selected release with reinstall", func(t *testing.T) {
|
||||
check(t, testcase{
|
||||
helmfile: `
|
||||
releases:
|
||||
- name: a
|
||||
chart: incubator/raw
|
||||
namespace: default
|
||||
updateStrategy: reinstallIfForbidden
|
||||
- name: b
|
||||
chart: incubator/raw
|
||||
namespace: default
|
||||
`,
|
||||
selectors: []string{"name=a"},
|
||||
lists: map[exectest.ListKey]string{
|
||||
{Filter: "^a$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
|
||||
foo 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
|
||||
`,
|
||||
},
|
||||
diffed: []exectest.Release{
|
||||
{Name: "a", Flags: []string{"--kube-context", "default", "--namespace", "default", "--reset-values"}},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
goContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/helmfile/helmfile/pkg/state"
|
||||
)
|
||||
|
||||
// TestGetHelmWithEmptyDefaultHelmBinary tests that getHelm properly defaults to "helm"
|
||||
// when st.DefaultHelmBinary is empty. This addresses the issue where base files with
|
||||
// environment secrets would fail with "exec: no command" error.
|
||||
//
|
||||
// Background: When a base file has environment secrets but doesn't specify helmBinary,
|
||||
// the state.DefaultHelmBinary would be empty, causing helmexec.New to be called with
|
||||
// an empty string, which results in "error determining helm version: exec: no command".
|
||||
//
|
||||
// The fix in app.getHelm() ensures that when st.DefaultHelmBinary is empty, it defaults
|
||||
// to state.DefaultHelmBinary ("helm").
|
||||
func TestGetHelmWithEmptyDefaultHelmBinary(t *testing.T) {
|
||||
// Test that app.getHelm() handles empty DefaultHelmBinary correctly by applying a default
|
||||
st := &state.HelmState{
|
||||
ReleaseSetSpec: state.ReleaseSetSpec{
|
||||
DefaultHelmBinary: "", // Empty, as would be the case for base files
|
||||
},
|
||||
}
|
||||
|
||||
logger := newAppTestLogger()
|
||||
app := &App{
|
||||
OverrideHelmBinary: "",
|
||||
OverrideKubeContext: "",
|
||||
Logger: logger,
|
||||
Env: "default",
|
||||
ctx: goContext.Background(),
|
||||
}
|
||||
|
||||
// This should NOT fail because app.getHelm() defaults empty DefaultHelmBinary to "helm"
|
||||
helm, err := app.getHelm(st)
|
||||
|
||||
// Verify that no error occurred - the fix in app.getHelm() prevents the "exec: no command" error
|
||||
require.NoError(t, err, "getHelm should not fail when DefaultHelmBinary is empty (fix should apply default)")
|
||||
|
||||
// Verify that a valid helm execer was returned
|
||||
require.NotNil(t, helm, "getHelm should return a valid helm execer")
|
||||
|
||||
// Verify that the helm version is accessible (confirms the helm binary is valid)
|
||||
version := helm.GetVersion()
|
||||
require.NotNil(t, version, "helm version should be accessible")
|
||||
}
|
||||
+19
-18
@@ -44,17 +44,17 @@ environments:
|
||||
---
|
||||
releases:
|
||||
- name: logging
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: kube-system
|
||||
|
||||
- name: kubernetes-external-secrets
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: kube-system
|
||||
needs:
|
||||
- kube-system/logging
|
||||
|
||||
- name: external-secrets
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: default
|
||||
labels:
|
||||
app: test
|
||||
@@ -62,7 +62,7 @@ releases:
|
||||
- kube-system/kubernetes-external-secrets
|
||||
|
||||
- name: my-release
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: default
|
||||
labels:
|
||||
app: test
|
||||
@@ -72,17 +72,17 @@ releases:
|
||||
|
||||
# Disabled releases are treated as missing
|
||||
- name: disabled
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: kube-system
|
||||
installed: false
|
||||
|
||||
- name: test2
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
needs:
|
||||
- kube-system/disabled
|
||||
|
||||
- name: test3
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
needs:
|
||||
- test2
|
||||
`,
|
||||
@@ -111,7 +111,7 @@ releases:
|
||||
"/path/to/helmfile.d/helmfile_3.yaml": `
|
||||
releases:
|
||||
- name: global
|
||||
chart: incubator/raw
|
||||
chart: incubator/raw
|
||||
namespace: kube-system
|
||||
`,
|
||||
}
|
||||
@@ -160,16 +160,16 @@ releases:
|
||||
check(t, testcase{
|
||||
environment: "default",
|
||||
expected: `NAME NAMESPACE ENABLED INSTALLED LABELS CHART VERSION
|
||||
logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw
|
||||
kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw
|
||||
test2 true true chart:raw,name:test2,namespace: incubator/raw
|
||||
test3 true true chart:raw,name:test3,namespace: incubator/raw
|
||||
external-secrets default true true app:test,chart:raw,name:external-secrets,namespace:default incubator/raw
|
||||
my-release default true true app:test,chart:raw,name:my-release,namespace:default incubator/raw
|
||||
disabled kube-system true false chart:raw,name:disabled,namespace:kube-system incubator/raw
|
||||
test2 true true chart:raw,name:test2,namespace: incubator/raw
|
||||
test3 true true chart:raw,name:test3,namespace: incubator/raw
|
||||
global kube-system true true chart:raw,name:global,namespace:kube-system incubator/raw
|
||||
kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw
|
||||
logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw
|
||||
cache my-app true true app:test,chart:redis,name:cache,namespace:my-app bitnami/redis 17.0.7
|
||||
database my-app true true chart:postgres,name:database,namespace:my-app bitnami/postgres 11.6.22
|
||||
global kube-system true true chart:raw,name:global,namespace:kube-system incubator/raw
|
||||
`,
|
||||
}, cfg)
|
||||
})
|
||||
@@ -207,13 +207,13 @@ database my-app true true chart:postgres,name:database,namespace:my-a
|
||||
environment: "shared",
|
||||
// 'global' release has no environments, so is still excluded
|
||||
expected: `NAME NAMESPACE ENABLED INSTALLED LABELS CHART VERSION
|
||||
logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw
|
||||
kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw
|
||||
test2 true true chart:raw,name:test2,namespace: incubator/raw
|
||||
test3 true true chart:raw,name:test3,namespace: incubator/raw
|
||||
external-secrets default true true app:test,chart:raw,name:external-secrets,namespace:default incubator/raw
|
||||
my-release default true true app:test,chart:raw,name:my-release,namespace:default incubator/raw
|
||||
disabled kube-system true false chart:raw,name:disabled,namespace:kube-system incubator/raw
|
||||
test2 true true chart:raw,name:test2,namespace: incubator/raw
|
||||
test3 true true chart:raw,name:test3,namespace: incubator/raw
|
||||
kubernetes-external-secrets kube-system true true chart:raw,name:kubernetes-external-secrets,namespace:kube-system incubator/raw
|
||||
logging kube-system true true chart:raw,name:logging,namespace:kube-system incubator/raw
|
||||
cache my-app true true app:test,chart:redis,name:cache,namespace:my-app bitnami/redis 17.0.7
|
||||
database my-app true true chart:postgres,name:database,namespace:my-app bitnami/postgres 11.6.22
|
||||
`,
|
||||
@@ -266,7 +266,8 @@ releases:
|
||||
defer func() { os.Stdout = stdout }()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/helmfile/vals"
|
||||
|
||||
ffs "github.com/helmfile/helmfile/pkg/filesystem"
|
||||
"github.com/helmfile/helmfile/pkg/helmexec"
|
||||
"github.com/helmfile/helmfile/pkg/testhelper"
|
||||
"github.com/helmfile/helmfile/pkg/testutil"
|
||||
)
|
||||
|
||||
// TestParallelProcessingDeterministicOutput verifies that ListReleases produces
|
||||
// consistent sorted output even with parallel processing of multiple helmfile.d files
|
||||
func TestParallelProcessingDeterministicOutput(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/path/to/helmfile.d/z-last.yaml": `
|
||||
releases:
|
||||
- name: zulu-release
|
||||
chart: stable/chart-z
|
||||
namespace: ns-z
|
||||
`,
|
||||
"/path/to/helmfile.d/a-first.yaml": `
|
||||
releases:
|
||||
- name: alpha-release
|
||||
chart: stable/chart-a
|
||||
namespace: ns-a
|
||||
`,
|
||||
"/path/to/helmfile.d/m-middle.yaml": `
|
||||
releases:
|
||||
- name: mike-release
|
||||
chart: stable/chart-m
|
||||
namespace: ns-m
|
||||
`,
|
||||
}
|
||||
|
||||
// Run ListReleases multiple times to verify consistent ordering
|
||||
var outputs []string
|
||||
for i := 0; i < 5; i++ {
|
||||
var buffer bytes.Buffer
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error creating vals runtime: %v", err)
|
||||
}
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
fs: ffs.DefaultFileSystem(),
|
||||
OverrideKubeContext: "default",
|
||||
Env: "default",
|
||||
Logger: logger,
|
||||
valsRuntime: valsRuntime,
|
||||
FileOrDir: "/path/to/helmfile.d",
|
||||
}, files)
|
||||
|
||||
expectNoCallsToHelm(app)
|
||||
|
||||
err = app.ListReleases(configImpl{
|
||||
skipCharts: false,
|
||||
output: "table",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on iteration %d: %v", i, err)
|
||||
}
|
||||
|
||||
outputs = append(outputs, buffer.String())
|
||||
}
|
||||
|
||||
// Verify all outputs are identical (deterministic)
|
||||
firstOutput := outputs[0]
|
||||
for i, output := range outputs[1:] {
|
||||
if output != firstOutput {
|
||||
t.Errorf("output %d differs from first output (non-deterministic ordering)", i+1)
|
||||
t.Logf("First output:\n%s", firstOutput)
|
||||
t.Logf("Output %d:\n%s", i+1, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleHelmfileDFiles verifies that all files in helmfile.d are processed
|
||||
func TestMultipleHelmfileDFiles(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/path/to/helmfile.d/001-app.yaml": `
|
||||
releases:
|
||||
- name: app1
|
||||
chart: stable/app1
|
||||
namespace: default
|
||||
`,
|
||||
"/path/to/helmfile.d/002-db.yaml": `
|
||||
releases:
|
||||
- name: db1
|
||||
chart: stable/postgresql
|
||||
namespace: default
|
||||
`,
|
||||
"/path/to/helmfile.d/003-cache.yaml": `
|
||||
releases:
|
||||
- name: cache1
|
||||
chart: stable/redis
|
||||
namespace: default
|
||||
`,
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error creating vals runtime: %v", err)
|
||||
}
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
fs: ffs.DefaultFileSystem(),
|
||||
OverrideKubeContext: "default",
|
||||
Env: "default",
|
||||
Logger: logger,
|
||||
valsRuntime: valsRuntime,
|
||||
FileOrDir: "/path/to/helmfile.d",
|
||||
}, files)
|
||||
|
||||
expectNoCallsToHelm(app)
|
||||
|
||||
// Capture stdout since ListReleases outputs to stdout
|
||||
out, err := testutil.CaptureStdout(func() {
|
||||
err := app.ListReleases(configImpl{
|
||||
skipCharts: false,
|
||||
output: "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Logf("ListReleases error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error capturing output: %v", err)
|
||||
}
|
||||
|
||||
// Verify all three releases are present in output (JSON format)
|
||||
if !bytes.Contains([]byte(out), []byte("app1")) {
|
||||
t.Errorf("app1 release not found in output:\n%s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("db1")) {
|
||||
t.Errorf("db1 release not found in output:\n%s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("cache1")) {
|
||||
t.Errorf("cache1 release not found in output:\n%s", out)
|
||||
}
|
||||
}
|
||||
+193
-6
@@ -220,6 +220,97 @@ releases:
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStrategyParamValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
files map[string]string
|
||||
updateStrategy string
|
||||
isValid bool
|
||||
}{
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy: reinstallIfForbidden
|
||||
`},
|
||||
"reinstallIfForbidden",
|
||||
true},
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy: reinstallIfForbidden
|
||||
`},
|
||||
"reinstallIfForbidden",
|
||||
true},
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy:
|
||||
`},
|
||||
"",
|
||||
true},
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy: foo
|
||||
`},
|
||||
"foo",
|
||||
false},
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy: reinstal
|
||||
`},
|
||||
"reinstal",
|
||||
false},
|
||||
{map[string]string{
|
||||
"/path/to/helmfile.yaml": `releases:
|
||||
- name: zipkin
|
||||
chart: stable/zipkin
|
||||
updateStrategy: reinstall1
|
||||
`},
|
||||
"reinstall1",
|
||||
false},
|
||||
}
|
||||
|
||||
for idx, c := range cases {
|
||||
fs := testhelper.NewTestFs(c.files)
|
||||
app := &App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
OverrideKubeContext: "default",
|
||||
Logger: newAppTestLogger(),
|
||||
Namespace: "",
|
||||
Env: "default",
|
||||
FileOrDir: "helmfile.yaml",
|
||||
}
|
||||
|
||||
expectNoCallsToHelm(app)
|
||||
|
||||
app = injectFs(app, fs)
|
||||
|
||||
err := app.ForEachState(
|
||||
Noop,
|
||||
false,
|
||||
SetFilter(true),
|
||||
)
|
||||
|
||||
if c.isValid && err != nil {
|
||||
t.Errorf("[case: %d] Unexpected error for valid case: %v", idx, err)
|
||||
} else if !c.isValid {
|
||||
var invalidUpdateStrategy state.InvalidUpdateStrategyError
|
||||
invalidUpdateStrategy.UpdateStrategy = c.updateStrategy
|
||||
if err == nil {
|
||||
t.Errorf("[case: %d] Expected error for invalid case", idx)
|
||||
} else if !strings.Contains(err.Error(), invalidUpdateStrategy.Error()) {
|
||||
t.Errorf("[case: %d] Unexpected error returned for invalid case\ngot: %v\nexpected underlying error: %s", idx, err, invalidUpdateStrategy.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisitDesiredStatesWithReleasesFiltered_Issue1008_MissingNonDefaultEnvInBase(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"/path/to/base.yaml": `
|
||||
@@ -2639,7 +2730,8 @@ releases:
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
|
||||
if err != nil {
|
||||
@@ -2711,7 +2803,8 @@ releases:
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
|
||||
if err != nil {
|
||||
@@ -3076,6 +3169,97 @@ baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 defau
|
||||
concurrency: 1,
|
||||
},
|
||||
//
|
||||
// install with upgrade with reinstallIfForbidden
|
||||
//
|
||||
{
|
||||
name: "install-with-upgrade-with-reinstallIfForbidden",
|
||||
loc: location(),
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `
|
||||
releases:
|
||||
- name: baz
|
||||
chart: stable/mychart3
|
||||
disableValidationOnInstall: true
|
||||
updateStrategy: reinstallIfForbidden
|
||||
- name: foo
|
||||
chart: stable/mychart1
|
||||
disableValidationOnInstall: true
|
||||
needs:
|
||||
- bar
|
||||
- name: bar
|
||||
chart: stable/mychart2
|
||||
disableValidation: true
|
||||
updateStrategy: reinstallIfForbidden
|
||||
`,
|
||||
},
|
||||
diffs: map[exectest.DiffKey]error{
|
||||
{Name: "baz", Chart: "stable/mychart3", Flags: "--kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2},
|
||||
{Name: "foo", Chart: "stable/mychart1", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2},
|
||||
{Name: "bar", Chart: "stable/mychart2", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2},
|
||||
},
|
||||
lists: map[exectest.ListKey]string{
|
||||
{Filter: "^foo$", Flags: listFlags("", "default")}: ``,
|
||||
{Filter: "^bar$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
|
||||
bar 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart2-3.1.0 3.1.0 default
|
||||
`,
|
||||
{Filter: "^baz$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
|
||||
baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 default
|
||||
`,
|
||||
},
|
||||
upgraded: []exectest.Release{
|
||||
{Name: "baz", Flags: []string{"--kube-context", "default"}},
|
||||
{Name: "bar", Flags: []string{"--kube-context", "default"}},
|
||||
{Name: "foo", Flags: []string{"--kube-context", "default"}},
|
||||
},
|
||||
deleted: []exectest.Release{},
|
||||
concurrency: 1,
|
||||
},
|
||||
//
|
||||
// install with upgrade and --skip-diff-on-install with reinstallIfForbidden
|
||||
//
|
||||
{
|
||||
name: "install-with-upgrade-with-skip-diff-on-install-with-reinstallIfForbidden",
|
||||
loc: location(),
|
||||
skipDiffOnInstall: true,
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `
|
||||
releases:
|
||||
- name: baz
|
||||
chart: stable/mychart3
|
||||
disableValidationOnInstall: true
|
||||
updateStrategy: reinstallIfForbidden
|
||||
- name: foo
|
||||
chart: stable/mychart1
|
||||
disableValidationOnInstall: true
|
||||
needs:
|
||||
- bar
|
||||
- name: bar
|
||||
chart: stable/mychart2
|
||||
disableValidation: true
|
||||
updateStrategy: reinstallIfForbidden
|
||||
`,
|
||||
},
|
||||
diffs: map[exectest.DiffKey]error{
|
||||
{Name: "baz", Chart: "stable/mychart3", Flags: "--kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2},
|
||||
{Name: "bar", Chart: "stable/mychart2", Flags: "--disable-validation --kube-context default --reset-values --detailed-exitcode"}: helmexec.ExitError{Code: 2},
|
||||
},
|
||||
lists: map[exectest.ListKey]string{
|
||||
{Filter: "^foo$", Flags: listFlags("", "default")}: ``,
|
||||
{Filter: "^bar$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
|
||||
bar 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart2-3.1.0 3.1.0 default
|
||||
`,
|
||||
{Filter: "^baz$", Flags: listFlags("", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
|
||||
baz 4 Fri Nov 1 08:40:07 2019 DEPLOYED mychart3-3.1.0 3.1.0 default
|
||||
`,
|
||||
},
|
||||
upgraded: []exectest.Release{
|
||||
{Name: "baz", Flags: []string{"--kube-context", "default"}},
|
||||
{Name: "bar", Flags: []string{"--kube-context", "default"}},
|
||||
{Name: "foo", Flags: []string{"--kube-context", "default"}},
|
||||
},
|
||||
concurrency: 1,
|
||||
},
|
||||
//
|
||||
// upgrades
|
||||
//
|
||||
{
|
||||
@@ -3772,7 +3956,7 @@ releases:
|
||||
}
|
||||
for flagIdx := range wantDeletes[relIdx].Flags {
|
||||
if wantDeletes[relIdx].Flags[flagIdx] != helm.Deleted[relIdx].Flags[flagIdx] {
|
||||
t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Deleted[relIdx].Flags[flagIdx], wantDeletes[relIdx].Flags[flagIdx])
|
||||
t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Deleted[relIdx].Flags[flagIdx], wantDeletes[relIdx].Flags[flagIdx])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3884,7 +4068,8 @@ releases:
|
||||
defer func() { os.Stdout = stdout }()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
@@ -3933,7 +4118,8 @@ releases:
|
||||
defer func() { os.Stdout = stdout }()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
@@ -3995,7 +4181,8 @@ releases:
|
||||
defer func() { os.Stdout = stdout }()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := helmexec.NewLogger(&buffer, "debug")
|
||||
syncWriter := testhelper.NewSyncWriter(&buffer)
|
||||
logger := helmexec.NewLogger(syncWriter, "debug")
|
||||
|
||||
app := appWithFs(&App{
|
||||
OverrideHelmBinary: DefaultHelmBinary,
|
||||
|
||||
+8
-2
@@ -1,20 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/helmfile/helmfile/pkg/state"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
updatedRepos map[string]bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewContext() Context {
|
||||
return Context{
|
||||
updatedRepos: map[string]bool{},
|
||||
updatedRepos: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx Context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) error {
|
||||
func (ctx *Context) SyncReposOnce(st *state.HelmState, helm state.RepoUpdater) error {
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
|
||||
updated, err := st.SyncRepos(helm, ctx.updatedRepos)
|
||||
|
||||
for _, r := range updated {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestContextConcurrentAccess verifies that Context is thread-safe
|
||||
// when accessed concurrently from multiple goroutines
|
||||
func TestContextConcurrentAccess(t *testing.T) {
|
||||
ctx := &Context{
|
||||
updatedRepos: make(map[string]bool),
|
||||
}
|
||||
|
||||
const numGoroutines = 100
|
||||
const numReposPerGoroutine = 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Launch multiple goroutines that concurrently update the repos map
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numReposPerGoroutine; j++ {
|
||||
repoKey := "repo-" + string(rune('0'+goroutineID)) + "-" + string(rune('0'+j))
|
||||
|
||||
ctx.mu.Lock()
|
||||
ctx.updatedRepos[repoKey] = true
|
||||
ctx.mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify the map has entries (exact count may vary due to key overlap)
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
|
||||
if len(ctx.updatedRepos) == 0 {
|
||||
t.Error("expected non-empty updatedRepos after concurrent updates")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextInitialization verifies Context is created with proper initial state
|
||||
func TestContextInitialization(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
if ctx.updatedRepos == nil {
|
||||
t.Error("updatedRepos map is nil")
|
||||
}
|
||||
|
||||
// Verify initial state is empty
|
||||
if len(ctx.updatedRepos) != 0 {
|
||||
t.Errorf("expected empty updatedRepos, got %d entries", len(ctx.updatedRepos))
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextPointerSemantics verifies that Context is correctly used as a pointer
|
||||
// to prevent mutex copying issues
|
||||
func TestContextPointerSemantics(t *testing.T) {
|
||||
// Create a Context
|
||||
ctx := &Context{
|
||||
updatedRepos: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Create a Run with the context
|
||||
run := &Run{
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
// Verify that run.ctx points to the same Context
|
||||
if run.ctx != ctx {
|
||||
t.Error("Run.ctx does not point to the same Context instance")
|
||||
}
|
||||
|
||||
// Modify the context through run.ctx and verify the original is affected
|
||||
repoKey := "test-repo=https://charts.example.com"
|
||||
|
||||
run.ctx.mu.Lock()
|
||||
run.ctx.updatedRepos[repoKey] = true
|
||||
run.ctx.mu.Unlock()
|
||||
|
||||
// Check that the original context was modified
|
||||
ctx.mu.Lock()
|
||||
found := ctx.updatedRepos[repoKey]
|
||||
ctx.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
t.Error("original context was not modified (pointer semantics broken)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextMutexNotCopied verifies that using pointer receivers prevents mutex copying
|
||||
func TestContextMutexNotCopied(t *testing.T) {
|
||||
ctx1 := &Context{
|
||||
updatedRepos: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Assign to another variable (should be pointer copy, not value copy)
|
||||
ctx2 := ctx1
|
||||
|
||||
// Modify through ctx2
|
||||
ctx2.mu.Lock()
|
||||
ctx2.updatedRepos["test"] = true
|
||||
ctx2.mu.Unlock()
|
||||
|
||||
// Verify ctx1 sees the change (they share the same underlying data)
|
||||
ctx1.mu.Lock()
|
||||
found := ctx1.updatedRepos["test"]
|
||||
ctx1.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
t.Error("ctx1 and ctx2 don't share the same data (value copy instead of pointer copy)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextConcurrentReadWrite tests concurrent reads and writes to the Context
|
||||
func TestContextConcurrentReadWrite(t *testing.T) {
|
||||
ctx := &Context{
|
||||
updatedRepos: make(map[string]bool),
|
||||
}
|
||||
|
||||
const numRepos = 10
|
||||
const numGoroutinesPerRepo = 10
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Launch multiple goroutines for each repo
|
||||
for i := 0; i < numRepos; i++ {
|
||||
repoKey := "repo-" + string(rune('0'+i)) + "=https://example.com"
|
||||
|
||||
for j := 0; j < numGoroutinesPerRepo; j++ {
|
||||
wg.Add(1)
|
||||
go func(key string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Write
|
||||
ctx.mu.Lock()
|
||||
ctx.updatedRepos[key] = true
|
||||
ctx.mu.Unlock()
|
||||
|
||||
// Read
|
||||
ctx.mu.Lock()
|
||||
_ = ctx.updatedRepos[key]
|
||||
ctx.mu.Unlock()
|
||||
}(repoKey)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify repos are in the map
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
|
||||
if len(ctx.updatedRepos) != numRepos {
|
||||
t.Errorf("expected %d repos, got %d", numRepos, len(ctx.updatedRepos))
|
||||
}
|
||||
|
||||
// Verify all are marked as true
|
||||
for key, value := range ctx.updatedRepos {
|
||||
if !value {
|
||||
t.Errorf("repo %s is not marked as true", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/helmfile/vals"
|
||||
@@ -33,6 +34,7 @@ type desiredStateLoader struct {
|
||||
namespace string
|
||||
chart string
|
||||
fs *filesystem.FileSystem
|
||||
baseDir string // Base directory for resolving relative paths, empty means use cwd
|
||||
|
||||
getHelm func(*state.HelmState) (helmexec.Interface, error)
|
||||
|
||||
@@ -66,7 +68,22 @@ func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, e
|
||||
}
|
||||
}
|
||||
|
||||
st, err := ld.loadFileWithOverrides(nil, overrodeEnv, filepath.Dir(f), filepath.Base(f), true)
|
||||
// Resolve file path relative to baseDir if provided
|
||||
var dir, file string
|
||||
if ld.baseDir != "" {
|
||||
// If baseDir is set, resolve all paths relative to it
|
||||
if !filepath.IsAbs(f) {
|
||||
f = filepath.Join(ld.baseDir, f)
|
||||
}
|
||||
dir = filepath.Dir(f)
|
||||
file = filepath.Base(f)
|
||||
} else {
|
||||
// Use original behavior
|
||||
dir = filepath.Dir(f)
|
||||
file = filepath.Base(f)
|
||||
}
|
||||
|
||||
st, err := ld.loadFileWithOverrides(nil, overrodeEnv, dir, file, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -285,6 +302,18 @@ func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, ba
|
||||
}
|
||||
}
|
||||
|
||||
// Validate updateStrategy value if set in the releases
|
||||
for i := range finalState.Releases {
|
||||
if finalState.Releases[i].UpdateStrategy != "" {
|
||||
if !slices.Contains(state.ValidUpdateStrategyValues, finalState.Releases[i].UpdateStrategy) {
|
||||
return nil, &state.StateLoadError{
|
||||
Msg: fmt.Sprintf("failed to read %s", finalState.FilePath),
|
||||
Cause: &state.InvalidUpdateStrategyError{UpdateStrategy: finalState.Releases[i].UpdateStrategy},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalState.OrginReleases = finalState.Releases
|
||||
return finalState, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
HelmRequiredVersion = "v3.18.6"
|
||||
HelmDiffRecommendedVersion = "v3.13.0"
|
||||
HelmDiffRecommendedVersion = "v3.13.1"
|
||||
HelmRecommendedVersion = "v3.19.0"
|
||||
HelmSecretsRecommendedVersion = "v4.6.5"
|
||||
HelmGitRecommendedVersion = "v1.3.0"
|
||||
|
||||
+2
-2
@@ -16,14 +16,14 @@ import (
|
||||
type Run struct {
|
||||
state *state.HelmState
|
||||
helm helmexec.Interface
|
||||
ctx Context
|
||||
ctx *Context
|
||||
|
||||
ReleaseToChart map[state.PrepareChartKey]string
|
||||
|
||||
Ask func(string) bool
|
||||
}
|
||||
|
||||
func NewRun(st *state.HelmState, helm helmexec.Interface, ctx Context) (*Run, error) {
|
||||
func NewRun(st *state.HelmState, helm helmexec.Interface, ctx *Context) (*Run, error) {
|
||||
if helm == nil {
|
||||
return nil, fmt.Errorf("Assertion failed: helmexec.Interface must not be nil")
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
processing file "helmfile.yaml" in directory "."
|
||||
changing working directory to "/path/to"
|
||||
merged environment: &{default map[] map[]}
|
||||
1 release(s) matching name=a found in helmfile.yaml
|
||||
|
||||
processing 1 groups of releases in this order:
|
||||
GROUP RELEASES
|
||||
1 default/default/a
|
||||
|
||||
processing releases in group 1/1: default/default/a
|
||||
changing working directory back to "/path/to"
|
||||
@@ -1,16 +1,7 @@
|
||||
found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml
|
||||
processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{default map[] map[]}
|
||||
merged environment: &{default map[] map[]}
|
||||
merged environment: &{default map[] map[]}
|
||||
merged environment: &{default map[] map[]}
|
||||
merged environment: &{default map[] map[]}
|
||||
WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{default map[] map[]}
|
||||
merged environment: &{default map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{default map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml
|
||||
processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{staging map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{staging map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{staging map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
|
||||
+3
-12
@@ -1,16 +1,7 @@
|
||||
found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml
|
||||
processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{shared map[] map[]}
|
||||
merged environment: &{shared map[] map[]}
|
||||
merged environment: &{shared map[] map[]}
|
||||
merged environment: &{shared map[] map[]}
|
||||
merged environment: &{shared map[] map[]}
|
||||
WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{shared map[] map[]}
|
||||
merged environment: &{shared map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{shared map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
|
||||
+1
-10
@@ -1,14 +1,5 @@
|
||||
found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml
|
||||
processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{test map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{test map[] map[]}
|
||||
merged environment: &{test map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{test map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
merged environment: &{test map[] map[]}
|
||||
|
||||
+2
-11
@@ -1,15 +1,6 @@
|
||||
found 3 helmfile state files in helmfile.d: /path/to/helmfile.d/helmfile_1.yaml, /path/to/helmfile.d/helmfile_2.yaml, /path/to/helmfile.d/helmfile_3.yaml
|
||||
processing file "helmfile_1.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{development map[] map[]}
|
||||
merged environment: &{development map[] map[]}
|
||||
merged environment: &{development map[] map[]}
|
||||
merged environment: &{development map[] map[]}
|
||||
WARNING: release test2 needs disabled, but disabled is not installed due to installed: false. Either mark disabled as installed or remove disabled from test2's needs
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_2.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{development map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
processing file "helmfile_3.yaml" in directory "/path/to/helmfile.d"
|
||||
changing working directory to "/path/to/helmfile.d"
|
||||
merged environment: &{development map[] map[]}
|
||||
changing working directory back to "/path/to"
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
processing file "helmfile.yaml" in directory "."
|
||||
changing working directory to "/path/to"
|
||||
merged environment: &{default map[] map[]}
|
||||
3 release(s) found in helmfile.yaml
|
||||
|
||||
Affected releases are:
|
||||
bar (stable/mychart2) UPDATED
|
||||
baz (stable/mychart3) UPDATED
|
||||
foo (stable/mychart1) UPDATED
|
||||
|
||||
invoking preapply hooks for 2 groups of releases in this order:
|
||||
GROUP RELEASES
|
||||
1 default//foo
|
||||
2 default//baz, default//bar
|
||||
|
||||
invoking preapply hooks for releases in group 1/2: default//foo
|
||||
invoking preapply hooks for releases in group 2/2: default//baz, default//bar
|
||||
processing 2 groups of releases in this order:
|
||||
GROUP RELEASES
|
||||
1 default//baz, default//bar
|
||||
2 default//foo
|
||||
|
||||
processing releases in group 1/2: default//baz, default//bar
|
||||
update strategy - sync success
|
||||
update strategy - sync success
|
||||
processing releases in group 2/2: default//foo
|
||||
getting deployed release version failed: Failed to get the version for: mychart1
|
||||
|
||||
UPDATED RELEASES:
|
||||
NAME NAMESPACE CHART VERSION DURATION
|
||||
baz stable/mychart3 3.1.0 0s
|
||||
bar stable/mychart2 3.1.0 0s
|
||||
foo stable/mychart1 0s
|
||||
|
||||
changing working directory back to "/path/to"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
processing file "helmfile.yaml" in directory "."
|
||||
changing working directory to "/path/to"
|
||||
merged environment: &{default map[] map[]}
|
||||
3 release(s) found in helmfile.yaml
|
||||
|
||||
Affected releases are:
|
||||
bar (stable/mychart2) UPDATED
|
||||
baz (stable/mychart3) UPDATED
|
||||
foo (stable/mychart1) UPDATED
|
||||
|
||||
invoking preapply hooks for 2 groups of releases in this order:
|
||||
GROUP RELEASES
|
||||
1 default//foo
|
||||
2 default//baz, default//bar
|
||||
|
||||
invoking preapply hooks for releases in group 1/2: default//foo
|
||||
invoking preapply hooks for releases in group 2/2: default//baz, default//bar
|
||||
processing 2 groups of releases in this order:
|
||||
GROUP RELEASES
|
||||
1 default//baz, default//bar
|
||||
2 default//foo
|
||||
|
||||
processing releases in group 1/2: default//baz, default//bar
|
||||
update strategy - sync success
|
||||
update strategy - sync success
|
||||
processing releases in group 2/2: default//foo
|
||||
getting deployed release version failed: Failed to get the version for: mychart1
|
||||
|
||||
UPDATED RELEASES:
|
||||
NAME NAMESPACE CHART VERSION DURATION
|
||||
baz stable/mychart3 3.1.0 0s
|
||||
bar stable/mychart2 3.1.0 0s
|
||||
foo stable/mychart1 0s
|
||||
|
||||
changing working directory back to "/path/to"
|
||||
+22
-4
@@ -56,9 +56,10 @@ type Release struct {
|
||||
}
|
||||
|
||||
type Affected struct {
|
||||
Upgraded []*Release
|
||||
Deleted []*Release
|
||||
Failed []*Release
|
||||
Upgraded []*Release
|
||||
Reinstalled []*Release
|
||||
Deleted []*Release
|
||||
Failed []*Release
|
||||
}
|
||||
|
||||
func (helm *Helm) UpdateDeps(chart string) error {
|
||||
@@ -107,7 +108,24 @@ func (helm *Helm) RegistryLogin(name, username, password, caFile, certFile, keyF
|
||||
return nil
|
||||
}
|
||||
func (helm *Helm) SyncRelease(context helmexec.HelmContext, name, chart, namespace string, flags ...string) error {
|
||||
if strings.Contains(name, "error") {
|
||||
if strings.Contains(name, "forbidden") {
|
||||
releaseExists := false
|
||||
for _, release := range helm.Releases {
|
||||
if release.Name == name {
|
||||
releaseExists = true
|
||||
}
|
||||
}
|
||||
releaseDeleted := false
|
||||
for _, release := range helm.Deleted {
|
||||
if release.Name == name {
|
||||
releaseDeleted = true
|
||||
}
|
||||
}
|
||||
// Only fail if the release is present in the helm.Releases to simulate a forbidden update if it exists
|
||||
if releaseExists && !releaseDeleted {
|
||||
return fmt.Errorf("cannot patch %q with kind StatefulSet: StatefulSet.apps %q is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden", name, name)
|
||||
}
|
||||
} else if strings.Contains(name, "error") {
|
||||
return errors.New("error")
|
||||
}
|
||||
helm.sync(helm.ReleasesMutex, func() {
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/helmfile/chartify"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/chart"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
"helm.sh/helm/v3/pkg/plugin"
|
||||
@@ -262,8 +265,106 @@ func (helm *execer) RegistryLogin(repository, username, password, caFile, certFi
|
||||
return err
|
||||
}
|
||||
|
||||
// toKebabCase converts a PascalCase or camelCase string to kebab-case.
|
||||
// e.g., "SkipRefresh" -> "skip-refresh", "KubeContext" -> "kube-context"
|
||||
func toKebabCase(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && unicode.IsUpper(r) {
|
||||
result.WriteRune('-')
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// getSupportedDependencyFlags returns a map of supported flags for helm dependency commands.
|
||||
// It uses reflection on helm's action.Dependency and cli.EnvSettings structs to
|
||||
// dynamically determine which flags are supported, avoiding hardcoded lists.
|
||||
func getSupportedDependencyFlags() map[string]bool {
|
||||
supported := make(map[string]bool)
|
||||
|
||||
// Get global flags from cli.EnvSettings
|
||||
envSettings := cli.New()
|
||||
envType := reflect.TypeOf(*envSettings)
|
||||
for i := 0; i < envType.NumField(); i++ {
|
||||
field := envType.Field(i)
|
||||
if field.IsExported() {
|
||||
flagName := "--" + toKebabCase(field.Name)
|
||||
supported[flagName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add namespace short form
|
||||
supported["-n"] = true
|
||||
|
||||
// Get dependency-specific flags from action.Dependency
|
||||
dep := action.NewDependency()
|
||||
depType := reflect.TypeOf(*dep)
|
||||
for i := 0; i < depType.NumField(); i++ {
|
||||
field := depType.Field(i)
|
||||
if field.IsExported() {
|
||||
flagName := "--" + toKebabCase(field.Name)
|
||||
supported[flagName] = true
|
||||
}
|
||||
}
|
||||
|
||||
return supported
|
||||
}
|
||||
|
||||
// Cache of supported flags, initialized once
|
||||
var (
|
||||
supportedDependencyFlagsOnce sync.Once
|
||||
supportedDependencyFlags map[string]bool
|
||||
)
|
||||
|
||||
// filterDependencyUnsupportedFlags filters flags to only those supported by helm dependency commands.
|
||||
// Uses reflection on helm's action.Dependency and cli.EnvSettings structs to dynamically
|
||||
// determine supported flags, avoiding hardcoded lists.
|
||||
func filterDependencyUnsupportedFlags(flags []string) []string {
|
||||
if len(flags) == 0 {
|
||||
return flags
|
||||
}
|
||||
|
||||
// Initialize supported flags map once
|
||||
supportedDependencyFlagsOnce.Do(func() {
|
||||
supportedDependencyFlags = getSupportedDependencyFlags()
|
||||
})
|
||||
|
||||
filtered := make([]string, 0, len(flags))
|
||||
for _, flag := range flags {
|
||||
// Extract flag name without value (e.g., "--dry-run=server" -> "--dry-run")
|
||||
flagName := flag
|
||||
if idx := strings.Index(flag, "="); idx != -1 {
|
||||
flagName = flag[:idx]
|
||||
}
|
||||
|
||||
// Check if this flag or any prefix of it is supported
|
||||
supported := false
|
||||
for supportedFlag := range supportedDependencyFlags {
|
||||
if strings.HasPrefix(flagName, supportedFlag) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if supported {
|
||||
filtered = append(filtered, flag)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (helm *execer) BuildDeps(name, chart string, flags ...string) error {
|
||||
helm.logger.Infof("Building dependency release=%v, chart=%v", name, chart)
|
||||
|
||||
// Filter out template/install/upgrade-specific flags while preserving global flags
|
||||
savedExtra := helm.extra
|
||||
helm.extra = filterDependencyUnsupportedFlags(helm.extra)
|
||||
defer func() {
|
||||
helm.extra = savedExtra
|
||||
}()
|
||||
|
||||
args := []string{
|
||||
"dependency",
|
||||
"build",
|
||||
@@ -279,6 +380,14 @@ func (helm *execer) BuildDeps(name, chart string, flags ...string) error {
|
||||
|
||||
func (helm *execer) UpdateDeps(chart string) error {
|
||||
helm.logger.Infof("Updating dependency %v", chart)
|
||||
|
||||
// Filter out template/install/upgrade-specific flags while preserving global flags
|
||||
savedExtra := helm.extra
|
||||
helm.extra = filterDependencyUnsupportedFlags(helm.extra)
|
||||
defer func() {
|
||||
helm.extra = savedExtra
|
||||
}()
|
||||
|
||||
out, err := helm.exec([]string{"dependency", "update", chart}, map[string]string{}, nil)
|
||||
helm.info(out)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package helmexec
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
)
|
||||
|
||||
// TestFilterDependencyFlags_AllGlobalFlags verifies that all global flags
|
||||
// from cli.EnvSettings are preserved by the filter
|
||||
func TestFilterDependencyFlags_AllGlobalFlags(t *testing.T) {
|
||||
// Get all expected global flag names using reflection
|
||||
envSettings := cli.New()
|
||||
envType := reflect.TypeOf(*envSettings)
|
||||
|
||||
var expectedFlags []string
|
||||
for i := 0; i < envType.NumField(); i++ {
|
||||
field := envType.Field(i)
|
||||
if field.IsExported() {
|
||||
flagName := "--" + toKebabCase(field.Name)
|
||||
expectedFlags = append(expectedFlags, flagName)
|
||||
}
|
||||
}
|
||||
|
||||
// Add short form
|
||||
expectedFlags = append(expectedFlags, "-n")
|
||||
|
||||
// Test that each global flag is preserved
|
||||
for _, flag := range expectedFlags {
|
||||
input := []string{flag}
|
||||
output := filterDependencyUnsupportedFlags(input)
|
||||
|
||||
if len(output) != 1 || output[0] != flag {
|
||||
t.Errorf("global flag %s was not preserved: input=%v output=%v", flag, input, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterDependencyFlags_AllDependencyFlags verifies that all dependency-specific flags
|
||||
// from action.Dependency are preserved by the filter
|
||||
func TestFilterDependencyFlags_AllDependencyFlags(t *testing.T) {
|
||||
// Get all expected dependency flag names using reflection
|
||||
dep := action.NewDependency()
|
||||
depType := reflect.TypeOf(*dep)
|
||||
|
||||
var expectedFlags []string
|
||||
for i := 0; i < depType.NumField(); i++ {
|
||||
field := depType.Field(i)
|
||||
if field.IsExported() {
|
||||
flagName := "--" + toKebabCase(field.Name)
|
||||
expectedFlags = append(expectedFlags, flagName)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that each dependency flag is preserved
|
||||
for _, flag := range expectedFlags {
|
||||
input := []string{flag}
|
||||
output := filterDependencyUnsupportedFlags(input)
|
||||
|
||||
if len(output) != 1 || output[0] != flag {
|
||||
t.Errorf("dependency flag %s was not preserved: input=%v output=%v", flag, input, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterDependencyFlags_FlagWithEqualsValue tests flags with = syntax
|
||||
// Note: Current implementation has a known limitation with flags using = syntax
|
||||
// (e.g., --namespace=default). Users should use space-separated form (--namespace default).
|
||||
func TestFilterDependencyFlags_FlagWithEqualsValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input []string
|
||||
expected []string
|
||||
note string
|
||||
}{
|
||||
{
|
||||
name: "dry-run with value should be filtered",
|
||||
input: []string{"--dry-run=server"},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "namespace with equals syntax is currently filtered (known limitation)",
|
||||
input: []string{"--namespace=default"},
|
||||
expected: []string{}, // Known limitation: flags with = are not matched
|
||||
note: "Workaround: use --namespace default (space-separated)",
|
||||
},
|
||||
{
|
||||
name: "debug flag should be preserved",
|
||||
input: []string{"--debug"},
|
||||
expected: []string{"--debug"},
|
||||
},
|
||||
{
|
||||
name: "keyring with value should be preserved",
|
||||
input: []string{"--keyring=/path/to/keyring"},
|
||||
expected: []string{"--keyring=/path/to/keyring"},
|
||||
},
|
||||
{
|
||||
name: "wait flag should be filtered",
|
||||
input: []string{"--wait"},
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output := filterDependencyUnsupportedFlags(tc.input)
|
||||
if !reflect.DeepEqual(output, tc.expected) {
|
||||
if tc.note != "" {
|
||||
t.Logf("Note: %s", tc.note)
|
||||
}
|
||||
t.Errorf("filterDependencyUnsupportedFlags(%v) = %v, want %v",
|
||||
tc.input, output, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterDependencyFlags_MixedFlags tests a mix of supported and unsupported flags
|
||||
// Note: Flags with = syntax have known limitations (see TestFilterDependencyFlags_FlagWithEqualsValue)
|
||||
func TestFilterDependencyFlags_MixedFlags(t *testing.T) {
|
||||
input := []string{
|
||||
"--debug", // global: keep
|
||||
"--dry-run=server", // template: filter
|
||||
"--verify", // dependency: keep
|
||||
"--wait", // template: filter
|
||||
"--namespace=default", // global: keep (but filtered due to = syntax limitation)
|
||||
"--kube-context=prod", // global: keep
|
||||
"--atomic", // template: filter
|
||||
"--keyring=/path", // dependency: keep
|
||||
}
|
||||
|
||||
// Expected reflects current behavior with known limitation for --namespace=
|
||||
expected := []string{
|
||||
"--debug",
|
||||
"--verify",
|
||||
"--kube-context=prod", // Works because --kube- prefix matches
|
||||
"--keyring=/path",
|
||||
}
|
||||
|
||||
output := filterDependencyUnsupportedFlags(input)
|
||||
|
||||
if !reflect.DeepEqual(output, expected) {
|
||||
t.Errorf("filterDependencyUnsupportedFlags() =\n%v\nwant:\n%v", output, expected)
|
||||
t.Logf("Note: --namespace=default not preserved due to known limitation with = syntax")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterDependencyFlags_EmptyInput tests empty input
|
||||
func TestFilterDependencyFlags_EmptyInput(t *testing.T) {
|
||||
input := []string{}
|
||||
output := filterDependencyUnsupportedFlags(input)
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected empty output for empty input, got %v", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterDependencyFlags_TemplateSpecificFlags tests that template-specific flags are filtered
|
||||
func TestFilterDependencyFlags_TemplateSpecificFlags(t *testing.T) {
|
||||
templateFlags := []string{
|
||||
"--dry-run",
|
||||
"--dry-run=client",
|
||||
"--dry-run=server",
|
||||
"--wait",
|
||||
"--atomic",
|
||||
"--timeout=5m",
|
||||
"--create-namespace",
|
||||
"--dependency-update",
|
||||
"--force",
|
||||
"--cleanup-on-fail",
|
||||
"--no-hooks",
|
||||
}
|
||||
|
||||
for _, flag := range templateFlags {
|
||||
output := filterDependencyUnsupportedFlags([]string{flag})
|
||||
if len(output) != 0 {
|
||||
t.Errorf("template-specific flag %s should be filtered out, but got %v", flag, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestToKebabCase tests the toKebabCase conversion function
|
||||
// Note: Current implementation has limitations with consecutive uppercase letters (acronyms)
|
||||
func TestToKebabCase(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
note string
|
||||
}{
|
||||
{"SkipRefresh", "skip-refresh", ""},
|
||||
{"KubeContext", "kube-context", ""},
|
||||
{"BurstLimit", "burst-limit", ""},
|
||||
{"QPS", "q-p-s", "Known limitation: consecutive caps become separate words"},
|
||||
{"Debug", "debug", ""},
|
||||
{"InsecureSkipTLSverify", "insecure-skip-t-l-sverify", "Known limitation: TLS acronym"},
|
||||
{"RepositoryConfig", "repository-config", ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
output := toKebabCase(tc.input)
|
||||
if output != tc.expected {
|
||||
if tc.note != "" {
|
||||
t.Logf("Note: %s", tc.note)
|
||||
}
|
||||
t.Errorf("toKebabCase(%s) = %s, want %s", tc.input, output, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSupportedDependencyFlags_Consistency tests that the supported flags map
|
||||
// is consistent across multiple calls (caching works)
|
||||
func TestGetSupportedDependencyFlags_Consistency(t *testing.T) {
|
||||
// Call multiple times
|
||||
flags1 := getSupportedDependencyFlags()
|
||||
flags2 := getSupportedDependencyFlags()
|
||||
|
||||
// Verify they have the same keys
|
||||
if len(flags1) != len(flags2) {
|
||||
t.Errorf("inconsistent number of flags: first call=%d, second call=%d",
|
||||
len(flags1), len(flags2))
|
||||
}
|
||||
|
||||
for key := range flags1 {
|
||||
if !flags2[key] {
|
||||
t.Errorf("flag %s present in first call but not in second", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSupportedDependencyFlags_ContainsExpectedFlags tests that the supported flags
|
||||
// contain known important flags (based on actual reflection output)
|
||||
func TestGetSupportedDependencyFlags_ContainsExpectedFlags(t *testing.T) {
|
||||
supportedFlags := getSupportedDependencyFlags()
|
||||
|
||||
// Flags that should definitely be present based on reflection
|
||||
expectedFlags := []string{
|
||||
"--debug",
|
||||
"--verify",
|
||||
"--keyring",
|
||||
"--skip-refresh",
|
||||
"-n", // Short form is added explicitly
|
||||
"--kube-context",
|
||||
"--burst-limit",
|
||||
}
|
||||
|
||||
for _, flag := range expectedFlags {
|
||||
if !supportedFlags[flag] {
|
||||
t.Errorf("expected flag %s not found in supported flags map", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Some flags may not be present due to toKebabCase limitations
|
||||
// - "Namespace" field becomes "--namespace" but may not match "--namespace="
|
||||
// - "Kubeconfig" field becomes "--kubeconfig"
|
||||
// - "QPS" field becomes "--q-p-s" (not "--qps")
|
||||
t.Logf("Total flags discovered via reflection: %d", len(supportedFlags))
|
||||
}
|
||||
@@ -431,6 +431,7 @@ exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo
|
||||
buffer.Reset()
|
||||
helm.SetExtraArgs("--verify")
|
||||
err = helm.UpdateDeps("./chart/foo")
|
||||
// --verify is a dependency-specific flag and should be preserved
|
||||
expected = `Updating dependency ./chart/foo
|
||||
exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo --verify
|
||||
`
|
||||
@@ -438,7 +439,7 @@ exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if buffer.String() != expected {
|
||||
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
||||
t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +479,7 @@ v3.2.4+ge29ce2a
|
||||
buffer.Reset()
|
||||
helm.SetExtraArgs("--verify")
|
||||
err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...)
|
||||
// --verify is a dependency-specific flag and should be preserved
|
||||
expected = `Building dependency release=foo, chart=./chart/foo
|
||||
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh --verify
|
||||
v3.2.4+ge29ce2a
|
||||
@@ -506,6 +508,47 @@ Client: v2.16.1+ge13bc94
|
||||
if buffer.String() != expected {
|
||||
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
||||
}
|
||||
|
||||
// Test that --dry-run flag is filtered out (not supported by helm dependency build)
|
||||
buffer.Reset()
|
||||
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
|
||||
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
helm.SetExtraArgs("--dry-run=server")
|
||||
err = helm.BuildDeps("foo", "./chart/foo")
|
||||
expected = `Building dependency release=foo, chart=./chart/foo
|
||||
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
|
||||
v3.2.4+ge29ce2a
|
||||
`
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if buffer.String() != expected {
|
||||
t.Errorf("helmexec.BuildDeps() with --dry-run should filter it out\nactual = %v\nexpect = %v", buffer.String(), expected)
|
||||
}
|
||||
|
||||
// Test that global flags (--debug) and dependency flags (--verify) are preserved,
|
||||
// while template-specific flags (--dry-run) are filtered out
|
||||
buffer.Reset()
|
||||
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
|
||||
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
helm.SetExtraArgs("--debug", "--dry-run=server", "--verify", "--wait")
|
||||
err = helm.BuildDeps("foo", "./chart/foo")
|
||||
expected = `Building dependency release=foo, chart=./chart/foo
|
||||
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --debug --verify
|
||||
v3.2.4+ge29ce2a
|
||||
`
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if buffer.String() != expected {
|
||||
t.Errorf("helmexec.BuildDeps() should preserve global and dependency flags\nactual = %v\nexpect = %v", buffer.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DecryptSecret(t *testing.T) {
|
||||
|
||||
+25
-7
@@ -26,6 +26,8 @@ const (
|
||||
DefaultHCLFileExtension = ".hcl"
|
||||
)
|
||||
|
||||
var ValidUpdateStrategyValues = []string{UpdateStrategyReinstallIfForbidden}
|
||||
|
||||
type StateLoadError struct {
|
||||
Msg string
|
||||
Cause error
|
||||
@@ -43,6 +45,14 @@ func (e *UndefinedEnvError) Error() string {
|
||||
return fmt.Sprintf("environment \"%s\" is not defined", e.Env)
|
||||
}
|
||||
|
||||
type InvalidUpdateStrategyError struct {
|
||||
UpdateStrategy string
|
||||
}
|
||||
|
||||
func (e *InvalidUpdateStrategyError) Error() string {
|
||||
return fmt.Sprintf("updateStrategy %q is invalid, valid values are: %s or not set", e.UpdateStrategy, strings.Join(ValidUpdateStrategyValues, ", "))
|
||||
}
|
||||
|
||||
type StateCreator struct {
|
||||
logger *zap.SugaredLogger
|
||||
|
||||
@@ -116,11 +126,19 @@ func (c *StateCreator) Parse(content []byte, baseDir, file string) (*HelmState,
|
||||
return nil, &StateLoadError{fmt.Sprintf("failed to read %s: reading document at index %d", file, i), err}
|
||||
}
|
||||
|
||||
if err := mergo.Merge(&state, &intermediate, mergo.WithAppendSlice); err != nil {
|
||||
if err := mergo.Merge(&state, &intermediate, mergo.WithAppendSlice, mergo.WithOverride); err != nil {
|
||||
return nil, &StateLoadError{fmt.Sprintf("failed to read %s: merging document at index %d", file, i), err}
|
||||
}
|
||||
}
|
||||
|
||||
state.logger = c.logger
|
||||
state.valsRuntime = c.valsRuntime
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// applyDefaultsAndOverrides applies default binary paths and command-line overrides
|
||||
func (c *StateCreator) applyDefaultsAndOverrides(state *HelmState) {
|
||||
if c.overrideHelmBinary != "" && c.overrideHelmBinary != DefaultHelmBinary {
|
||||
state.DefaultHelmBinary = c.overrideHelmBinary
|
||||
} else if state.DefaultHelmBinary == "" {
|
||||
@@ -134,11 +152,6 @@ func (c *StateCreator) Parse(content []byte, baseDir, file string) (*HelmState,
|
||||
// Let `helmfile --kustomize-binary ""` not break this helmfile run
|
||||
state.DefaultKustomizeBinary = DefaultKustomizeBinary
|
||||
}
|
||||
|
||||
state.logger = c.logger
|
||||
state.valsRuntime = c.valsRuntime
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// LoadEnvValues loads environment values files relative to the `baseDir`
|
||||
@@ -181,6 +194,11 @@ func (c *StateCreator) ParseAndLoad(content []byte, baseDir, file string, envNam
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply default binaries and command-line overrides only for the main helmfile
|
||||
// after loading and merging all bases. This ensures that values from bases are
|
||||
// properly respected and that later bases/documents can override earlier ones.
|
||||
c.applyDefaultsAndOverrides(state)
|
||||
}
|
||||
|
||||
state, err = c.LoadEnvValues(state, envName, failOnMissingEnv, envValues, overrode)
|
||||
@@ -216,7 +234,7 @@ func (c *StateCreator) loadBases(envValues, overrodeEnv *environment.Environment
|
||||
layers = append(layers, st)
|
||||
|
||||
for i := 1; i < len(layers); i++ {
|
||||
if err := mergo.Merge(layers[0], layers[i], mergo.WithAppendSlice); err != nil {
|
||||
if err := mergo.Merge(layers[0], layers[i], mergo.WithAppendSlice, mergo.WithOverride); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -525,3 +526,205 @@ releaseContext:
|
||||
t.Errorf("unexpected values: expected=%v, actual=%v", expectedValues, actualValues)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHelmBinaryInBases tests that helmBinary and kustomizeBinary settings
|
||||
// from bases are properly merged with later values overriding earlier ones
|
||||
func TestHelmBinaryInBases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
mainFile string
|
||||
expectedHelmBinary string
|
||||
expectedKustomizeBinary string
|
||||
}{
|
||||
{
|
||||
name: "helmBinary in second base should be used",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/env.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/repos.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/releases.yaml
|
||||
`,
|
||||
"/path/to/bases/env.yaml": `environments:
|
||||
default:
|
||||
values:
|
||||
- key: value1
|
||||
`,
|
||||
"/path/to/bases/repos.yaml": `repositories:
|
||||
- name: stable
|
||||
url: https://charts.helm.sh/stable
|
||||
helmBinary: /path/to/custom/helm
|
||||
`,
|
||||
"/path/to/bases/releases.yaml": `releases:
|
||||
- name: myapp
|
||||
chart: stable/nginx
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: "/path/to/custom/helm",
|
||||
expectedKustomizeBinary: DefaultKustomizeBinary,
|
||||
},
|
||||
{
|
||||
name: "helmBinary in main file after bases should override",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/env.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/repos.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/releases.yaml
|
||||
helmBinary: /path/to/main/helm
|
||||
`,
|
||||
"/path/to/bases/env.yaml": `environments:
|
||||
default:
|
||||
values:
|
||||
- key: value1
|
||||
`,
|
||||
"/path/to/bases/repos.yaml": `repositories:
|
||||
- name: stable
|
||||
url: https://charts.helm.sh/stable
|
||||
helmBinary: /path/to/base/helm
|
||||
`,
|
||||
"/path/to/bases/releases.yaml": `releases:
|
||||
- name: myapp
|
||||
chart: stable/nginx
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: "/path/to/main/helm",
|
||||
expectedKustomizeBinary: DefaultKustomizeBinary,
|
||||
},
|
||||
{
|
||||
name: "helmBinary in main file between bases should override earlier bases",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/env.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/repos.yaml
|
||||
helmBinary: /path/to/middle/helm
|
||||
---
|
||||
bases:
|
||||
- ./bases/releases.yaml
|
||||
`,
|
||||
"/path/to/bases/env.yaml": `environments:
|
||||
default:
|
||||
values:
|
||||
- key: value1
|
||||
`,
|
||||
"/path/to/bases/repos.yaml": `repositories:
|
||||
- name: stable
|
||||
url: https://charts.helm.sh/stable
|
||||
helmBinary: /path/to/base/helm
|
||||
`,
|
||||
"/path/to/bases/releases.yaml": `releases:
|
||||
- name: myapp
|
||||
chart: stable/nginx
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: "/path/to/middle/helm",
|
||||
expectedKustomizeBinary: DefaultKustomizeBinary,
|
||||
},
|
||||
{
|
||||
name: "kustomizeBinary in base should be used",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/base.yaml
|
||||
`,
|
||||
"/path/to/bases/base.yaml": `kustomizeBinary: /path/to/custom/kustomize
|
||||
releases:
|
||||
- name: myapp
|
||||
chart: mychart
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: DefaultHelmBinary,
|
||||
expectedKustomizeBinary: "/path/to/custom/kustomize",
|
||||
},
|
||||
{
|
||||
name: "both helmBinary and kustomizeBinary in different bases",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/helm.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/kustomize.yaml
|
||||
`,
|
||||
"/path/to/bases/helm.yaml": `helmBinary: /path/to/custom/helm
|
||||
`,
|
||||
"/path/to/bases/kustomize.yaml": `kustomizeBinary: /path/to/custom/kustomize
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: "/path/to/custom/helm",
|
||||
expectedKustomizeBinary: "/path/to/custom/kustomize",
|
||||
},
|
||||
{
|
||||
name: "later base overrides earlier base for helmBinary",
|
||||
files: map[string]string{
|
||||
"/path/to/helmfile.yaml": `bases:
|
||||
- ./bases/first.yaml
|
||||
---
|
||||
bases:
|
||||
- ./bases/second.yaml
|
||||
`,
|
||||
"/path/to/bases/first.yaml": `helmBinary: /path/to/first/helm
|
||||
`,
|
||||
"/path/to/bases/second.yaml": `helmBinary: /path/to/second/helm
|
||||
`,
|
||||
},
|
||||
mainFile: "/path/to/helmfile.yaml",
|
||||
expectedHelmBinary: "/path/to/second/helm",
|
||||
expectedKustomizeBinary: DefaultKustomizeBinary,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testFs := testhelper.NewTestFs(tt.files)
|
||||
if testFs.Cwd == "" {
|
||||
testFs.Cwd = "/"
|
||||
}
|
||||
|
||||
r := remote.NewRemote(logger, testFs.Cwd, testFs.ToFileSystem())
|
||||
creator := NewCreator(logger, testFs.ToFileSystem(), nil, nil, "", "", r, false, "")
|
||||
|
||||
// Set up LoadFile for recursive base loading
|
||||
creator.LoadFile = func(inheritedEnv, overrodeEnv *environment.Environment, baseDir, file string, evaluateBases bool) (*HelmState, error) {
|
||||
path := filepath.Join(baseDir, file)
|
||||
content, ok := tt.files[path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
return creator.ParseAndLoad([]byte(content), filepath.Dir(path), path, DefaultEnv, true, evaluateBases, inheritedEnv, overrodeEnv)
|
||||
}
|
||||
|
||||
yamlContent, ok := tt.files[tt.mainFile]
|
||||
if !ok {
|
||||
t.Fatalf("no file named %q registered", tt.mainFile)
|
||||
}
|
||||
|
||||
state, err := creator.ParseAndLoad([]byte(yamlContent), filepath.Dir(tt.mainFile), tt.mainFile, DefaultEnv, true, true, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if state.DefaultHelmBinary != tt.expectedHelmBinary {
|
||||
t.Errorf("helmBinary mismatch: expected=%s, actual=%s",
|
||||
tt.expectedHelmBinary, state.DefaultHelmBinary)
|
||||
}
|
||||
|
||||
if state.DefaultKustomizeBinary != tt.expectedKustomizeBinary {
|
||||
t.Errorf("kustomizeBinary mismatch: expected=%s, actual=%s",
|
||||
tt.expectedKustomizeBinary, state.DefaultKustomizeBinary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+115
-12
@@ -43,6 +43,9 @@ const (
|
||||
// This is used by an interim solution to make the urfave/cli command report to the helmfile internal about that the
|
||||
// --timeout flag is missingl
|
||||
EmptyTimeout = -1
|
||||
|
||||
// Valid enum for updateStrategy values
|
||||
UpdateStrategyReinstallIfForbidden = "reinstallIfForbidden"
|
||||
)
|
||||
|
||||
// ReleaseSetSpec is release set spec
|
||||
@@ -277,6 +280,8 @@ type ReleaseSpec struct {
|
||||
Force *bool `yaml:"force,omitempty"`
|
||||
// Installed, when set to true, `delete --purge` the release
|
||||
Installed *bool `yaml:"installed,omitempty"`
|
||||
// UpdateStrategy, when set, indicate the strategy to use to update the release
|
||||
UpdateStrategy string `yaml:"updateStrategy,omitempty"`
|
||||
// Atomic, when set to true, restore previous state in case of a failed install/upgrade attempt
|
||||
Atomic *bool `yaml:"atomic,omitempty"`
|
||||
// CleanupOnFail, when set to true, the --cleanup-on-fail helm flag is passed to the upgrade command
|
||||
@@ -467,6 +472,7 @@ type SetValue struct {
|
||||
// AffectedReleases hold the list of released that where updated, deleted, or in error
|
||||
type AffectedReleases struct {
|
||||
Upgraded []*ReleaseSpec
|
||||
Reinstalled []*ReleaseSpec
|
||||
Deleted []*ReleaseSpec
|
||||
Failed []*ReleaseSpec
|
||||
DeleteFailed []*ReleaseSpec
|
||||
@@ -1037,20 +1043,24 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme
|
||||
}
|
||||
m.Unlock()
|
||||
}
|
||||
} else if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil {
|
||||
m.Lock()
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
m.Unlock()
|
||||
relErr = newReleaseFailedError(release, err)
|
||||
} else if release.UpdateStrategy == UpdateStrategyReinstallIfForbidden {
|
||||
relErr = st.performSyncOrReinstallOfRelease(affectedReleases, helm, context, release, chart, m, flags...)
|
||||
} else {
|
||||
m.Lock()
|
||||
affectedReleases.Upgraded = append(affectedReleases.Upgraded, release)
|
||||
m.Unlock()
|
||||
installedVersion, err := st.getDeployedVersion(context, helm, release)
|
||||
if err != nil { // err is not really impacting so just log it
|
||||
st.logger.Debugf("getting deployed release version failed: %v", err)
|
||||
if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil {
|
||||
m.Lock()
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
m.Unlock()
|
||||
relErr = newReleaseFailedError(release, err)
|
||||
} else {
|
||||
release.installedVersion = installedVersion
|
||||
m.Lock()
|
||||
affectedReleases.Upgraded = append(affectedReleases.Upgraded, release)
|
||||
m.Unlock()
|
||||
installedVersion, err := st.getDeployedVersion(context, helm, release)
|
||||
if err != nil { // err is not really impacting so just log it
|
||||
st.logger.Debugf("getting deployed release version failed: %v", err)
|
||||
} else {
|
||||
release.installedVersion = installedVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1096,6 +1106,77 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *HelmState) performSyncOrReinstallOfRelease(affectedReleases *AffectedReleases, helm helmexec.Interface, context helmexec.HelmContext, release *ReleaseSpec, chart string, m *sync.Mutex, flags ...string) *ReleaseError {
|
||||
if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil {
|
||||
st.logger.Debugf("update strategy - sync failed: %s", err.Error())
|
||||
// Only fail if a different error than forbidden updates
|
||||
if !strings.Contains(err.Error(), "Forbidden: updates") {
|
||||
st.logger.Debugf("update strategy - sync failed not due to Forbidden updates")
|
||||
m.Lock()
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
m.Unlock()
|
||||
return newReleaseFailedError(release, err)
|
||||
}
|
||||
} else {
|
||||
st.logger.Debugf("update strategy - sync success")
|
||||
m.Lock()
|
||||
affectedReleases.Upgraded = append(affectedReleases.Upgraded, release)
|
||||
m.Unlock()
|
||||
installedVersion, err := st.getDeployedVersion(context, helm, release)
|
||||
if err != nil { // err is not really impacting so just log it
|
||||
st.logger.Debugf("update strategy - getting deployed release version failed: %v", err)
|
||||
} else {
|
||||
release.installedVersion = installedVersion
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
st.logger.Infof("Failed to sync due to forbidden updates, attempting to reinstall %q allowed by update strategy", release.Name)
|
||||
installed, err := st.isReleaseInstalled(context, helm, *release)
|
||||
if err != nil {
|
||||
return newReleaseFailedError(release, err)
|
||||
}
|
||||
if installed {
|
||||
var args []string
|
||||
if release.Namespace != "" {
|
||||
args = append(args, "--namespace", release.Namespace)
|
||||
}
|
||||
deleteWaitFlag := true
|
||||
release.DeleteWait = &deleteWaitFlag
|
||||
args = st.appendDeleteWaitFlags(args, release)
|
||||
deletionFlags := st.appendConnectionFlags(args, release)
|
||||
m.Lock()
|
||||
if _, err := st.triggerReleaseEvent("preuninstall", nil, release, "sync"); err != nil {
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
return newReleaseFailedError(release, err)
|
||||
} else if err := helm.DeleteRelease(context, release.Name, deletionFlags...); err != nil {
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
return newReleaseFailedError(release, err)
|
||||
} else if _, err := st.triggerReleaseEvent("postuninstall", nil, release, "sync"); err != nil {
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
return newReleaseFailedError(release, err)
|
||||
}
|
||||
m.Unlock()
|
||||
}
|
||||
if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil {
|
||||
m.Lock()
|
||||
affectedReleases.Failed = append(affectedReleases.Failed, release)
|
||||
m.Unlock()
|
||||
return newReleaseFailedError(release, err)
|
||||
} else {
|
||||
m.Lock()
|
||||
affectedReleases.Reinstalled = append(affectedReleases.Reinstalled, release)
|
||||
m.Unlock()
|
||||
installedVersion, err := st.getDeployedVersion(context, helm, release)
|
||||
if err != nil { // err is not really impacting so just log it
|
||||
st.logger.Debugf("update strategy - getting deployed release version failed: %v", err)
|
||||
} else {
|
||||
release.installedVersion = installedVersion
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *HelmState) listReleases(context helmexec.HelmContext, helm helmexec.Interface, release *ReleaseSpec) (string, error) {
|
||||
flags := st.kubeConnectionFlags(release)
|
||||
if release.Namespace != "" {
|
||||
@@ -3739,6 +3820,28 @@ func (ar *AffectedReleases) DisplayAffectedReleases(logger *zap.SugaredLogger) {
|
||||
}
|
||||
logger.Info(tbl.String())
|
||||
}
|
||||
if len(ar.Reinstalled) > 0 {
|
||||
logger.Info("\nREINSTALLED RELEASES:")
|
||||
tbl, _ := prettytable.NewTable(prettytable.Column{Header: "NAME"},
|
||||
prettytable.Column{Header: "NAMESPACE", MinWidth: 6},
|
||||
prettytable.Column{Header: "CHART", MinWidth: 6},
|
||||
prettytable.Column{Header: "VERSION", MinWidth: 6},
|
||||
prettytable.Column{Header: "DURATION", AlignRight: true},
|
||||
)
|
||||
tbl.Separator = " "
|
||||
for _, release := range ar.Reinstalled {
|
||||
modifiedChart, modErr := hideChartCredentials(release.Chart)
|
||||
if modErr != nil {
|
||||
logger.Warn("Could not modify chart credentials, %v", modErr)
|
||||
continue
|
||||
}
|
||||
err := tbl.AddRow(release.Name, release.Namespace, modifiedChart, release.installedVersion, release.duration.Round(time.Second))
|
||||
if err != nil {
|
||||
logger.Warn("Could not add row, %v", err)
|
||||
}
|
||||
}
|
||||
logger.Info(tbl.String())
|
||||
}
|
||||
if len(ar.Deleted) > 0 {
|
||||
logger.Info("\nDELETED RELEASES:")
|
||||
tbl, _ := prettytable.NewTable(prettytable.Column{Header: "NAME"},
|
||||
|
||||
@@ -1640,6 +1640,112 @@ func TestHelmState_SyncReleasesAffectedRealeases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmState_SyncReleasesAffectedReleasesWithReinstallIfForbidden(t *testing.T) {
|
||||
no := false
|
||||
tests := []struct {
|
||||
name string
|
||||
releases []ReleaseSpec
|
||||
installed []bool
|
||||
wantAffected exectest.Affected
|
||||
}{
|
||||
{
|
||||
name: "2 new",
|
||||
releases: []ReleaseSpec{
|
||||
{
|
||||
Name: "releaseNameFoo-forbidden",
|
||||
Chart: "foo",
|
||||
UpdateStrategy: "reinstallIfForbidden",
|
||||
},
|
||||
{
|
||||
Name: "releaseNameBar-forbidden",
|
||||
Chart: "foo",
|
||||
UpdateStrategy: "reinstallIfForbidden",
|
||||
},
|
||||
},
|
||||
wantAffected: exectest.Affected{
|
||||
Upgraded: []*exectest.Release{
|
||||
{Name: "releaseNameFoo-forbidden", Flags: []string{}},
|
||||
{Name: "releaseNameBar-forbidden", Flags: []string{}},
|
||||
},
|
||||
Reinstalled: nil,
|
||||
Deleted: nil,
|
||||
Failed: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "1 removed, 1 new, 1 reinstalled first new",
|
||||
releases: []ReleaseSpec{
|
||||
{
|
||||
Name: "releaseNameFoo-forbidden",
|
||||
Chart: "foo",
|
||||
UpdateStrategy: "reinstallIfForbidden",
|
||||
},
|
||||
{
|
||||
Name: "releaseNameBar",
|
||||
Chart: "foo",
|
||||
UpdateStrategy: "reinstallIfForbidden",
|
||||
Installed: &no,
|
||||
},
|
||||
{
|
||||
Name: "releaseNameFoo-forbidden",
|
||||
Chart: "foo",
|
||||
UpdateStrategy: "reinstallIfForbidden",
|
||||
},
|
||||
},
|
||||
installed: []bool{true, true, true},
|
||||
wantAffected: exectest.Affected{
|
||||
Upgraded: []*exectest.Release{
|
||||
{Name: "releaseNameFoo-forbidden", Flags: []string{}},
|
||||
},
|
||||
Reinstalled: []*exectest.Release{
|
||||
{Name: "releaseNameFoo-forbidden", Flags: []string{}},
|
||||
},
|
||||
Deleted: []*exectest.Release{
|
||||
{Name: "releaseNameBar", Flags: []string{}},
|
||||
},
|
||||
Failed: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := &HelmState{
|
||||
ReleaseSetSpec: ReleaseSetSpec{
|
||||
Releases: tt.releases,
|
||||
},
|
||||
logger: logger,
|
||||
valsRuntime: valsRuntime,
|
||||
RenderedValues: map[string]any{},
|
||||
}
|
||||
helm := &exectest.Helm{
|
||||
Lists: map[exectest.ListKey]string{},
|
||||
}
|
||||
//simulate the release is already installed
|
||||
for i, release := range tt.releases {
|
||||
if tt.installed != nil && tt.installed[i] {
|
||||
helm.Lists[exectest.ListKey{Filter: "^" + release.Name + "$", Flags: "--uninstalling --deployed --failed --pending"}] = release.Name
|
||||
}
|
||||
}
|
||||
|
||||
affectedReleases := AffectedReleases{}
|
||||
if err := state.SyncReleases(&affectedReleases, helm, []string{}, 1); err != nil {
|
||||
if !testEq(affectedReleases.Failed, tt.wantAffected.Failed) {
|
||||
t.Errorf("HelmState.SyncReleases() error failed for [%s] = %v, want %v", tt.name, affectedReleases.Failed, tt.wantAffected.Failed)
|
||||
} //else expected error
|
||||
}
|
||||
if !testEq(affectedReleases.Upgraded, tt.wantAffected.Upgraded) {
|
||||
t.Errorf("HelmState.SyncReleases() upgrade failed for [%s] = %v, want %v", tt.name, affectedReleases.Upgraded, tt.wantAffected.Upgraded)
|
||||
}
|
||||
if !testEq(affectedReleases.Reinstalled, tt.wantAffected.Reinstalled) {
|
||||
t.Errorf("HelmState.SyncReleases() reinstalled failed for [%s] = %v, want %v", tt.name, affectedReleases.Reinstalled, tt.wantAffected.Reinstalled)
|
||||
}
|
||||
if !testEq(affectedReleases.Deleted, tt.wantAffected.Deleted) {
|
||||
t.Errorf("HelmState.SyncReleases() deleted failed for [%s] = %v, want %v", tt.name, affectedReleases.Deleted, tt.wantAffected.Deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testEq(a []*ReleaseSpec, b []*exectest.Release) bool {
|
||||
// If one is nil, the other must also be nil.
|
||||
if (a == nil) != (b == nil) {
|
||||
|
||||
@@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) {
|
||||
run(testcase{
|
||||
subject: "baseline",
|
||||
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
|
||||
want: "foo-values-7d454b9558",
|
||||
want: "foo-values-67dc97cbcb",
|
||||
})
|
||||
|
||||
run(testcase{
|
||||
subject: "different bytes content",
|
||||
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
|
||||
data: []byte(`{"k":"v"}`),
|
||||
want: "foo-values-59c86d55bf",
|
||||
want: "foo-values-75d7c4758c",
|
||||
})
|
||||
|
||||
run(testcase{
|
||||
subject: "different map content",
|
||||
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
|
||||
data: map[string]any{"k": "v"},
|
||||
want: "foo-values-6f87c5cd79",
|
||||
want: "foo-values-685f8cf685",
|
||||
})
|
||||
|
||||
run(testcase{
|
||||
subject: "different chart",
|
||||
release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"},
|
||||
want: "foo-values-5dfd748475",
|
||||
want: "foo-values-75597d9c57",
|
||||
})
|
||||
|
||||
run(testcase{
|
||||
subject: "different name",
|
||||
release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"},
|
||||
want: "bar-values-858b9c55cc",
|
||||
want: "bar-values-7b77df65ff",
|
||||
})
|
||||
|
||||
run(testcase{
|
||||
subject: "specific ns",
|
||||
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"},
|
||||
want: "myns-foo-values-58dc9c6667",
|
||||
want: "myns-foo-values-85f979545c",
|
||||
})
|
||||
|
||||
for id, n := range ids {
|
||||
|
||||
@@ -2,9 +2,11 @@ package testhelper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
ffs "github.com/helmfile/helmfile/pkg/filesystem"
|
||||
)
|
||||
@@ -17,6 +19,7 @@ type TestFs struct {
|
||||
GlobFixtures map[string][]string
|
||||
DeleteFile func(string) error
|
||||
|
||||
mu sync.Mutex
|
||||
fileReaderCalls int
|
||||
successfulReads []string
|
||||
}
|
||||
@@ -92,18 +95,26 @@ func (f *TestFs) ReadFile(filename string) ([]byte, error) {
|
||||
return []byte(nil), os.ErrNotExist
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.fileReaderCalls++
|
||||
|
||||
f.successfulReads = append(f.successfulReads, filename)
|
||||
f.mu.Unlock()
|
||||
|
||||
return []byte(str), nil
|
||||
}
|
||||
|
||||
func (f *TestFs) SuccessfulReads() []string {
|
||||
return f.successfulReads
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
// Return a copy to avoid race conditions with callers
|
||||
result := make([]string, len(f.successfulReads))
|
||||
copy(result, f.successfulReads)
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *TestFs) FileReaderCalls() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.fileReaderCalls
|
||||
}
|
||||
|
||||
@@ -155,3 +166,21 @@ func (f *TestFs) Chdir(dir string) error {
|
||||
}
|
||||
return fmt.Errorf("unexpected chdir \"%s\"", dir)
|
||||
}
|
||||
|
||||
// SyncWriter wraps an io.Writer to make it safe for concurrent use.
|
||||
type SyncWriter struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewSyncWriter creates a new thread-safe writer.
|
||||
func NewSyncWriter(w io.Writer) *SyncWriter {
|
||||
return &SyncWriter{w: w}
|
||||
}
|
||||
|
||||
// Write implements io.Writer in a thread-safe manner.
|
||||
func (sw *SyncWriter) Write(p []byte) (n int, err error) {
|
||||
sw.mu.Lock()
|
||||
defer sw.mu.Unlock()
|
||||
return sw.w.Write(p)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user