Merge remote-tracking branch 'origin/main' into fix-include-needs-transitive-1003

Resolve merge conflicts in pkg/app/app.go:
- WithPreparedCharts rename (withPreparedCharts -> WithPreparedCharts)
- WriteOutput handling for fetch command
- DetailedExitcode handling for sync command
- GetPlannedAndSelectedReleasesWithNeeds new function (pass both includeNeeds params)
- SyncState using GetPlannedAndSelectedReleasesWithNeeds

Also fix ForEachState call signatures in tests to include both bool params.
This commit is contained in:
copilot-swe-agent[bot]
2026-05-27 08:46:23 +00:00
committed by GitHub
133 changed files with 10958 additions and 2933 deletions
+275 -110
View File
@@ -162,10 +162,11 @@ func (a *App) Diff(c DiffConfigProvider) error {
includeCRDs := !c.SkipCRDs()
prepErr := run.withPreparedCharts("diff", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("diff", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
Validate: c.Validate(),
Concurrency: c.Concurrency(),
@@ -234,10 +235,11 @@ func (a *App) Template(c TemplateConfigProvider) error {
// https://github.com/helmfile/helmfile/issues/1749
run.helm.SetExtraArgs()
prepErr := run.withPreparedCharts("template", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("template", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
SkipCleanup: c.SkipCleanup(),
Validate: c.Validate(),
@@ -263,7 +265,7 @@ func (a *App) Template(c TemplateConfigProvider) error {
func (a *App) WriteValues(c WriteValuesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
prepErr := run.withPreparedCharts("write-values", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("write-values", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
@@ -317,7 +319,7 @@ func (a *App) Lint(c LintConfigProvider) error {
var lintErrs []error
// `helm lint` on helm v2 and v3 does not support remote charts, that we need to set `forceDownload=true` here
prepErr := run.withPreparedCharts("lint", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("lint", state.ChartPrepareOptions{
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
@@ -360,7 +362,7 @@ func (a *App) Unittest(c UnittestConfigProvider) error {
var unittestErrs []error
// helm unittest needs local charts, so force download
prepErr := run.withPreparedCharts("unittest", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("unittest", state.ChartPrepareOptions{
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
@@ -397,8 +399,50 @@ func (a *App) Unittest(c UnittestConfigProvider) error {
}
func (a *App) Fetch(c FetchConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
prepErr := run.withPreparedCharts("pull", state.ChartPrepareOptions{
if c.WriteOutput() && c.OutputDir() == "" {
return fmt.Errorf("--output-dir is required when --write-output is set")
}
if c.WriteOutput() {
// Force sequential processing to ensure YAML documents are emitted in order
// without interleaving when multiple helmfile state files are processed.
// Restore the original value when Fetch returns so the App instance is not
// permanently mutated (important for tests and library usage).
prev := a.SequentialHelmfiles
a.SequentialHelmfiles = true
defer func() { a.SequentialHelmfiles = prev }()
}
// processedStateFileCount tracks how many state files have been processed when
// --write-output is set; used to detect multi-file inputs early and return
// a clear error instead of silently producing semantically incorrect YAML.
var processedStateFileCount int
// yamlOutput buffers the generated YAML document so that nothing is written to
// stdout until ForEachState completes successfully. This prevents partial/corrupted
// output reaching stdout when a later state file (or chart download error) causes
// the operation to fail.
var yamlOutput strings.Builder
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
if c.WriteOutput() {
processedStateFileCount++
if processedStateFileCount > 1 {
return false, []error{fmt.Errorf(
"--write-output requires a single helmfile state file, but multiple were found; " +
"use -f to specify a single helmfile instead of a directory or a helmfile with nested helmfiles: entries",
)}
}
// Disable live output to avoid Helm progress/status lines being streamed
// to stdout and corrupting the YAML document emitted by --write-output.
// Restore the original value when this callback returns so the cached helm
// exec instance is not permanently mutated (important for tests and library usage).
run.helm.SetEnableLiveOutput(false)
defer run.helm.SetEnableLiveOutput(a.EnableLiveOutput)
}
prepErr := run.WithPreparedCharts("pull", state.ChartPrepareOptions{
ForceDownload: true,
SkipRefresh: c.SkipRefresh(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
@@ -407,6 +451,27 @@ func (a *App) Fetch(c FetchConfigProvider) error {
OutputDirTemplate: c.OutputDirTemplate(),
Concurrency: c.Concurrency(),
}, func() []error {
if c.WriteOutput() {
for i := range run.state.Releases {
rel := &run.state.Releases[i]
if rel.ChartPath != "" {
rel.Chart = rel.ChartPath
rel.ChartPath = ""
}
}
stateYaml, yamlErr := run.state.ToYaml()
if yamlErr != nil {
return []error{yamlErr}
}
sourceFile, pathErr := run.state.FullFilePath()
if pathErr != nil {
return []error{pathErr}
}
fmt.Fprintf(&yamlOutput, "---\n# Source: %s\n\n%s", sourceFile, stateYaml)
}
return nil
})
@@ -414,18 +479,29 @@ func (a *App) Fetch(c FetchConfigProvider) error {
errs = append(errs, prepErr)
}
return
return ok, errs
}, false, false, SetFilter(true))
if err == nil && c.WriteOutput() {
fmt.Print(yamlOutput.String())
}
return err
}
func (a *App) Sync(c SyncConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
var any bool
mut := &sync.Mutex{}
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := !c.SkipCRDs()
prepErr := run.withPreparedCharts("sync", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
@@ -435,7 +511,14 @@ func (a *App) Sync(c SyncConfigProvider) error {
Validate: c.Validate(),
Concurrency: c.Concurrency(),
}, func() []error {
ok, errs = a.sync(run, c)
matched, updated, es := a.SyncState(run, c)
mut.Lock()
any = any || updated
mut.Unlock()
ok = matched
errs = es
return errs
})
@@ -445,6 +528,18 @@ func (a *App) Sync(c SyncConfigProvider) error {
return
}, c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return err
}
if ec, ok := c.(interface{ DetailedExitcode() bool }); ok && ec.DetailedExitcode() && any {
code := 2
return &Error{msg: "", code: &code}
}
return nil
}
func (a *App) Apply(c ApplyConfigProvider) error {
@@ -459,10 +554,11 @@ func (a *App) Apply(c ApplyConfigProvider) error {
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
includeCRDs := !c.SkipCRDs()
prepErr := run.withPreparedCharts("apply", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("apply", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
WaitRetries: c.WaitRetries(),
WaitForJobs: c.WaitForJobs(),
@@ -505,7 +601,7 @@ func (a *App) Apply(c ApplyConfigProvider) error {
func (a *App) Status(c StatusesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
err := run.withPreparedCharts("status", state.ChartPrepareOptions{
err := run.WithPreparedCharts("status", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: c.Concurrency(),
@@ -525,7 +621,7 @@ func (a *App) Status(c StatusesConfigProvider) error {
func (a *App) Destroy(c DestroyConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
if !c.SkipCharts() {
err := run.withPreparedCharts("destroy", state.ChartPrepareOptions{
err := run.WithPreparedCharts("destroy", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
@@ -554,7 +650,7 @@ func (a *App) Test(c TestConfigProvider) error {
"or set helm.sh/hook-delete-policy\n")
}
err := run.withPreparedCharts("test", state.ChartPrepareOptions{
err := run.WithPreparedCharts("test", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
@@ -575,7 +671,7 @@ func (a *App) Test(c TestConfigProvider) error {
func (a *App) PrintDAGState(c DAGConfigProvider) error {
var err error
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
err = run.withPreparedCharts("show-dag", state.ChartPrepareOptions{
err = run.WithPreparedCharts("show-dag", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
@@ -592,7 +688,7 @@ func (a *App) PrintDAGState(c DAGConfigProvider) error {
func (a *App) PrintState(c StateConfigProvider) error {
return a.ForEachState(func(run *Run) (_ bool, errs []error) {
err := run.withPreparedCharts("build", state.ChartPrepareOptions{
err := run.WithPreparedCharts("build", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
@@ -665,7 +761,7 @@ func (a *App) ListReleases(c ListConfigProvider) error {
var listErr error
if !c.SkipCharts() {
prepErr := run.withPreparedCharts("list", state.ChartPrepareOptions{
prepErr := run.WithPreparedCharts("list", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
@@ -1015,6 +1111,11 @@ func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converg
func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) (bool, error) {
anyMatched := false
for i, m := range st.Helmfiles {
if subhelmfileSelectorsConflict(a.Selectors, m, a.Logger) {
a.Logger.Debugf("skipping subhelmfile %q: CLI selectors %v conflict with subhelmfile selectors %v", m.Path, a.Selectors, m.Selectors)
continue
}
optsForNestedState := LoadOpts{
CalleePath: filepath.Join(absd, file),
Environment: m.Environment,
@@ -1040,6 +1141,24 @@ func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, def
return anyMatched, nil
}
// subhelmfileSelectorsConflict returns true when the subhelmfile has explicit
// selectors that are provably incompatible with the CLI selectors,
// meaning no release could satisfy both. In that case the subhelmfile can be
// safely skipped without loading or evaluating it.
// Only CLI selectors (not inherited parent selectors) are used for comparison,
// so this optimization is restricted to cases where the user explicitly
// provided selectors via the command line (e.g. -l name=b).
func subhelmfileSelectorsConflict(cliSelectors []string, m state.SubHelmfileSpec, logger *zap.SugaredLogger) bool {
if len(cliSelectors) == 0 || len(m.Selectors) == 0 || m.SelectorsInherited {
return false
}
compatible, err := state.SelectorsAreCompatible(cliSelectors, m.Selectors)
if err != nil {
logger.Debugf("selector compatibility check failed for subhelmfile %q: %v", m.Path, err)
}
return !compatible
}
func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error {
noMatchInHelmfiles := true
@@ -1370,14 +1489,12 @@ func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir stri
for _, v := range a.ValuesFiles {
envvals = append(envvals, v)
}
if len(a.Set) > 0 {
envvals = append(envvals, a.Set)
}
if len(envvals) > 0 {
opts.Environment.OverrideValues = envvals
opts.Environment.OverrideValuesAreCLI = true
}
if len(a.Set) > 0 {
opts.Environment.OverrideCLISetValues = []any{a.Set}
}
a.remote = remote.NewRemote(a.Logger, "", a.fs)
@@ -1632,42 +1749,63 @@ func (a *App) getSelectedReleases(r *Run, includeNeeds bool, includeTransitiveNe
return selected, deduplicated, nil
}
func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
// GetPlannedAndSelectedReleasesWithNeeds returns the planned releases and the selected releases used for planning.
// The planned releases include dependency releases only when includeNeeds is true and skipNeeds is false.
func (a *App) GetPlannedAndSelectedReleasesWithNeeds(r *Run, skipNeeds bool, includeNeeds bool, includeTransitiveNeeds bool) ([]state.ReleaseSpec, []state.ReleaseSpec, error) {
st := r.state
helm := r.helm
helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, c.IncludeNeeds(), c.IncludeTransitiveNeeds())
selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, includeNeeds, includeTransitiveNeeds)
if err != nil {
return false, false, []error{err}
return nil, nil, err
}
if len(selectedReleases) == 0 {
return false, false, nil
return nil, nil, nil
}
// This is required when you're trying to deduplicate releases by the selector.
// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
// even if we provided `SelectedReleases: selectedReleases`.
// See https://github.com/roboll/helmfile/issues/1818 for more context.
originalReleases := st.Releases
st.Releases = selectedAndNeededReleases
defer func() {
st.Releases = originalReleases
}()
plan, err := st.PlanReleases(state.PlanOptions{Reverse: false, SelectedReleases: selectedReleases, SkipNeeds: c.SkipNeeds(), IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()})
batches, err := st.PlanReleases(state.PlanOptions{Reverse: false, SelectedReleases: selectedReleases, SkipNeeds: skipNeeds, IncludeNeeds: includeNeeds, IncludeTransitiveNeeds: includeTransitiveNeeds})
if err != nil {
return nil, nil, err
}
var releasesWithNeeds []state.ReleaseSpec
for _, rs := range batches {
for _, r := range rs {
releasesWithNeeds = append(releasesWithNeeds, r.ReleaseSpec)
}
}
return releasesWithNeeds, selectedAndNeededReleases, nil
}
func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
st := r.state
helm := r.helm
helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return false, false, []error{err}
}
var toApplyWithNeeds []state.ReleaseSpec
for _, rs := range plan {
for _, r := range rs {
toApplyWithNeeds = append(toApplyWithNeeds, r.ReleaseSpec)
}
if len(releasesWithNeeds) == 0 {
return false, false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = toApplyWithNeeds
st.Releases = releasesWithNeeds
// helm must be 2.11+ and helm-diff should be provided `--detailed-exitcode` in order for `helmfile apply` to work properly
detailedExitCode := true
@@ -1693,27 +1831,27 @@ func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
DetectedKubeVersion: detectedKubeVersion,
}
infoMsg, releasesToBeUpdated, releasesToBeDeleted, errs := r.diff(false, detailedExitCode, c, diffOpts)
if len(errs) > 0 {
return false, false, errs
infoMsg, releasesToUpdate, releasesToDelete, diffErrs := r.diff(false, detailedExitCode, c, diffOpts)
if len(diffErrs) > 0 {
return false, false, diffErrs
}
var toDelete []state.ReleaseSpec
for _, r := range releasesToBeDeleted {
for _, r := range releasesToDelete {
toDelete = append(toDelete, r)
}
var toUpdate []state.ReleaseSpec
for _, r := range releasesToBeUpdated {
for _, r := range releasesToUpdate {
toUpdate = append(toUpdate, r)
}
releasesWithNoChange := map[string]state.ReleaseSpec{}
for _, r := range toApplyWithNeeds {
for _, r := range releasesWithNeeds {
release := r
id := state.ReleaseToID(&release)
_, uninstalled := releasesToBeDeleted[id]
_, updated := releasesToBeUpdated[id]
_, uninstalled := releasesToDelete[id]
_, updated := releasesToUpdate[id]
if !uninstalled && !updated {
releasesWithNoChange[id] = release
}
@@ -1735,19 +1873,18 @@ Do you really want to apply?
a.Logger.Debug(infoMsgStr)
}
var applyErrs []error
affectedReleases := state.AffectedReleases{}
var errs []error
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = selectedAndNeededReleases
if len(releasesToBeUpdated) == 0 && len(releasesToBeDeleted) == 0 {
if len(releasesToUpdate) == 0 && len(releasesToDelete) == 0 {
return true, false, nil
}
affectedReleases := state.AffectedReleases{}
if !interactive || interactive && r.askForConfirmation(confMsg) {
if _, preapplyErrors := withDAG(st, helm, a.Logger, state.PlanOptions{Purpose: "invoking preapply hooks for", Reverse: true, SelectedReleases: toApplyWithNeeds, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
if _, preapplyErrors := withDAG(st, helm, a.Logger, state.PlanOptions{Purpose: "invoking preapply hooks for", Reverse: true, SelectedReleases: releasesWithNeeds, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
for _, r := range subst.Releases {
release := r
if _, err := st.TriggerPreapplyEvent(&release, "apply"); err != nil {
@@ -1761,13 +1898,13 @@ Do you really want to apply?
}
// We deleted releases by traversing the DAG in reverse order
if len(releasesToBeDeleted) > 0 {
if len(releasesToDelete) > 0 {
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if r2, ok := releasesToBeDeleted[state.ReleaseToID(&release)]; ok {
if r2, ok := releasesToDelete[state.ReleaseToID(&release)]; ok {
rs = append(rs, r2)
}
}
@@ -1778,18 +1915,18 @@ Do you really want to apply?
}))
if len(deletionErrs) > 0 {
applyErrs = append(applyErrs, deletionErrs...)
errs = append(errs, deletionErrs...)
}
}
// We upgrade releases by traversing the DAG
if len(releasesToBeUpdated) > 0 {
_, updateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, Reverse: false, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
if len(releasesToUpdate) > 0 {
_, updateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
for _, r := range subst.Releases {
release := r
if r2, ok := releasesToBeUpdated[state.ReleaseToID(&release)]; ok {
if r2, ok := releasesToUpdate[state.ReleaseToID(&release)]; ok {
rs = append(rs, r2)
}
}
@@ -1816,13 +1953,14 @@ Do you really want to apply?
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
}
return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
}))
if len(updateErrs) > 0 {
applyErrs = append(applyErrs, updateErrs...)
errs = append(errs, updateErrs...)
}
}
}
@@ -1835,11 +1973,11 @@ Do you really want to apply?
a.Logger.Warnf("warn: %v\n", err)
}
}
if releasesToBeDeleted == nil && releasesToBeUpdated == nil {
if releasesToDelete == nil && releasesToUpdate == nil {
return true, false, nil
}
return true, true, applyErrs
return true, true, errs
}
func (a *App) delete(r *Run, purge bool, c DestroyConfigProvider) (bool, []error) {
@@ -2113,44 +2251,25 @@ func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) {
return true, errs
}
func (a *App) sync(r *Run, c SyncConfigProvider) (bool, []error) {
func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) {
st := r.state
helm := r.helm
selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, c.IncludeNeeds(), c.IncludeTransitiveNeeds())
releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
if err != nil {
return false, []error{err}
return false, false, []error{err}
}
if len(selectedReleases) == 0 {
return false, nil
}
// This is required when you're trying to deduplicate releases by the selector.
// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
// even if we provided `SelectedReleases: selectedReleases`.
// See https://github.com/roboll/helmfile/issues/1818 for more context.
st.Releases = selectedAndNeededReleases
batches, err := st.PlanReleases(state.PlanOptions{Reverse: false, SelectedReleases: selectedReleases, IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(), SkipNeeds: c.SkipNeeds()})
if err != nil {
return false, []error{err}
}
var toSyncWithNeeds []state.ReleaseSpec
for _, rs := range batches {
for _, r := range rs {
toSyncWithNeeds = append(toSyncWithNeeds, r.ReleaseSpec)
}
if len(releasesWithNeeds) == 0 {
return false, false, nil
}
// Do build deps and prepare only on selected releases so that we won't waste time
// on running various helm commands on unnecessary releases
st.Releases = toSyncWithNeeds
st.Releases = releasesWithNeeds
toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, toSyncWithNeeds)
toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, releasesWithNeeds)
if err != nil {
return false, []error{err}
return false, false, []error{err}
}
releasesToDelete := map[string]state.ReleaseSpec{}
@@ -2161,7 +2280,7 @@ func (a *App) sync(r *Run, c SyncConfigProvider) (bool, []error) {
}
var toUpdate []state.ReleaseSpec
for _, r := range toSyncWithNeeds {
for _, r := range releasesWithNeeds {
release := r
if _, deleted := releasesToDelete[state.ReleaseToID(&release)]; !deleted {
if r.Desired() {
@@ -2181,7 +2300,7 @@ func (a *App) sync(r *Run, c SyncConfigProvider) (bool, []error) {
}
releasesWithNoChange := map[string]state.ReleaseSpec{}
for _, r := range toSyncWithNeeds {
for _, r := range releasesWithNeeds {
release := r
id := state.ReleaseToID(&release)
_, uninstalled := releasesToDelete[id]
@@ -2191,13 +2310,6 @@ func (a *App) sync(r *Run, c SyncConfigProvider) (bool, []error) {
}
}
for id := range releasesWithNoChange {
r := releasesWithNoChange[id]
if _, err := st.TriggerCleanupEvent(&r, "sync"); err != nil {
a.Logger.Warnf("warn: %v\n", err)
}
}
names := []string{}
for _, r := range releasesToUpdate {
names = append(names, fmt.Sprintf(" %s (%s) UPDATED", r.Name, r.Chart))
@@ -2208,9 +2320,57 @@ func (a *App) sync(r *Run, c SyncConfigProvider) (bool, []error) {
// Make the output deterministic for testing purpose
sort.Strings(names)
infoMsg := fmt.Sprintf(`Affected releases are:
interactive := c.Interactive()
var infoMsg string
var errs []error
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
operationsAttempted := false
if interactive {
if diffC, ok := c.(DiffConfigProvider); ok {
detectedKubeVersion := a.detectKubeVersion(st)
diffOpts := &state.DiffOpts{
Context: diffC.Context(),
Output: diffC.DiffOutput(),
Color: diffC.Color(),
NoColor: diffC.NoColor(),
Set: diffC.Set(),
DiffArgs: diffC.DiffArgs(),
SkipDiffOnInstall: diffC.SkipDiffOnInstall(),
ReuseValues: diffC.ReuseValues(),
ResetValues: diffC.ResetValues(),
PostRenderer: diffC.PostRenderer(),
PostRendererArgs: diffC.PostRendererArgs(),
SkipSchemaValidation: diffC.SkipSchemaValidation(),
SuppressOutputLineRegex: diffC.SuppressOutputLineRegex(),
TakeOwnership: diffC.TakeOwnership(),
DetectedKubeVersion: detectedKubeVersion,
}
infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts)
if len(diffErrs) > 0 {
return false, false, diffErrs
}
if infoMsgPtr != nil {
infoMsg = *infoMsgPtr
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
}
} else {
infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
a.Logger.Debug(infoMsg)
}
confMsg := fmt.Sprintf(`%s
Do you really want to sync?
@@ -2218,15 +2378,6 @@ Do you really want to sync?
`, infoMsg)
interactive := c.Interactive()
if !interactive {
a.Logger.Debug(infoMsg)
}
var errs []error
r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)
// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
st.Releases = selectedAndNeededReleases
@@ -2234,6 +2385,7 @@ Do you really want to sync?
if !interactive || interactive && r.askForConfirmation(confMsg) {
if len(releasesToDelete) > 0 {
operationsAttempted = true
_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
@@ -2255,6 +2407,7 @@ Do you really want to sync?
}
if len(releasesToUpdate) > 0 {
operationsAttempted = true
_, syncErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
var rs []state.ReleaseSpec
@@ -2267,7 +2420,7 @@ Do you really want to sync?
subst.Releases = rs
opts := &state.SyncOpts{
syncOpts := &state.SyncOpts{
Set: c.Set(),
SkipCRDs: c.SkipCRDs(),
Wait: c.Wait(),
@@ -2286,9 +2439,10 @@ Do you really want to sync?
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
}
return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), opts)
return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
}))
if len(syncErrs) > 0 {
@@ -2296,8 +2450,19 @@ Do you really want to sync?
}
}
}
affectedReleases.DisplayAffectedReleases(c.Logger())
return true, errs
for id := range releasesWithNoChange {
r := releasesWithNoChange[id]
if _, err := st.TriggerCleanupEvent(&r, "sync"); err != nil {
a.Logger.Warnf("warn: %v\n", err)
}
}
changesApplied := operationsAttempted && len(errs) == 0
return true, changesApplied, errs
}
func (a *App) template(r *Run, c TemplateConfigProvider) (bool, []error) {
+219 -1
View File
@@ -14,6 +14,187 @@ import (
"github.com/helmfile/helmfile/pkg/helmexec"
)
func TestSyncInteractive(t *testing.T) {
type testcase struct {
interactive bool
confirm bool
error string
files map[string]string
selectors []string
lists map[exectest.ListKey]string
diffs map[exectest.DiffKey]error
wantDiffs int
upgraded []exectest.Release
deleted []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
wantUpgrades := tc.upgraded
wantDeletes := tc.deleted
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
Lists: tc.lists,
Diffs: tc.diffs,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
bs := runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, tc.files)
if tc.selectors != nil {
app.Selectors = tc.selectors
}
// Use ForEachState to gain access to the Run so we can inject Ask
forEachErr := app.ForEachState(func(run *Run) (bool, []error) {
run.Ask = func(msg string) bool {
return tc.confirm
}
ok, _, errs := app.SyncState(run, applyConfig{
concurrency: 1,
interactive: tc.interactive,
skipNeeds: true,
logger: logger,
})
return ok, errs
}, false, false)
var gotErr string
if forEachErr != nil {
gotErr = forEachErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
if len(wantUpgrades) > len(helm.Releases) {
t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades))
}
for relIdx := range wantUpgrades {
if wantUpgrades[relIdx].Name != helm.Releases[relIdx].Name {
t.Errorf("releases[%d].name: got %q, want %q", relIdx, helm.Releases[relIdx].Name, wantUpgrades[relIdx].Name)
}
for flagIdx := range wantUpgrades[relIdx].Flags {
if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] {
t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
}
}
}
if len(helm.Diffed) != tc.wantDiffs {
t.Fatalf("unexpected number of diffs: got %d, want %d", len(helm.Diffed), tc.wantDiffs)
}
if len(wantDeletes) > len(helm.Deleted) {
t.Fatalf("insufficient number of deletes: got %d, want %d", len(helm.Deleted), len(wantDeletes))
}
})
_ = bs
}
t.Run("non-interactive: sync proceeds without diff", func(t *testing.T) {
check(t, testcase{
interactive: false,
confirm: false,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("interactive with diff: user confirms", func(t *testing.T) {
check(t, testcase{
interactive: true,
confirm: true,
wantDiffs: 1,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
diffs: map[exectest.DiffKey]error{
{Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("interactive with diff: user rejects", func(t *testing.T) {
check(t, testcase{
interactive: true,
confirm: false,
wantDiffs: 1,
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
upgraded: []exectest.Release{},
diffs: map[exectest.DiffKey]error{
{Name: "my-release", Chart: "incubator/raw", Flags: "--kube-context default --namespace default --reset-values"}: helmexec.ExitError{Code: 2},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
}
func TestSync(t *testing.T) {
type fields struct {
skipNeeds bool
@@ -27,7 +208,9 @@ func TestSync(t *testing.T) {
concurrency int
timeout int
skipDiffOnInstall bool
detailedExitcode bool
error string
errorCode int
files map[string]string
selectors []string
lists map[exectest.ListKey]string
@@ -88,6 +271,7 @@ func TestSync(t *testing.T) {
skipNeeds: tc.fields.skipNeeds,
includeNeeds: tc.fields.includeNeeds,
includeTransitiveNeeds: tc.fields.includeTransitiveNeeds,
detailedExitcode: tc.detailedExitcode,
})
var gotErr string
@@ -99,6 +283,16 @@ func TestSync(t *testing.T) {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
if tc.errorCode >= 0 {
var gotCode int
if appErr, ok := syncErr.(*Error); ok && appErr != nil {
gotCode = appErr.Code()
}
if tc.errorCode != gotCode {
t.Fatalf("unexpected error code: got %d, want %d", gotCode, tc.errorCode)
}
}
if len(wantUpgrades) > len(helm.Releases) {
t.Fatalf("insufficient number of upgrades: got %d, want %d", len(helm.Releases), len(wantUpgrades))
}
@@ -109,7 +303,7 @@ func TestSync(t *testing.T) {
}
for flagIdx := range wantUpgrades[relIdx].Flags {
if wantUpgrades[relIdx].Flags[flagIdx] != helm.Releases[relIdx].Flags[flagIdx] {
t.Errorf("releaes[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
t.Errorf("releases[%d].flags[%d]: got %v, want %v", relIdx, flagIdx, helm.Releases[relIdx].Flags[flagIdx], wantUpgrades[relIdx].Flags[flagIdx])
}
}
}
@@ -499,6 +693,30 @@ releases:
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
})
t.Run("detailed-exitcode returns exit code 2 on successful sync", func(t *testing.T) {
check(t, testcase{
files: map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: my-release
chart: incubator/raw
namespace: default
`,
},
detailedExitcode: true,
errorCode: 2,
concurrency: 1,
upgraded: []exectest.Release{
{Name: "my-release", Flags: []string{"--kube-context", "default", "--namespace", "default"}},
},
lists: map[exectest.ListKey]string{
{Filter: "^my-release$", Flags: listFlags("default", "default")}: `NAME REVISION UPDATED STATUS CHART APP VERSION NAMESPACE
my-release 4 Fri Nov 1 08:40:07 2019 DEPLOYED raw-3.1.0 3.1.0 default
`,
},
})
+236
View File
@@ -527,3 +527,239 @@ releases:
})
})
}
func TestTemplate_DefaultInherit(t *testing.T) {
type testcase struct {
error string
templated []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
templates:
default:
namespace: default-ns
labels:
managed: "true"
defaultInherit: default
releases:
- name: app1
chart: incubator/raw
- name: app2
chart: incubator/raw
inherit:
- template: default
except:
- labels
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
require.Equal(t, tc.templated, helm.Templated)
})
}
t.Run("default inherit applies template to all releases", func(t *testing.T) {
check(t, testcase{
templated: []exectest.Release{
{Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}},
{Name: "app2", Flags: []string{"--kube-context", "default", "--namespace", "default-ns"}},
},
})
})
}
func TestTemplate_DefaultInherit_Multiple(t *testing.T) {
type testcase struct {
error string
templated []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
templates:
ns:
namespace: from-ns-template
override:
namespace: from-ctx-template
defaultInherit:
- ns
- override
releases:
- name: app1
chart: incubator/raw
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
require.Equal(t, tc.templated, helm.Templated)
})
}
t.Run("multiple default inherits are applied in order", func(t *testing.T) {
check(t, testcase{
templated: []exectest.Release{
{Name: "app1", Flags: []string{"--kube-context", "default", "--namespace", "from-ctx-template"}},
},
})
})
}
func TestTemplate_DefaultInherit_NonExistent(t *testing.T) {
type testcase struct {
error string
}
check := func(t *testing.T, tc testcase) {
t.Helper()
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
defaultInherit: nonexistent
releases:
- name: app1
chart: incubator/raw
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: logger,
})
var gotErr string
if tmplErr != nil {
gotErr = tmplErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
})
}
t.Run("fail due to non-existent template in defaultInherit", func(t *testing.T) {
check(t, testcase{
error: `in ./helmfile.yaml: failed executing release templates in "helmfile.yaml": release "app1" tried to inherit inexistent release template "nonexistent"`,
})
})
}
+542 -7
View File
@@ -731,8 +731,8 @@ releases:
}{
{label: "duplicatedOK=yes", expectedReleases: []string{"zipkin", "prometheus", "bar", "bar", "grafana", "postgresql"}, expectErr: false},
{label: "name=zipkin", expectedReleases: []string{"zipkin", "prometheus", "grafana", "postgresql"}, expectErr: false},
{label: "name=grafana", expectedReleases: []string{"zipkin", "prometheus", "grafana", "grafana", "postgresql"}, expectErr: false},
{label: "name=doesnotexists", expectedReleases: []string{"zipkin", "prometheus", "grafana", "postgresql"}, expectErr: false},
{label: "name=grafana", expectedReleases: []string{"grafana", "grafana", "postgresql"}, expectErr: false},
{label: "name=doesnotexists", expectedReleases: []string{"grafana", "postgresql"}, expectErr: false},
}
runFilterSubHelmFilesTests(legacyTestcases, files, t, "1st EmbeddedSelectors")
@@ -744,7 +744,7 @@ releases:
errMsg string
}{
{label: "duplicatedOK=yes", expectedReleases: []string{"zipkin", "prometheus", "grafana", "bar", "bar", "grafana", "postgresql"}, expectErr: false},
{label: "name=doesnotexists", expectedReleases: []string{"zipkin", "prometheus", "grafana", "bar", "bar", "grafana", "postgresql"}, expectErr: false},
{label: "name=doesnotexists", expectedReleases: []string{"grafana", "bar", "bar", "grafana", "postgresql"}, expectErr: false},
}
t.Setenv(envvar.Experimental, ExperimentalSelectorExplicit)
@@ -860,6 +860,89 @@ releases:
runFilterSubHelmFilesTests(desiredTestcases, files, t, "2nd inherits")
}
func TestSubhelmfileSelectorsConflict(t *testing.T) {
tests := []struct {
name string
cliSelectors []string
spec state.SubHelmfileSpec
conflict bool
}{
{
name: "no CLI selectors",
cliSelectors: nil,
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a"}},
conflict: false,
},
{
name: "no subhelmfile selectors",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: nil},
conflict: false,
},
{
name: "selectorsInherited true",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a"}, SelectorsInherited: true},
conflict: false,
},
{
name: "conflicting selectors",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a"}},
conflict: true,
},
{
name: "matching selectors",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "b.yaml", Selectors: []string{"name=b"}},
conflict: false,
},
{
name: "different keys no conflict",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"env=prod"}},
conflict: false,
},
{
name: "empty CLI selectors slice",
cliSelectors: []string{},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a"}},
conflict: false,
},
{
name: "empty subhelmfile selectors slice",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{}},
conflict: false,
},
{
name: "all pairs conflict with multiple subhelmfile selectors",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a", "name=c"}},
conflict: true,
},
{
name: "one matching pair among multiple subhelmfile selectors",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a", "name=b"}},
conflict: false,
},
{
name: "selectorsInherited false with conflicting selectors still conflicts",
cliSelectors: []string{"name=b"},
spec: state.SubHelmfileSpec{Path: "a.yaml", Selectors: []string{"name=a"}, SelectorsInherited: false},
conflict: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := subhelmfileSelectorsConflict(tt.cliSelectors, tt.spec, newAppTestLogger())
assert.Equal(t, tt.conflict, got)
})
}
}
func runFilterSubHelmFilesTests(testcases []struct {
label string
expectedReleases []string
@@ -1297,6 +1380,67 @@ x:
}
}
// TestStateValuesFileArrayReplace verifies that arrays in --state-values-file
// replace environment default arrays entirely rather than merging element-by-element.
// Regression test for https://github.com/helmfile/helmfile/issues/2536
func TestStateValuesFileArrayReplace(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml.gotmpl": `
environments:
default:
values:
- env-defaults.yaml
---
releases:
- name: {{ .Values.list | join "," }}
chart: stable/noop
`,
// Environment default defines a list with two elements
"/path/to/env-defaults.yaml": `
list:
- first
- second
`,
// --state-values-file overrides the list with a single element
// This should REPLACE the list, not merge element-by-element.
"/path/to/overrides.yaml": `
list:
- second
`,
}
actual := []state.ReleaseSpec{}
collectReleases := func(run *Run) (bool, []error) {
actual = append(actual, run.state.Releases...)
return false, []error{}
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Logger: newAppTestLogger(),
Selectors: []string{},
Env: "default",
ValuesFiles: []string{"overrides.yaml"},
FileOrDir: "helmfile.yaml.gotmpl",
}, files)
expectNoCallsToHelm(app)
err := app.ForEachState(collectReleases, false, false, SetFilter(true))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(actual) != 1 {
t.Fatalf("expected 1 release, got %d", len(actual))
}
// list should be ["second"] (replaced), not ["second","second"] (merged by position)
if actual[0].Name != "second" {
t.Errorf("expected release name %q (list replaced), got %q (list was merged)", "second", actual[0].Name)
}
}
func TestVisitDesiredStatesWithReleasesFiltered_ChartAtAbsPath(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml": `
@@ -2241,6 +2385,8 @@ type configImpl struct {
enforceNeedsAreInstalled bool
skipCharts bool
kubeVersion string
postRenderer string
postRendererArgs []string
}
func (c configImpl) Selectors() []string {
@@ -2328,11 +2474,11 @@ func (c configImpl) SkipCharts() bool {
}
func (c configImpl) PostRenderer() string {
return ""
return c.postRenderer
}
func (c configImpl) PostRendererArgs() []string {
return nil
return c.postRendererArgs
}
func (c configImpl) KubeVersion() string {
@@ -2351,6 +2497,10 @@ func (c configImpl) EnforceNeedsAreInstalled() bool {
return c.enforceNeedsAreInstalled
}
func (c configImpl) WriteOutput() bool {
return false
}
type applyConfig struct {
args string
cascade string
@@ -2403,6 +2553,7 @@ type applyConfig struct {
trackMode string
trackTimeout int
trackLogs bool
trackFailOnError bool
// template-only options
includeCRDs, skipTests bool
@@ -2629,6 +2780,10 @@ func (a applyConfig) TrackLogs() bool {
return a.trackLogs
}
func (a applyConfig) TrackFailOnError() bool {
return a.trackFailOnError
}
func (a applyConfig) Description() string {
return ""
}
@@ -2683,8 +2838,9 @@ func MockExecer(logger *zap.SugaredLogger, kubeContext string) (helmexec.Interfa
// mocking helmexec.Interface
type mockHelmExec struct {
templated []mockTemplates
repos []mockRepo
templated []mockTemplates
repos []mockRepo
enableLiveOutput bool
}
type mockTemplates struct {
@@ -2724,6 +2880,7 @@ func (helm *mockHelmExec) SetHelmBinary(bin string) {
}
func (helm *mockHelmExec) SetEnableLiveOutput(enableLiveOutput bool) {
helm.enableLiveOutput = enableLiveOutput
}
func (helm *mockHelmExec) SetDisableForceUpdate(forceUpdate bool) {
@@ -2951,6 +3108,222 @@ releases:
}
}
// newPostRendererTestApp creates an App and mockHelmExec wired together from a helmfile YAML content map,
// suitable for testing post-renderer flag propagation.
func newPostRendererTestApp(t *testing.T, files map[string]string) (*App, *mockHelmExec) {
t.Helper()
helm := &mockHelmExec{}
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",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
return app, helm
}
// hasFlagWithValue reports whether flags contains either "--flagName value" as adjacent entries
// or "--flagName=value" as a single entry.
func hasFlagWithValue(flags []string, flagName, value string) bool {
for i, f := range flags {
if f == flagName && i+1 < len(flags) && flags[i+1] == value {
return true
}
if f == flagName+"="+value {
return true
}
}
return false
}
func TestTemplate_HelmDefaultsPostRendererArgs(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "single-doc",
content: `
helmDefaults:
postRenderer: foo
postRendererArgs:
- --arg1
- --arg2
releases:
- name: myrelease
chart: stable/mychart
`,
},
{
name: "multi-doc",
content: `
helmDefaults:
postRenderer: foo
postRendererArgs:
- --arg1
- --arg2
---
releases:
- name: myrelease
chart: stable/mychart
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, helm := newPostRendererTestApp(t, map[string]string{"/path/to/helmfile.yaml": tt.content})
if err := app.Template(configImpl{}); err != nil {
t.Fatalf("%v", err)
}
if len(helm.templated) != 1 {
t.Fatalf("expected 1 release, got %d", len(helm.templated))
}
flags := helm.templated[0].flags
if !hasFlagWithValue(flags, "--post-renderer", "foo") {
t.Errorf("expected --post-renderer foo in flags, got %v", flags)
}
if !hasFlagWithValue(flags, "--post-renderer-args", "--arg1") {
t.Errorf("expected --post-renderer-args=--arg1 or --post-renderer-args --arg1 in flags, got %v", flags)
}
if !hasFlagWithValue(flags, "--post-renderer-args", "--arg2") {
t.Errorf("expected --post-renderer-args=--arg2 or --post-renderer-args --arg2 in flags, got %v", flags)
}
})
}
}
func TestTemplate_CLIPostRendererArgsOverridesHelmDefaults(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "single-doc",
content: `
helmDefaults:
postRenderer: foo
postRendererArgs:
- --default-arg
releases:
- name: myrelease
chart: stable/mychart
`,
},
{
name: "multi-doc",
content: `
helmDefaults:
postRenderer: foo
postRendererArgs:
- --default-arg
---
releases:
- name: myrelease
chart: stable/mychart
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, helm := newPostRendererTestApp(t, map[string]string{"/path/to/helmfile.yaml": tt.content})
// CLI provides --post-renderer-args which should override helmDefaults.postRendererArgs
if err := app.Template(configImpl{postRendererArgs: []string{"--cli-arg"}}); err != nil {
t.Fatalf("%v", err)
}
if len(helm.templated) != 1 {
t.Fatalf("expected 1 release, got %d", len(helm.templated))
}
flags := helm.templated[0].flags
if !hasFlagWithValue(flags, "--post-renderer-args", "--cli-arg") {
t.Errorf("expected --post-renderer-args=--cli-arg or --post-renderer-args --cli-arg in flags (CLI should override helmDefaults), got %v", flags)
}
if hasFlagWithValue(flags, "--post-renderer-args", "--default-arg") {
t.Errorf("unexpected --post-renderer-args --default-arg in flags (CLI should override helmDefaults), got %v", flags)
}
})
}
}
func TestTemplate_ReleasePostRendererArgsOverridesCLI(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "single-doc",
content: `
helmDefaults:
postRenderer: foo
releases:
- name: myrelease
chart: stable/mychart
postRendererArgs:
- --release-arg
`,
},
{
name: "multi-doc",
content: `
helmDefaults:
postRenderer: foo
---
releases:
- name: myrelease
chart: stable/mychart
postRendererArgs:
- --release-arg
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, helm := newPostRendererTestApp(t, map[string]string{"/path/to/helmfile.yaml": tt.content})
// Release explicitly sets postRendererArgs, CLI also provides --post-renderer-args flag; release should win
if err := app.Template(configImpl{postRendererArgs: []string{"--cli-arg"}}); err != nil {
t.Fatalf("%v", err)
}
if len(helm.templated) != 1 {
t.Fatalf("expected 1 release, got %d", len(helm.templated))
}
flags := helm.templated[0].flags
if !hasFlagWithValue(flags, "--post-renderer-args", "--release-arg") {
t.Errorf("expected --post-renderer-args=--release-arg or --post-renderer-args --release-arg in flags (release should override CLI), got %v", flags)
}
if hasFlagWithValue(flags, "--post-renderer-args", "--cli-arg") {
t.Errorf("unexpected --post-renderer-args --cli-arg in flags (release should override CLI), got %v", flags)
}
})
}
}
func TestApply(t *testing.T) {
type fields struct {
skipNeeds bool
@@ -4250,6 +4623,168 @@ releases:
"state should contain source helmfile name:\n%s\n", out)
}
type fetchConfigImpl struct {
configImpl
outputDir string
outputDirTemplate string
writeOutput bool
}
func (f fetchConfigImpl) OutputDir() string {
return f.outputDir
}
func (f fetchConfigImpl) OutputDirTemplate() string {
return f.outputDirTemplate
}
func (f fetchConfigImpl) WriteOutput() bool {
return f.writeOutput
}
func TestFetch_WriteOutputRequiresOutputDir(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: myrelease1
chart: mychart1
`,
}
var buffer bytes.Buffer
syncWriter := testhelper.NewSyncWriter(&buffer)
logger := helmexec.NewLogger(syncWriter, "debug")
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
Namespace: "testNamespace",
}, files)
expectNoCallsToHelm(app)
err := app.Fetch(fetchConfigImpl{
writeOutput: true,
outputDir: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "--output-dir is required")
}
func TestFetch_WriteOutput_ErrorsOnMultipleStateFiles(t *testing.T) {
// Two separate helmfile state files in a helmfile.d directory simulate the
// multi-file scenario that --write-output cannot safely handle: the resulting
// multi-document YAML stream would be merged by Helmfile in a way that can
// alter semantics (helmDefaults override, broken relative paths, etc.).
files := map[string]string{
"/path/to/helmfile.d/first.yaml": `
releases:
- name: release1
chart: chart1
`,
"/path/to/helmfile.d/second.yaml": `
releases:
- name: release2
chart: chart2
`,
}
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)
}
helm := &mockHelmExec{}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey(DefaultHelmBinary, "default"): helm,
},
Namespace: "testNamespace",
valsRuntime: valsRuntime,
}, files)
outputDir := t.TempDir()
fetchErr := app.Fetch(fetchConfigImpl{
writeOutput: true,
outputDir: outputDir,
})
assert.Error(t, fetchErr, "expected error when --write-output is used with multiple state files")
assert.Contains(t, fetchErr.Error(), "--write-output requires a single helmfile state file")
}
func TestFetch_WriteOutputRestoresSequentialHelmfiles(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: myrelease1
chart: mychart1
`,
}
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)
}
// Use a real mock helm exec (not noCallHelmExec) so that Fetch can proceed
// past the validation check and enter the SequentialHelmfiles mutation block.
// Start with enableLiveOutput = true so the restore path is actually exercised:
// Fetch will call SetEnableLiveOutput(false), then the deferred restore call
// SetEnableLiveOutput(true) (a.EnableLiveOutput). If the defer were missing,
// helm.enableLiveOutput would remain false and the assertion below would fail.
helm := &mockHelmExec{enableLiveOutput: true}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey(DefaultHelmBinary, "default"): helm,
},
Namespace: "testNamespace",
valsRuntime: valsRuntime,
// Start with SequentialHelmfiles = false; it must be restored after Fetch.
SequentialHelmfiles: false,
// Start with EnableLiveOutput = true; the deferred restore must bring it back.
EnableLiveOutput: true,
}, files)
outputDir := t.TempDir()
// Fetch with --write-output + --output-dir enters the mutation block,
// temporarily sets SequentialHelmfiles = true and helm.EnableLiveOutput = false,
// then restores both when it returns.
_ = app.Fetch(fetchConfigImpl{
writeOutput: true,
outputDir: outputDir,
})
assert.False(t, app.SequentialHelmfiles, "SequentialHelmfiles should be restored to false after Fetch returns")
assert.True(t, helm.enableLiveOutput, "helm.enableLiveOutput should be restored to true (a.EnableLiveOutput) after Fetch returns")
}
func TestList(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.d/first.yaml": `
+11
View File
@@ -93,6 +93,7 @@ type ApplyConfigProvider interface {
TrackMode() string
TrackTimeout() int
TrackLogs() bool
TrackFailOnError() bool
Description() string
@@ -132,6 +133,7 @@ type SyncConfigProvider interface {
TrackMode() string
TrackTimeout() int
TrackLogs() bool
TrackFailOnError() bool
Description() string
@@ -244,6 +246,7 @@ type FetchConfigProvider interface {
SkipRefresh() bool
OutputDir() string
OutputDirTemplate() string
WriteOutput() bool
concurrencyConfig
}
@@ -328,6 +331,14 @@ type InitConfigProvider interface {
Force() bool
}
type CreateConfigProvider interface {
Name() string
OutputDir() string
Force() bool
loggingConfig
}
type PrintEnvConfigProvider interface {
Output() string
}
+145
View File
@@ -0,0 +1,145 @@
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
helmfileYAMLTemplate = `# Helmfile configuration
# Documentation: https://helmfile.readthedocs.io/
# Common Helm defaults applied to all releases
helmDefaults:
createNamespace: true
wait: true
timeout: 300
# # Helm chart repositories
# repositories:
# - name: bitnami
# url: https://charts.bitnami.com/bitnami
# - name: ingress-nginx
# url: https://kubernetes.github.io/ingress-nginx
# - name: prometheus-community
# url: https://prometheus-community.github.io/helm-charts
# # Environment-specific values
# # Usage: helmfile -e <environment> apply
# environments:
# default:
# values:
# - environments/default.yaml
# staging:
# values:
# - environments/staging.yaml
# production:
# values:
# - environments/production.yaml
# # Helm releases
# releases:
# - name: my-app
# namespace: my-app
# chart: bitnami/nginx
# version: ~18.0.0
# values:
# - values/my-app.yaml
# # secrets:
# # - secrets/my-app.yaml
# # hooks:
# # - events: ["presync"]
# # command: kubectl
# # args: ["apply", "-f", "manifests/"]
`
envDefaultYAMLTemplate = `# Default environment values
# These values are available in helmfile.yaml as {{ .Values }}
# Example:
# replicaCount: 1
# image:
# repository: nginx
# tag: latest
`
)
func (a *App) Create(c CreateConfigProvider) error {
outputDir := c.OutputDir()
absDir, err := filepath.Abs(outputDir)
if err != nil {
return appError("", fmt.Errorf("failed to resolve output directory: %w", err))
}
// Scaffold file paths (intermediate directories may not exist yet).
helmfilePath := filepath.Join(absDir, "helmfile.yaml")
envFilePath := filepath.Join(absDir, "environments", "default.yaml")
gitkeepPath := filepath.Join(absDir, "values", ".gitkeep")
// Preflight: when --force is not set, check all scaffold paths before
// writing anything so the command fails atomically rather than leaving a
// partially-written project directory.
if !c.Force() {
var existing []string
for _, p := range []string{helmfilePath, envFilePath, gitkeepPath} {
_, statErr := os.Stat(p)
if statErr == nil {
existing = append(existing, p)
} else if !os.IsNotExist(statErr) {
return appError("", fmt.Errorf("failed to check %s: %w", p, statErr))
}
}
if len(existing) > 0 {
return appError("", fmt.Errorf("the following files already exist, use --force to overwrite: %s", strings.Join(existing, ", ")))
}
}
// Create directories.
for _, dir := range []string{absDir, filepath.Join(absDir, "environments"), filepath.Join(absDir, "values")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return appError("", fmt.Errorf("failed to create directory %s: %w", dir, err))
}
}
// Write scaffold files.
files := []struct {
path string
content []byte
}{
{helmfilePath, []byte(helmfileYAMLTemplate)},
{envFilePath, []byte(envDefaultYAMLTemplate)},
{gitkeepPath, []byte("")},
}
for _, f := range files {
if err := writeScaffoldFile(f.path, f.content, c.Force()); err != nil {
return appError("", fmt.Errorf("failed to write %s: %w", f.path, err))
}
c.Logger().Infof("created %s", f.path)
}
c.Logger().Infof("\nhelmfile project created in %s\n\nNext steps:\n cd %s\n # Edit helmfile.yaml to add your releases\n helmfile apply", absDir, absDir)
return nil
}
// writeScaffoldFile writes content to path. When force is false it uses
// O_EXCL so that a file appearing between the preflight check and the write
// is caught rather than silently overwritten (TOCTOU protection).
func writeScaffoldFile(path string, content []byte, force bool) error {
if force {
return os.WriteFile(path, content, 0o644)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("file %s already exists, use --force to overwrite: %w", path, err)
}
return err
}
_, werr := f.Write(content)
cerr := f.Close()
if werr != nil {
return werr
}
return cerr
}
+208
View File
@@ -0,0 +1,208 @@
package app
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// mockCreateConfigProvider is a test double for CreateConfigProvider.
type mockCreateConfigProvider struct {
name string
outputDir string
force bool
logger *zap.SugaredLogger
}
func (m *mockCreateConfigProvider) Name() string { return m.name }
func (m *mockCreateConfigProvider) OutputDir() string { return m.outputDir }
func (m *mockCreateConfigProvider) Force() bool { return m.force }
func (m *mockCreateConfigProvider) Logger() *zap.SugaredLogger {
if m.logger != nil {
return m.logger
}
return newTestLogger()
}
func newMockCreateConfig(outputDir string, force bool) *mockCreateConfigProvider {
return &mockCreateConfigProvider{outputDir: outputDir, force: force}
}
func TestCreate_NewDirectory(t *testing.T) {
dir := t.TempDir()
outDir := filepath.Join(dir, "myproject")
a := &App{}
cfg := newMockCreateConfig(outDir, false)
require.NoError(t, a.Create(cfg))
// Verify all scaffold files were created.
assertFileContent(t, filepath.Join(outDir, "helmfile.yaml"), helmfileYAMLTemplate)
assertFileContent(t, filepath.Join(outDir, "environments", "default.yaml"), envDefaultYAMLTemplate)
assertFileExists(t, filepath.Join(outDir, "values", ".gitkeep"))
}
func TestCreate_CurrentDirectory(t *testing.T) {
dir := t.TempDir()
a := &App{}
cfg := newMockCreateConfig(dir, false)
require.NoError(t, a.Create(cfg))
assertFileContent(t, filepath.Join(dir, "helmfile.yaml"), helmfileYAMLTemplate)
assertFileContent(t, filepath.Join(dir, "environments", "default.yaml"), envDefaultYAMLTemplate)
assertFileExists(t, filepath.Join(dir, "values", ".gitkeep"))
}
func TestCreate_ExistingHelmfileYAMLNoForce(t *testing.T) {
dir := t.TempDir()
// Pre-create helmfile.yaml
require.NoError(t, os.WriteFile(filepath.Join(dir, "helmfile.yaml"), []byte("existing"), 0o644))
a := &App{}
cfg := newMockCreateConfig(dir, false)
err := a.Create(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exist")
assert.Contains(t, err.Error(), "--force")
// Verify the existing file was not overwritten.
content, readErr := os.ReadFile(filepath.Join(dir, "helmfile.yaml"))
require.NoError(t, readErr)
assert.Equal(t, "existing", string(content))
}
func TestCreate_ExistingEnvDefaultYAMLNoForce(t *testing.T) {
dir := t.TempDir()
envDir := filepath.Join(dir, "environments")
require.NoError(t, os.MkdirAll(envDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(envDir, "default.yaml"), []byte("existing"), 0o644))
a := &App{}
cfg := newMockCreateConfig(dir, false)
err := a.Create(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exist")
assert.Contains(t, err.Error(), "--force")
// Verify the existing file was not overwritten.
content, readErr := os.ReadFile(filepath.Join(envDir, "default.yaml"))
require.NoError(t, readErr)
assert.Equal(t, "existing", string(content))
}
func TestCreate_ExistingGitkeepNoForce(t *testing.T) {
dir := t.TempDir()
valuesDir := filepath.Join(dir, "values")
require.NoError(t, os.MkdirAll(valuesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(valuesDir, ".gitkeep"), []byte("existing"), 0o644))
a := &App{}
cfg := newMockCreateConfig(dir, false)
err := a.Create(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exist")
assert.Contains(t, err.Error(), "--force")
// Verify the existing file was not overwritten.
content, readErr := os.ReadFile(filepath.Join(valuesDir, ".gitkeep"))
require.NoError(t, readErr)
assert.Equal(t, "existing", string(content))
}
// TestCreate_PreflightAtomicOnLaterConflict verifies that when only a later
// scaffold file exists (e.g. environments/default.yaml but not helmfile.yaml),
// the preflight check catches it and no files are written at all.
func TestCreate_PreflightAtomicOnLaterConflict(t *testing.T) {
dir := t.TempDir()
envDir := filepath.Join(dir, "environments")
require.NoError(t, os.MkdirAll(envDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(envDir, "default.yaml"), []byte("existing"), 0o644))
a := &App{}
cfg := newMockCreateConfig(dir, false)
err := a.Create(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exist")
// helmfile.yaml must NOT have been created (preflight aborted before any write).
_, statErr := os.Stat(filepath.Join(dir, "helmfile.yaml"))
assert.True(t, os.IsNotExist(statErr), "helmfile.yaml should not have been created")
}
func TestCreate_ExistingFilesWithForce(t *testing.T) {
dir := t.TempDir()
// Pre-create all scaffold files with different content.
require.NoError(t, os.WriteFile(filepath.Join(dir, "helmfile.yaml"), []byte("old"), 0o644))
envDir := filepath.Join(dir, "environments")
require.NoError(t, os.MkdirAll(envDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(envDir, "default.yaml"), []byte("old"), 0o644))
valuesDir := filepath.Join(dir, "values")
require.NoError(t, os.MkdirAll(valuesDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(valuesDir, ".gitkeep"), []byte("old"), 0o644))
a := &App{}
cfg := newMockCreateConfig(dir, true)
require.NoError(t, a.Create(cfg))
// Verify scaffold files were overwritten with the template content.
assertFileContent(t, filepath.Join(dir, "helmfile.yaml"), helmfileYAMLTemplate)
assertFileContent(t, filepath.Join(dir, "environments", "default.yaml"), envDefaultYAMLTemplate)
assertFileExists(t, filepath.Join(dir, "values", ".gitkeep"))
}
// assertFileContent asserts that the file at path exists and contains wantContent.
func assertFileContent(t *testing.T, path, wantContent string) {
t.Helper()
content, err := os.ReadFile(path)
require.NoError(t, err, "file %s should exist", path)
assert.Equal(t, wantContent, string(content))
}
// assertFileExists asserts that the file at path exists.
func assertFileExists(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
assert.NoError(t, err, "file %s should exist", path)
}
func TestWriteScaffoldFile_CreatesNewFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "new.yaml")
require.NoError(t, writeScaffoldFile(path, []byte("hello"), false))
assertFileContent(t, path, "hello")
}
func TestWriteScaffoldFile_ExistingFileNoForce(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "existing.yaml")
require.NoError(t, os.WriteFile(path, []byte("original"), 0o644))
err := writeScaffoldFile(path, []byte("new"), false)
require.Error(t, err)
assert.Contains(t, err.Error(), "--force")
// Original content must be unchanged.
assertFileContent(t, path, "original")
}
func TestWriteScaffoldFile_ExistingFileWithForce(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "existing.yaml")
require.NoError(t, os.WriteFile(path, []byte("original"), 0o644))
require.NoError(t, writeScaffoldFile(path, []byte("new"), true))
assertFileContent(t, path, "new")
}
+23 -22
View File
@@ -50,30 +50,39 @@ type desiredStateLoader struct {
func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, error) {
var overrodeEnv *environment.Environment
args := opts.Environment.OverrideValues
fileArgs := opts.Environment.OverrideValues
setArgs := opts.Environment.OverrideCLISetValues
if len(args) > 0 {
if len(fileArgs) > 0 || len(setArgs) > 0 {
if opts.CalleePath == "" {
return nil, fmt.Errorf("bug: opts.CalleePath was nil: f=%s, opts=%v", f, opts)
return nil, fmt.Errorf("bug: opts.CalleePath was empty: f=%s, opts=%v", f, opts)
}
storage := state.NewStorage(opts.CalleePath, ld.logger, ld.fs)
envld := state.NewEnvironmentValuesLoader(storage, ld.fs, ld.logger, ld.remote)
handler := state.MissingFileHandlerError
vals, err := envld.LoadEnvironmentValues(&handler, args, environment.New(ld.env), ld.env)
if err != nil {
return nil, err
overrodeEnv = &environment.Environment{
Name: ld.env,
Values: map[string]any{},
CLIOverrides: map[string]any{},
}
if opts.Environment.OverrideValuesAreCLI {
overrodeEnv = &environment.Environment{
Name: ld.env,
CLIOverrides: vals,
// --state-values-file: loaded into Values so arrays replace (not merge)
if len(fileArgs) > 0 {
fileVals, err := envld.LoadEnvironmentValues(&handler, fileArgs, environment.New(ld.env), ld.env, "")
if err != nil {
return nil, err
}
} else {
overrodeEnv = &environment.Environment{
Name: ld.env,
Values: vals,
overrodeEnv.Values = fileVals
}
// --state-values-set: loaded into CLIOverrides so arrays merge element-by-element
if len(setArgs) > 0 {
setVals, err := envld.LoadEnvironmentValues(&handler, setArgs, environment.New(ld.env), ld.env, "")
if err != nil {
return nil, err
}
overrodeEnv.CLIOverrides = setVals
}
}
@@ -270,14 +279,6 @@ func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, ba
finalState.RenderedValues = currentState.RenderedValues
}
if len(finalState.HelmDefaults.PostRendererArgs) > 0 {
for i := range finalState.Releases {
if len(finalState.Releases[i].PostRendererArgs) == 0 {
finalState.Releases[i].PostRendererArgs = finalState.HelmDefaults.PostRendererArgs
}
}
finalState.HelmDefaults.PostRendererArgs = nil
}
env = &finalState.Env
ld.logger.Debugf("merged environment: %v", env)
+2 -2
View File
@@ -20,7 +20,7 @@ import (
const (
HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x)
HelmDiffRecommendedVersion = "v3.15.3"
HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4
HelmRecommendedVersion = "v4.2.0" // Recommended Helm 4 version
HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture)
HelmGitRecommendedVersion = "v1.3.0"
HelmS3RecommendedVersion = "v0.16.3"
@@ -217,7 +217,7 @@ func (h *HelmfileInit) CheckHelmPlugins() error {
if err != nil {
return err
}
err = helm.UpdatePlugin(p.name)
err = helm.UpdatePlugin(p.name, p.repo, p.version)
if err != nil {
// Check if plugin was updated despite the error
updatedVersion, verifyErr := helmexec.GetPluginVersion(p.name, pluginsDir)
+135
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
@@ -207,3 +208,137 @@ func TestCheckHelmPlugins_InstallErrorPluginTrulyMissing(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "sh: not found")
}
func TestCheckHelmPlugins_UpdateFailsFallbackToReinstall(t *testing.T) {
pluginsDir := t.TempDir()
t.Setenv("HELM_PLUGINS", pluginsDir)
// Pre-populate plugins with outdated versions so the update path is triggered.
for _, p := range helmPlugins {
createPluginYAML(t, pluginsDir, p.name, p.name, "0.0.1")
}
// Track which plugin sub-commands were executed.
var calledOps []string
// The mock runner simulates "helm plugin update" failing and falling back to
// "helm plugin uninstall" + "helm plugin install" which succeeds and writes the
// required version to disk.
runner := &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
}
if len(args) >= 2 && args[0] == "plugin" {
switch args[1] {
case "update":
if len(args) >= 3 {
calledOps = append(calledOps, "update:"+args[2])
}
// Simulate helm plugin update failing (as can happen with Helm 4)
return nil, helmexec.ExitError{Message: "plugin update failed", Code: 1}
case "uninstall":
if len(args) >= 3 {
calledOps = append(calledOps, "uninstall:"+args[2])
}
// Simulate successful uninstall
return []byte{}, nil
case "install":
// Find which plugin is being installed by matching the repo URL.
if len(args) >= 3 {
repo := args[2]
for _, p := range helmPlugins {
if p.repo == repo {
calledOps = append(calledOps, "install:"+p.name)
createPluginYAML(t, pluginsDir, p.name, p.name, strings.TrimPrefix(p.version, "v"))
break
}
}
}
return []byte{}, nil
}
}
return []byte{}, nil
},
}
h := NewHelmfileInit("helm", &mockInitConfigProvider{force: true}, newTestLogger(), runner)
err := h.CheckHelmPlugins()
// Should succeed: update failed but fallback reinstall updated the plugin
assert.NoError(t, err)
// Verify that for each plugin the fallback path was taken:
// update was attempted, then uninstall + install were called.
for _, p := range helmPlugins {
assert.Contains(t, calledOps, "update:"+p.name, "expected update to be attempted for plugin %s", p.name)
assert.Contains(t, calledOps, "uninstall:"+p.name, "expected uninstall to be called for plugin %s", p.name)
assert.Contains(t, calledOps, "install:"+p.name, "expected install to be called for plugin %s", p.name)
}
// Verify that all plugins are now at (or above) the required version on disk.
for _, p := range helmPlugins {
requiredVersion, err := semver.NewVersion(p.version)
require.NoError(t, err)
installedVersion, err := helmexec.GetPluginVersion(p.name, pluginsDir)
require.NoError(t, err, "plugin %s should be present after reinstall", p.name)
assert.False(t, installedVersion.LessThan(requiredVersion),
"plugin %s: installed version %s should be >= required version %s", p.name, installedVersion, requiredVersion)
}
}
func TestCheckHelmPlugins_UpdateErrorButPluginAtRequiredVersion(t *testing.T) {
pluginsDir := t.TempDir()
t.Setenv("HELM_PLUGINS", pluginsDir)
// Pre-populate plugins with outdated versions so the update path is triggered.
for _, p := range helmPlugins {
createPluginYAML(t, pluginsDir, p.name, p.name, "0.0.1")
}
// The mock runner simulates:
// 1. "helm plugin update" failing
// 2. "helm plugin uninstall" succeeding
// 3. "helm plugin install" writing the correct version but returning an error
// (e.g., post-install script error on Windows)
// In this case, UpdatePlugin returns the install error, but CheckHelmPlugins
// verifies the version and warns instead of returning an error.
runner := &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
}
if len(args) >= 2 && args[0] == "plugin" {
switch args[1] {
case "update":
return nil, helmexec.ExitError{Message: "plugin update failed", Code: 1}
case "uninstall":
return []byte{}, nil
case "install":
// Write the correct version to disk, then return an error
// (simulates post-install script failure on Windows)
if len(args) >= 3 {
repo := args[2]
for _, p := range helmPlugins {
if p.repo == repo {
createPluginYAML(t, pluginsDir, p.name, p.name, strings.TrimPrefix(p.version, "v"))
break
}
}
}
return nil, helmexec.ExitError{Message: "post-install script failed", Code: 1}
}
}
return []byte{}, nil
},
}
h := NewHelmfileInit("helm", &mockInitConfigProvider{force: true}, newTestLogger(), runner)
err := h.CheckHelmPlugins()
// Should succeed: UpdatePlugin returned an error (from the fallback install step),
// but the plugin is present at the required version, so CheckHelmPlugins warns and continues.
assert.NoError(t, err)
}
+11 -1
View File
@@ -30,7 +30,17 @@ func (o LoadOpts) DeepCopy() LoadOpts {
panic(err)
}
new.Environment.OverrideValuesAreCLI = o.Environment.OverrideValuesAreCLI
if src := o.Environment.OverrideCLISetValues; src != nil {
b, err := yaml.Marshal(src)
if err != nil {
panic(err)
}
var dst []any
if err := yaml.Unmarshal(b, &dst); err != nil {
panic(err)
}
new.Environment.OverrideCLISetValues = dst
}
return new
}
+22 -5
View File
@@ -21,16 +21,33 @@ func TestLoadOptsDeepCopy(t *testing.T) {
require.Equal(t, lOld, lNew, "DeepCopy should return a copy of the LoadOpts struct")
}
// TestLoadOptsDeepCopyPreservesOverrideValuesAreCLI verifies that DeepCopy
// preserves the OverrideValuesAreCLI flag which is tagged yaml:"-".
func TestLoadOptsDeepCopyPreservesOverrideValuesAreCLI(t *testing.T) {
// TestLoadOptsDeepCopyPreservesOverrideCLISetValues verifies that DeepCopy
// preserves the OverrideCLISetValues field which is tagged yaml:"-".
func TestLoadOptsDeepCopyPreservesOverrideCLISetValues(t *testing.T) {
lOld := LoadOpts{
Selectors: []string{"test"},
CalleePath: "test",
}
lOld.Environment.OverrideValuesAreCLI = true
lOld.Environment.OverrideCLISetValues = []any{map[string]any{"key": "value"}}
lNew := lOld.DeepCopy()
require.True(t, lNew.Environment.OverrideValuesAreCLI, "DeepCopy should preserve OverrideValuesAreCLI flag")
require.Equal(t, lOld.Environment.OverrideCLISetValues, lNew.Environment.OverrideCLISetValues, "DeepCopy should preserve OverrideCLISetValues field")
}
// TestLoadOptsDeepCopyOverrideCLISetValuesIsNotShallow verifies that mutating a
// map nested inside OverrideCLISetValues on the copy does not affect the
// original.
func TestLoadOptsDeepCopyOverrideCLISetValuesIsNotShallow(t *testing.T) {
lOld := LoadOpts{}
lOld.Environment.OverrideCLISetValues = []any{map[string]any{"key": "original"}}
lNew := lOld.DeepCopy()
// Mutate the map inside the copy.
lNew.Environment.OverrideCLISetValues[0].(map[string]any)["key"] = "mutated"
// The original must be unaffected; this fails with a shallow copy.
require.Equal(t, "original", lOld.Environment.OverrideCLISetValues[0].(map[string]any)["key"],
"mutating the copy's OverrideCLISetValues map must not affect the original (aliasing bug)")
}
+11 -3
View File
@@ -57,9 +57,9 @@ func (r *Run) prepareChartsIfNeeded(helmfileCommand string, dir string, concurre
return releaseToChart, nil
}
func (r *Run) withPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func() []error) error {
func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func() []error) error {
if r.ReleaseToChart != nil {
panic("Run.PrepareCharts can be called only once")
return fmt.Errorf("Run.WithPreparedCharts can be called only once")
}
// Check both CLI options and helmDefaults for skipping repos (issue #2296)
@@ -88,7 +88,7 @@ func (r *Run) withPreparedCharts(helmfileCommand string, opts state.ChartPrepare
dir = tempDir
} else {
dir = opts.OutputDir
fmt.Printf("Charts will be downloaded to: %s\n", dir)
fmt.Fprintf(os.Stderr, "Charts will be downloaded to: %s\n", dir)
}
if _, err := r.state.TriggerGlobalPrepareEvent(helmfileCommand); err != nil {
@@ -238,3 +238,11 @@ func (r *Run) diff(triggerCleanupEvent bool, detailedExitCode bool, c DiffConfig
return &infoMsg, releasesToBeUpdated, releasesToBeDeleted, nil
}
func (r *Run) State() *state.HelmState {
return r.state
}
func (r *Run) Helm() helmexec.Interface {
return r.helm
}
+7
View File
@@ -88,6 +88,8 @@ type ApplyOptions struct {
TrackTimeout int
// TrackLogs enables log streaming with kubedog
TrackLogs bool
// TrackFailOnError controls whether kubedog tracking failures cause a non-zero exit code
TrackFailOnError bool
// Description is the description that will be passed to helm upgrade --description
Description string
}
@@ -316,6 +318,11 @@ func (a *ApplyImpl) TrackLogs() bool {
return a.ApplyOptions.TrackLogs
}
// TrackFailOnError returns whether kubedog tracking failures should cause a non-zero exit code.
func (a *ApplyImpl) TrackFailOnError() bool {
return a.ApplyOptions.TrackFailOnError
}
// Description returns the description.
func (a *ApplyImpl) Description() string {
return a.ApplyOptions.Description
+62
View File
@@ -0,0 +1,62 @@
package config
import (
"fmt"
"strings"
)
type CreateOptions struct {
Name string
OutputDir string
Force bool
}
func NewCreateOptions() *CreateOptions {
return &CreateOptions{}
}
type CreateImpl struct {
*GlobalImpl
*CreateOptions
}
func NewCreateImpl(g *GlobalImpl, o *CreateOptions) *CreateImpl {
return &CreateImpl{
GlobalImpl: g,
CreateOptions: o,
}
}
func (c *CreateImpl) Name() string {
return c.CreateOptions.Name
}
func (c *CreateImpl) OutputDir() string {
if c.CreateOptions.OutputDir != "" {
return c.CreateOptions.OutputDir
}
if c.CreateOptions.Name != "" {
return c.CreateOptions.Name
}
return "."
}
func (c *CreateImpl) Force() bool {
return c.CreateOptions.Force
}
func (c *CreateImpl) ValidateConfig() error {
name := c.CreateOptions.Name
if name != "" {
if strings.ContainsAny(name, "/\\") {
return fmt.Errorf("invalid project name %q: must not contain path separators", name)
}
if name == ".." || name == "." {
return fmt.Errorf("invalid project name %q", name)
}
if strings.TrimSpace(name) == "" {
return fmt.Errorf("project name must not be empty or whitespace only")
}
}
return c.GlobalImpl.ValidateConfig()
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestCreateImplWithDefaults(name string) *CreateImpl {
return NewCreateImpl(NewGlobalImpl(&GlobalOptions{}), &CreateOptions{
Name: name,
})
}
func TestCreateImpl_ValidateConfig_NameWithForwardSlash(t *testing.T) {
c := newTestCreateImplWithDefaults("foo/bar")
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "must not contain path separators")
}
func TestCreateImpl_ValidateConfig_NameWithBackslash(t *testing.T) {
c := newTestCreateImplWithDefaults(`foo\bar`)
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "must not contain path separators")
}
func TestCreateImpl_ValidateConfig_NameDotDot(t *testing.T) {
c := newTestCreateImplWithDefaults("..")
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid project name")
}
func TestCreateImpl_ValidateConfig_NameDot(t *testing.T) {
c := newTestCreateImplWithDefaults(".")
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid project name")
}
func TestCreateImpl_ValidateConfig_WhitespaceOnlyName(t *testing.T) {
c := newTestCreateImplWithDefaults(" ")
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "must not be empty or whitespace only")
}
func TestCreateImpl_ValidateConfig_ValidName(t *testing.T) {
c := newTestCreateImplWithDefaults("myproject")
require.NoError(t, c.ValidateConfig())
}
func TestCreateImpl_ValidateConfig_GlobalColorConflict(t *testing.T) {
// Delegates to GlobalImpl.ValidateConfig which rejects --color + --no-color.
c := NewCreateImpl(
NewGlobalImpl(&GlobalOptions{Color: true, NoColor: true}),
&CreateOptions{},
)
err := c.ValidateConfig()
require.Error(t, err)
assert.Contains(t, err.Error(), "--color")
assert.Contains(t, err.Error(), "--no-color")
}
+10 -3
View File
@@ -1,6 +1,6 @@
package config
// FetchOptions is the options for the build command
// FetchOptions is the options for the fetch command
type FetchOptions struct {
// Concurrency is the maximum number of concurrent helm processes to run, 0 is unlimited
Concurrency int
@@ -8,14 +8,16 @@ type FetchOptions struct {
OutputDir string
// OutputDirTemplate is the go template to generate the path of output directory
OutputDirTemplate string
// WriteOutput writes a helmfile.yaml with chart references updated to point to downloaded local chart paths
WriteOutput bool
}
// NewFetchOptions creates a new Apply
// NewFetchOptions creates a new FetchOptions
func NewFetchOptions() *FetchOptions {
return &FetchOptions{}
}
// FetchImpl is impl for applyOptions
// FetchImpl is impl for fetchOptions
type FetchImpl struct {
*GlobalImpl
*FetchOptions
@@ -43,3 +45,8 @@ func (c *FetchImpl) OutputDir() string {
func (c *FetchImpl) OutputDirTemplate() string {
return c.FetchOptions.OutputDirTemplate
}
// WriteOutput returns whether to write a modified helmfile.yaml with local chart paths
func (c *FetchImpl) WriteOutput() bool {
return c.FetchOptions.WriteOutput
}
+89 -7
View File
@@ -109,12 +109,63 @@ func (g *GlobalImpl) SetSet(set map[string]any) {
// HelmBinary returns the path to the Helm binary.
func (g *GlobalImpl) HelmBinary() string {
return g.GlobalOptions.HelmBinary
var helmBinary string
switch {
case g.GlobalOptions.HelmBinary != "":
helmBinary = g.GlobalOptions.HelmBinary
case os.Getenv("HELMFILE_HELM_BINARY") != "":
helmBinary = os.Getenv("HELMFILE_HELM_BINARY")
default:
helmBinary = state.DefaultHelmBinary
}
return helmBinary
}
// KustomizeBinary returns the path to the Kustomize binary.
func (g *GlobalImpl) KustomizeBinary() string {
return g.GlobalOptions.KustomizeBinary
var kustomizeBinary string
switch {
case g.GlobalOptions.KustomizeBinary != "":
kustomizeBinary = g.GlobalOptions.KustomizeBinary
case os.Getenv("HELMFILE_KUSTOMIZE_BINARY") != "":
kustomizeBinary = os.Getenv("HELMFILE_KUSTOMIZE_BINARY")
default:
kustomizeBinary = state.DefaultKustomizeBinary
}
return kustomizeBinary
}
// LogLevel returns the log level to use.
func (g *GlobalImpl) LogLevel() string {
var logLevel string
switch {
case g.GlobalOptions.LogLevel != "":
logLevel = g.GlobalOptions.LogLevel
case os.Getenv("HELMFILE_LOG_LEVEL") != "":
logLevel = os.Getenv("HELMFILE_LOG_LEVEL")
default:
logLevel = "info"
}
return logLevel
}
// Debug returns whether debug output is enabled.
func (g *GlobalImpl) Debug() bool {
if g.GlobalOptions.Debug {
return true
}
return os.Getenv(envvar.Debug) == "true"
}
// Quiet returns whether quiet output is enabled.
func (g *GlobalImpl) Quiet() bool {
if g.GlobalOptions.Quiet {
return true
}
return os.Getenv(envvar.Quiet) == "true"
}
// Kubeconfig returns the path to the kubeconfig file to use.
@@ -124,12 +175,32 @@ func (g *GlobalImpl) Kubeconfig() string {
// KubeContext returns the name of the kubectl context to use.
func (g *GlobalImpl) KubeContext() string {
return g.GlobalOptions.KubeContext
var kubeContext string
switch {
case g.GlobalOptions.KubeContext != "":
kubeContext = g.GlobalOptions.KubeContext
case os.Getenv("HELMFILE_KUBE_CONTEXT") != "":
kubeContext = os.Getenv("HELMFILE_KUBE_CONTEXT")
default:
kubeContext = ""
}
return kubeContext
}
// Namespace returns the namespace to use.
func (g *GlobalImpl) Namespace() string {
return g.GlobalOptions.Namespace
var namespace string
switch {
case g.GlobalOptions.Namespace != "":
namespace = g.GlobalOptions.Namespace
case os.Getenv("HELMFILE_NAMESPACE") != "":
namespace = os.Getenv("HELMFILE_NAMESPACE")
default:
namespace = ""
}
return namespace
}
// Chart returns the chart to use.
@@ -222,7 +293,7 @@ func (g *GlobalImpl) Color() bool {
return c
}
if g.GlobalOptions.NoColor {
if g.NoColor() {
return false
}
@@ -239,7 +310,18 @@ func (g *GlobalImpl) Color() bool {
// NoColor returns the no color flag
func (g *GlobalImpl) NoColor() bool {
return g.GlobalOptions.NoColor
if g.GlobalOptions.NoColor {
return true
}
// Explicit --color short-circuits env-derived no-color: a flag must win over an env var.
if g.GlobalOptions.Color {
return false
}
if os.Getenv(envvar.NoColor) == "true" {
return true
}
// Honor the de-facto https://no-color.org/ standard: any non-empty value disables color.
return os.Getenv("NO_COLOR") != ""
}
// Env returns the environment to use.
@@ -276,7 +358,7 @@ func (g *GlobalImpl) Interactive() bool {
// Args returns the args to use for helm
func (g *GlobalImpl) Args() string {
args := g.GlobalOptions.Args
enableHelmDebug := g.Debug
enableHelmDebug := g.Debug()
if enableHelmDebug {
args = fmt.Sprintf("%s %s", args, "--debug")
+375
View File
@@ -45,3 +45,378 @@ func TestFileOrDir(t *testing.T) {
}
os.Unsetenv(envvar.FilePath)
}
// TestKubeContext tests the kube-context flag and HELMFILE_KUBE_CONTEXT env var fallback
func TestKubeContext(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{KubeContext: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{KubeContext: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.KubeContext, test.env)
received := NewGlobalImpl(&test.opts).KubeContext()
require.Equalf(t, test.expected, received, "KubeContext expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.KubeContext)
}
// TestNamespace tests the namespace flag and HELMFILE_NAMESPACE env var fallback
func TestNamespace(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{Namespace: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{Namespace: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.Namespace, test.env)
received := NewGlobalImpl(&test.opts).Namespace()
require.Equalf(t, test.expected, received, "Namespace expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.Namespace)
}
// TestHelmBinary tests the helm-binary flag and HELMFILE_HELM_BINARY env var fallback
func TestHelmBinary(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "helm",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{HelmBinary: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{HelmBinary: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.HelmBinary, test.env)
received := NewGlobalImpl(&test.opts).HelmBinary()
require.Equalf(t, test.expected, received, "HelmBinary expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.HelmBinary)
}
// TestKustomizeBinary tests the kustomize-binary flag and HELMFILE_KUSTOMIZE_BINARY env var fallback
func TestKustomizeBinary(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "kustomize",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{KustomizeBinary: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{KustomizeBinary: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.KustomizeBinary, test.env)
received := NewGlobalImpl(&test.opts).KustomizeBinary()
require.Equalf(t, test.expected, received, "KustomizeBinary expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.KustomizeBinary)
}
// TestLogLevel tests the log-level flag and HELMFILE_LOG_LEVEL env var fallback
func TestLogLevel(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected string
}{
{
opts: GlobalOptions{},
env: "",
expected: "info",
},
{
opts: GlobalOptions{},
env: "envset",
expected: "envset",
},
{
opts: GlobalOptions{LogLevel: "flagset"},
env: "",
expected: "flagset",
},
{
opts: GlobalOptions{LogLevel: "flagset"},
env: "envset",
expected: "flagset",
},
}
for _, test := range tests {
os.Setenv(envvar.LogLevel, test.env)
received := NewGlobalImpl(&test.opts).LogLevel()
require.Equalf(t, test.expected, received, "LogLevel expected %s, received %s", test.expected, received)
}
os.Unsetenv(envvar.LogLevel)
}
// TestDebug tests the debug flag and HELMFILE_DEBUG env var fallback
func TestDebug(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected bool
}{
{
opts: GlobalOptions{},
env: "",
expected: false,
},
{
opts: GlobalOptions{},
env: "true",
expected: true,
},
{
opts: GlobalOptions{},
env: "anything",
expected: false,
},
{
opts: GlobalOptions{Debug: true},
env: "",
expected: true,
},
{
opts: GlobalOptions{Debug: true},
env: "true",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.Debug, test.env)
received := NewGlobalImpl(&test.opts).Debug()
require.Equalf(t, test.expected, received, "Debug expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.Debug)
}
// TestQuiet tests the quiet flag and HELMFILE_QUIET env var fallback
func TestQuiet(t *testing.T) {
tests := []struct {
opts GlobalOptions
env string
expected bool
}{
{
opts: GlobalOptions{},
env: "",
expected: false,
},
{
opts: GlobalOptions{},
env: "true",
expected: true,
},
{
opts: GlobalOptions{},
env: "anything",
expected: false,
},
{
opts: GlobalOptions{Quiet: true},
env: "",
expected: true,
},
{
opts: GlobalOptions{Quiet: true},
env: "true",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.Quiet, test.env)
received := NewGlobalImpl(&test.opts).Quiet()
require.Equalf(t, test.expected, received, "Quiet expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.Quiet)
}
// TestNoColor tests the no-color flag, HELMFILE_NO_COLOR and NO_COLOR env var fallbacks
func TestNoColor(t *testing.T) {
tests := []struct {
opts GlobalOptions
helmfileEnv string
standardEnv string
expected bool
}{
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "",
expected: false,
},
{
opts: GlobalOptions{},
helmfileEnv: "true",
standardEnv: "",
expected: true,
},
{
opts: GlobalOptions{},
helmfileEnv: "anything",
standardEnv: "",
expected: false,
},
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "1",
expected: true,
},
{
opts: GlobalOptions{},
helmfileEnv: "",
standardEnv: "anything",
expected: true,
},
{
opts: GlobalOptions{NoColor: true},
helmfileEnv: "",
standardEnv: "",
expected: true,
},
}
for _, test := range tests {
os.Setenv(envvar.NoColor, test.helmfileEnv)
os.Setenv("NO_COLOR", test.standardEnv)
received := NewGlobalImpl(&test.opts).NoColor()
require.Equalf(t, test.expected, received, "NoColor expected %t, received %t", test.expected, received)
}
os.Unsetenv(envvar.NoColor)
os.Unsetenv("NO_COLOR")
}
// TestColorRespectsNoColorEnv guards against ValidateConfig() firing when
// HELMFILE_NO_COLOR / NO_COLOR is set without an explicit --color/--no-color flag.
// Color() must consult NoColor() (which is env-aware) before falling back to TTY autodetect.
func TestColorRespectsNoColorEnv(t *testing.T) {
tests := []struct {
name string
helmfileEnv string
standardEnv string
}{
{name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"},
{name: "NO_COLOR set", standardEnv: "1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(envvar.NoColor, test.helmfileEnv)
t.Setenv("NO_COLOR", test.standardEnv)
g := NewGlobalImpl(&GlobalOptions{})
require.True(t, g.NoColor(), "NoColor() should be true when env is set")
require.False(t, g.Color(), "Color() should be false when NoColor() is true via env")
require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error from env-only no-color")
})
}
}
// TestColorFlagOverridesNoColorEnv guards against ValidateConfig() firing when
// --color is explicitly passed but HELMFILE_NO_COLOR / NO_COLOR is set in the
// environment. The flag must win over the env var.
func TestColorFlagOverridesNoColorEnv(t *testing.T) {
tests := []struct {
name string
helmfileEnv string
standardEnv string
}{
{name: "HELMFILE_NO_COLOR=true", helmfileEnv: "true"},
{name: "NO_COLOR set", standardEnv: "1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(envvar.NoColor, test.helmfileEnv)
t.Setenv("NO_COLOR", test.standardEnv)
g := NewGlobalImpl(&GlobalOptions{Color: true})
require.True(t, g.Color(), "Color() should be true when --color is set")
require.False(t, g.NoColor(), "NoColor() should be false when --color is set, even if env says otherwise")
require.NoError(t, g.ValidateConfig(), "ValidateConfig() should not error when --color overrides env no-color")
})
}
}
+87
View File
@@ -59,8 +59,25 @@ type SyncOptions struct {
TrackTimeout int
// TrackLogs enables log streaming with kubedog
TrackLogs bool
// TrackFailOnError controls whether kubedog tracking failures cause a non-zero exit code
TrackFailOnError bool
// Description is the description that will be passed to helm upgrade --description
Description string
// Diff-related options for --interactive mode
SuppressOutputLineRegex []string
IncludeTests bool
Suppress []string
SuppressSecrets bool
ShowSecrets bool
NoHooks bool
SuppressDiff bool
SkipDiffOnInstall bool
DiffArgs string
DetailedExitcode bool
StripTrailingCR bool
Context int
DiffOutput string
}
// NewSyncOptions creates a new Apply
@@ -216,11 +233,81 @@ func (t *SyncImpl) TrackLogs() bool {
return t.SyncOptions.TrackLogs
}
// TrackFailOnError returns whether kubedog tracking failures should cause a non-zero exit code.
func (t *SyncImpl) TrackFailOnError() bool {
return t.SyncOptions.TrackFailOnError
}
// Description returns the description.
func (t *SyncImpl) Description() string {
return t.SyncOptions.Description
}
// SuppressOutputLineRegex returns the SuppressOutputLineRegex.
func (t *SyncImpl) SuppressOutputLineRegex() []string {
return t.SyncOptions.SuppressOutputLineRegex
}
// IncludeTests returns the IncludeTests.
func (t *SyncImpl) IncludeTests() bool {
return t.SyncOptions.IncludeTests
}
// Suppress returns the Suppress.
func (t *SyncImpl) Suppress() []string {
return t.SyncOptions.Suppress
}
// SuppressSecrets returns the SuppressSecrets.
func (t *SyncImpl) SuppressSecrets() bool {
return t.SyncOptions.SuppressSecrets
}
// ShowSecrets returns the ShowSecrets.
func (t *SyncImpl) ShowSecrets() bool {
return t.SyncOptions.ShowSecrets
}
// NoHooks returns the NoHooks.
func (t *SyncImpl) NoHooks() bool {
return t.SyncOptions.NoHooks
}
// SuppressDiff returns the SuppressDiff.
func (t *SyncImpl) SuppressDiff() bool {
return t.SyncOptions.SuppressDiff
}
// SkipDiffOnInstall returns the SkipDiffOnInstall.
func (t *SyncImpl) SkipDiffOnInstall() bool {
return t.SyncOptions.SkipDiffOnInstall
}
// DiffArgs returns the DiffArgs.
func (t *SyncImpl) DiffArgs() string {
return t.SyncOptions.DiffArgs
}
// DetailedExitcode returns the DetailedExitcode.
func (t *SyncImpl) DetailedExitcode() bool {
return t.SyncOptions.DetailedExitcode
}
// StripTrailingCR returns the StripTrailingCR.
func (t *SyncImpl) StripTrailingCR() bool {
return t.SyncOptions.StripTrailingCR
}
// Context returns the Context.
func (t *SyncImpl) Context() int {
return t.SyncOptions.Context
}
// DiffOutput returns the DiffOutput.
func (t *SyncImpl) DiffOutput() string {
return t.SyncOptions.DiffOutput
}
func (t *SyncImpl) ValidateConfig() error {
validTrackModes := []string{"helm", "helm-legacy", "kubedog"}
if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) {
+32
View File
@@ -205,3 +205,35 @@ func TestEnvironment_GetMergedValues_Issue2281_SparseArrayMerge(t *testing.T) {
assert.Equal(t, "second thing", elem1["thing"])
assert.Equal(t, "cmdline", elem1["anotherThing"])
}
func TestEnvironment_GetMergedValues_Issue2527_ValuesOverrideDefaults(t *testing.T) {
// Regression test for https://github.com/helmfile/helmfile/issues/2527:
// A boolean false in Values must not be overridden by a true in Defaults.
env := &Environment{
Name: "test",
Defaults: map[string]any{
"helmDefaults": map[string]any{
"atomic": true,
"wait": true,
"timeout": 300,
},
},
Values: map[string]any{
"appName": "my-app",
"helmDefaults": map[string]any{
"atomic": false, // explicit false override must survive
"wait": true,
"timeout": 300,
},
},
CLIOverrides: map[string]any{},
}
mergedValues, err := env.GetMergedValues()
require.NoError(t, err)
hd := mergedValues["helmDefaults"].(map[string]any)
assert.Equal(t, false, hd["atomic"], "Values false should override Defaults true for atomic")
assert.Equal(t, true, hd["wait"])
assert.Equal(t, 300, hd["timeout"])
}
+8
View File
@@ -9,6 +9,14 @@ const (
DisableRunnerUniqueID = "HELMFILE_DISABLE_RUNNER_UNIQUE_ID"
Experimental = "HELMFILE_EXPERIMENTAL" // environment variable for experimental features, expecting "true" lower case
Environment = "HELMFILE_ENVIRONMENT"
KubeContext = "HELMFILE_KUBE_CONTEXT"
Namespace = "HELMFILE_NAMESPACE"
HelmBinary = "HELMFILE_HELM_BINARY"
KustomizeBinary = "HELMFILE_KUSTOMIZE_BINARY"
LogLevel = "HELMFILE_LOG_LEVEL"
Debug = "HELMFILE_DEBUG"
Quiet = "HELMFILE_QUIET"
NoColor = "HELMFILE_NO_COLOR"
FilePath = "HELMFILE_FILE_PATH"
TempDir = "HELMFILE_TEMPDIR"
UpgradeNoticeDisabled = "HELMFILE_UPGRADE_NOTICE_DISABLED"
+15
View File
@@ -27,7 +27,10 @@ func (fs fileStat) Sys() any { return nil }
type FileSystem struct {
ReadFile func(string) ([]byte, error)
ReadDir func(string) ([]fs.DirEntry, error)
WriteFile func(string, []byte, fs.FileMode) error
DeleteFile func(string) error
MkdirTemp func(string, string) (string, error)
RemoveAll func(string) error
FileExists func(string) (bool, error)
Glob func(string) ([]string, error)
FileExistsAt func(string) bool
@@ -44,7 +47,10 @@ type FileSystem struct {
func DefaultFileSystem() *FileSystem {
dfs := FileSystem{
ReadDir: os.ReadDir,
WriteFile: os.WriteFile,
DeleteFile: os.Remove,
MkdirTemp: os.MkdirTemp,
RemoveAll: os.RemoveAll,
Glob: filepath.Glob,
Getwd: os.Getwd,
Chdir: os.Chdir,
@@ -107,6 +113,15 @@ func FromFileSystem(params FileSystem) *FileSystem {
if params.CopyDir != nil {
dfs.CopyDir = params.CopyDir
}
if params.WriteFile != nil {
dfs.WriteFile = params.WriteFile
}
if params.MkdirTemp != nil {
dfs.MkdirTemp = params.MkdirTemp
}
if params.RemoveAll != nil {
dfs.RemoveAll = params.RemoveAll
}
return dfs
}
+4
View File
@@ -252,4 +252,8 @@ func TestFs_DefaultBuilder(t *testing.T) {
assert.NotNil(t, ffs.Chdir)
assert.NotNil(t, ffs.Abs)
assert.NotNil(t, ffs.EvalSymlinks)
assert.NotNil(t, ffs.WriteFile)
assert.NotNil(t, ffs.MkdirTemp)
assert.NotNil(t, ffs.RemoveAll)
assert.NotNil(t, ffs.CopyDir)
}
+118 -10
View File
@@ -256,7 +256,7 @@ func (helm *execer) AddRepo(name, repository, cafile, certfile, keyfile, usernam
if username != "" && password != "" {
args = append(args, "--username", username, "--password-stdin")
buffer := bytes.Buffer{}
buffer.Write([]byte(fmt.Sprintf("%s\n", password)))
fmt.Fprintf(&buffer, "%s\n", password)
out, err = helm.execStdIn(args, map[string]string{}, &buffer)
} else {
out, err = helm.exec(args, map[string]string{}, nil)
@@ -311,7 +311,7 @@ func (helm *execer) RegistryLogin(repository, username, password, caFile, certFi
args = append(args, "--username", username, "--password-stdin")
buffer := bytes.Buffer{}
buffer.Write([]byte(fmt.Sprintf("%s\n", password)))
fmt.Fprintf(&buffer, "%s\n", password)
helm.logger.Info("Logging in to registry")
out, err := helm.execStdIn(args, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, &buffer)
@@ -651,17 +651,77 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
helm.logger.Infof("Templating release=%v, chart=%v", name, redactedURL(chart))
args := []string{"template", name, chart}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
var outputToFile bool
var hasPostRenderer bool
for _, f := range flags {
if strings.HasPrefix("--output-dir", f) {
if f == "--output-dir" || strings.HasPrefix(f, "--output-dir=") {
outputToFile = true
break
}
if f == "--post-renderer" || strings.HasPrefix(f, "--post-renderer=") {
hasPostRenderer = true
}
}
if outputToFile && hasPostRenderer && helm.IsHelm3() {
// Helm 3 does not apply --post-renderer to files written by --output-dir.
// It writes pre-post-renderer content to files and sends post-renderer output to stdout.
// Helm 4 handles this correctly, so the workaround is only needed for Helm 3.
// Workaround: run without --output-dir, capture stdout (with post-renderer applied),
// and write the output to the output directory ourselves.
var outputDir string
filteredFlags := make([]string, 0, len(flags))
for i := 0; i < len(flags); i++ {
if flags[i] == "--output-dir" && i+1 < len(flags) {
outputDir = flags[i+1]
i++
continue
}
if strings.HasPrefix(flags[i], "--output-dir=") {
outputDir = strings.TrimPrefix(flags[i], "--output-dir=")
continue
}
filteredFlags = append(filteredFlags, flags[i])
}
if outputDir == "" {
return fmt.Errorf("output dir not found for template command")
}
out, err := helm.exec(append(args, filteredFlags...), map[string]string{}, nil)
if err != nil {
return err
}
templatesDir := filepath.Join(outputDir, "templates")
legacyOutputPath := filepath.Join(outputDir, name+".yaml")
outputPath := filepath.Join(templatesDir, name+".yaml")
if removeErr := os.Remove(legacyOutputPath); removeErr != nil && !os.IsNotExist(removeErr) {
return fmt.Errorf("failed to remove legacy output file %s: %w", legacyOutputPath, removeErr)
}
// Remove only the specific file written by the previous run to avoid clobbering
// unrelated files in a shared output directory.
if removeErr := os.Remove(outputPath); removeErr != nil && !os.IsNotExist(removeErr) {
return fmt.Errorf("failed to remove stale output file %s: %w", outputPath, removeErr)
}
if len(out) > 0 {
if mkdirErr := os.MkdirAll(templatesDir, 0755); mkdirErr != nil {
return fmt.Errorf("failed to create templates directory %s: %w", templatesDir, mkdirErr)
}
if writeErr := os.WriteFile(outputPath, append(out, '\n'), 0644); writeErr != nil {
return fmt.Errorf("failed to write output file %s: %w", outputPath, writeErr)
}
helm.logger.Debugf("Wrote post-renderer output to %s", outputPath)
}
return nil
}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
if outputToFile {
// With --output-dir is passed to helm-template,
// we can safely direct all the logs from it to our logger.
@@ -856,7 +916,7 @@ func (helm *execer) AddPlugin(name, path, version string) error {
helm.logger.Infof("Install helm plugin %v", name)
// Special handling for helm-secrets 4.7.0+ with Helm 4 which uses split plugin architecture
if name == "secrets" && version >= "v4.7.0" && helm.IsHelm4() {
if name == "secrets" && helmSecretsRequiresSplitInstall(version) && helm.IsHelm4() {
return helm.installHelmSecretsV4(version)
}
@@ -911,11 +971,59 @@ func (helm *execer) installHelmSecretsV4(version string) error {
return nil
}
func (helm *execer) UpdatePlugin(name string) error {
helm.logger.Infof("Update helm plugin %v", name)
// helmSecretsV4SplitMinVersion is the minimum helm-secrets version that uses the
// split plugin architecture (secrets, secrets-getter, secrets-post-renderer) with Helm 4.
var helmSecretsV4SplitMinVersion = semver.MustParse("4.7.0")
// helmSecretsRequiresSplitInstall returns true when the given helm-secrets version
// requires the split plugin architecture introduced in v4.7.0 for Helm 4.
func helmSecretsRequiresSplitInstall(version string) bool {
v, err := semver.NewVersion(version)
if err != nil {
return false
}
return !v.LessThan(helmSecretsV4SplitMinVersion)
}
func (helm *execer) uninstallPlugin(name string) error {
helm.logger.Infof("Uninstalling helm plugin %v", name)
out, err := helm.exec([]string{"plugin", "uninstall", name}, map[string]string{}, nil)
if err == nil {
helm.info(out)
}
return err
}
func (helm *execer) UpdatePlugin(name, repo, version string) error {
helm.logger.Infof("Updating helm plugin %v", name)
// Special handling for helm-secrets 4.7.0+ with Helm 4 which uses split plugin architecture
if name == "secrets" && helmSecretsRequiresSplitInstall(version) && helm.IsHelm4() {
// Uninstall existing secrets plugins; ignore errors as some may not exist
for _, secretsPlugin := range []string{"secrets", "secrets-getter", "secrets-post-renderer"} {
if err := helm.uninstallPlugin(secretsPlugin); err != nil {
helm.logger.Debugf("Failed to uninstall helm plugin %v (may not exist): %v", secretsPlugin, err)
}
}
return helm.installHelmSecretsV4(version)
}
// Try standard helm plugin update
out, err := helm.exec([]string{"plugin", "update", name}, map[string]string{}, nil)
helm.info(out)
return err
if err != nil {
// If standard update failed, fall back to uninstall + reinstall with specific version
updateErr := err
helm.logger.Infof("helm plugin update %v failed (%v), falling back to reinstall with version %v", name, updateErr, version)
if uninstallErr := helm.uninstallPlugin(name); uninstallErr != nil {
return fmt.Errorf("helm plugin update failed (%w) and uninstall for reinstall also failed: %w", updateErr, uninstallErr)
}
if reinstallErr := helm.AddPlugin(name, repo, version); reinstallErr != nil {
return fmt.Errorf("helm plugin update failed (%w) and reinstall also failed: %w", updateErr, reinstallErr)
}
return nil
}
return nil
}
func (helm *execer) exec(args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
+165 -4
View File
@@ -21,8 +21,9 @@ import (
// Mocking the command-line runner
type mockRunner struct {
output []byte
err error
output []byte
versionOutput []byte // if set, returned for "helm version --short" probe; overrides default Helm 4 fallback
err error
}
func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
@@ -30,8 +31,13 @@ func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]s
}
func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
if len(mock.output) == 0 && strings.Join(args, " ") == "version --short" {
return []byte("v4.0.1+g12500dd"), nil
if strings.Join(args, " ") == "version --short" {
if mock.versionOutput != nil {
return mock.versionOutput, nil
}
if len(mock.output) == 0 {
return []byte("v4.0.1+g12500dd"), nil
}
}
return mock.output, mock.err
}
@@ -1255,6 +1261,64 @@ exec: helm --kubeconfig config --kube-context dev template release https://examp
}
}
func Test_Template_PostRendererWithOutputDir(t *testing.T) {
tests := []struct {
name string
postRendererFlag string
}{
{"separate flags", "--post-renderer"},
{"combined flag", "--post-renderer=/bin/echo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
// Use Helm 3 version for the version probe so the Helm 3 workaround is applied.
// The workaround is not needed for Helm 4, which natively applies --post-renderer to --output-dir output.
runner := &mockRunner{versionOutput: []byte("v3.20.0")}
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", runner)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
runner.output = []byte("apiVersion: v1\nkind: Namespace\n")
var flags []string
if tt.postRendererFlag == "--post-renderer" {
flags = []string{"--post-renderer", "/bin/echo", "--output-dir", tmpDir, "--values", "file.yml"}
} else {
flags = []string{tt.postRendererFlag, "--output-dir", tmpDir, "--values", "file.yml"}
}
err = helm.TemplateRelease("myrelease", "path/to/chart", flags...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
outputPath := filepath.Join(tmpDir, "templates", "myrelease.yaml")
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("expected output file %s to exist: %v", outputPath, err)
}
expected := "apiVersion: v1\nkind: Namespace\n\n"
if string(data) != expected {
t.Errorf("output file content:\nactual=%q\nexpect=%q", string(data), expected)
}
outputLog := buffer.String()
if strings.Contains(outputLog, "--output-dir") {
t.Errorf("helm should NOT have been called with --output-dir, got: %s", outputLog)
}
if !strings.Contains(outputLog, "--post-renderer") {
t.Errorf("helm should have been called with --post-renderer, got: %s", outputLog)
}
})
}
}
func Test_IsHelm3(t *testing.T) {
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
@@ -1547,3 +1611,100 @@ func TestParseHelmVersion(t *testing.T) {
})
}
}
func Test_helmSecretsRequiresSplitInstall(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{name: "below threshold", version: "v4.6.9", want: false},
{name: "exactly at threshold", version: "v4.7.0", want: true},
{name: "above threshold single digit minor", version: "v4.8.0", want: true},
{name: "above threshold double digit minor (v4.10.0+)", version: "v4.10.0", want: true},
{name: "pre-release below threshold", version: "v4.7.0-beta.1", want: false},
{name: "invalid version string", version: "not-a-version", want: false},
{name: "empty string", version: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := helmSecretsRequiresSplitInstall(tt.version)
assert.Equal(t, tt.want, got)
})
}
}
type funcRunner struct {
execute func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error)
}
func (r *funcRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
return r.execute(cmd, args, env, false)
}
func (r *funcRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
return r.execute(cmd, args, env, enableLiveOutput)
}
func Test_UpdatePlugin_Helm4SecretsUsesUninstallReinstall(t *testing.T) {
var calledArgs [][]string
runner := &funcRunner{
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
calledArgs = append(calledArgs, append([]string(nil), args...))
return []byte{}, nil
},
}
var buffer bytes.Buffer
logger := NewLogger(&buffer, "debug")
helm := &execer{
helmBinary: "helm",
version: semver.MustParse("4.0.0"),
logger: logger,
kubeconfig: "config",
kubeContext: "dev",
runner: runner,
}
err := helm.UpdatePlugin("secrets", "https://github.com/jkroepke/helm-secrets", "v4.7.0")
require.NoError(t, err)
// Verify that "plugin update" was NOT called (the Helm 4 secrets path should skip it).
for _, args := range calledArgs {
for i, a := range args {
if a == "plugin" && i+1 < len(args) && args[i+1] == "update" {
t.Errorf("expected 'plugin update' to not be called for Helm 4 secrets, but it was: %v", args)
}
}
}
// Verify that uninstall was called for all three split plugins.
checkUninstall := func(name string) {
for _, args := range calledArgs {
for i, a := range args {
if a == "plugin" && i+2 < len(args) && args[i+1] == "uninstall" && args[i+2] == name {
return
}
}
}
t.Errorf("expected 'plugin uninstall %s' to be called", name)
}
checkUninstall("secrets")
checkUninstall("secrets-getter")
checkUninstall("secrets-post-renderer")
// Verify that install was called for all three split plugin tarballs.
checkInstall := func(urlSubstring string) {
for _, args := range calledArgs {
for i, a := range args {
if a == "plugin" && i+2 < len(args) && args[i+1] == "install" && strings.Contains(args[i+2], urlSubstring) {
return
}
}
}
t.Errorf("expected 'plugin install' for %q to be called", urlSubstring)
}
checkInstall("secrets-4.7.0.tgz")
checkInstall("secrets-getter-4.7.0.tgz")
checkInstall("secrets-post-renderer-4.7.0.tgz")
}
+312
View File
@@ -0,0 +1,312 @@
package kubedog
import (
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/werf/kubedog/pkg/tracker/daemonset"
"github.com/werf/kubedog/pkg/tracker/deployment"
"github.com/werf/kubedog/pkg/tracker/indicators"
"github.com/werf/kubedog/pkg/tracker/job"
"github.com/werf/kubedog/pkg/tracker/pod"
"github.com/werf/kubedog/pkg/tracker/statefulset"
"github.com/werf/kubedog/pkg/utils"
"golang.org/x/term"
)
var statusProgressTableRatio = []float64{.58, .11, .12, .19}
var statusProgressSubTableRatio = []float64{.40, .15, .20, .25}
func writeOut(out io.Writer, s string) {
_, _ = fmt.Fprint(out, s)
}
func displayDeploymentStatusProgress(out io.Writer, resourceCaption string, status deployment.DeploymentStatus, prevStatus *deployment.DeploymentStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
available := "-"
if status.AvailableIndicator != nil {
available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("DEPLOYMENT", "REPLICAS", "AVAILABLE", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, available, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayStatefulSetStatusProgress(out io.Writer, resourceCaption string, status statefulset.StatefulSetStatus, prevStatus *statefulset.StatefulSetStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
ready := "-"
if status.ReadyIndicator != nil {
ready = status.ReadyIndicator.FormatTableElem(prevStatus.ReadyIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("STATEFULSET", "REPLICAS", "READY", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, ready, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
} else {
for _, w := range status.WarningMessages {
args = append(args, formatResourceWarning(w))
}
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayDaemonSetStatusProgress(out io.Writer, resourceCaption string, status daemonset.DaemonSetStatus, prevStatus *daemonset.DaemonSetStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
replicas := "-"
if status.ReplicasIndicator != nil {
replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
WithTargetValue: true,
})
}
available := "-"
if status.AvailableIndicator != nil {
available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
uptodate := "-"
if status.UpToDateIndicator != nil {
uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("DAEMONSET", "REPLICAS", "AVAILABLE", "UP-TO-DATE")
args := []interface{}{resourceCaption, replicas, available, uptodate}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages)
writeOut(out, t.Render())
}
func displayJobStatusProgress(out io.Writer, resourceCaption string, status job.JobStatus, prevStatus *job.JobStatus) {
t := utils.NewTable(statusProgressTableRatio...)
t.SetWidth(termWidth())
showProgress := status.StatusGeneration > prevStatus.StatusGeneration
succeeded := "-"
if status.SucceededIndicator != nil {
succeeded = status.SucceededIndicator.FormatTableElem(prevStatus.SucceededIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
})
}
t.Header("JOB", "ACTIVE", "DURATION", "SUCCEEDED/FAILED")
var active interface{} = "-"
if status.Active != 0 {
active = status.Active
}
failed := fmt.Sprintf("%d", status.Failed)
args := []interface{}{resourceCaption, active, status.Age, strings.Join([]string{succeeded, failed}, "/")}
if status.IsFailed {
args = append(args, formatResourceError(status.FailedReason))
}
t.Row(args...)
if len(status.Pods) > 0 {
st := displayChildPodsStatusProgress(&t, prevStatus.Pods, status.Pods, nil, showProgress)
extraMsg := ""
if len(status.WaitingForMessages) > 0 {
extraMsg += "---\n"
extraMsg += utils.BlueF("Waiting for: %s", strings.Join(status.WaitingForMessages, ", "))
}
st.Commit(extraMsg)
}
writeOut(out, t.Render())
}
func displayChildPodsAndWaiting(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, waitingForMessages []string) {
if len(pods) > 0 {
st := displayChildPodsStatusProgress(t, prevPods, pods, newPodsNames, true)
extraMsg := ""
if len(waitingForMessages) > 0 {
extraMsg += "---\n"
extraMsg += utils.BlueF("Waiting for: %s", strings.Join(waitingForMessages, ", "))
}
st.Commit(extraMsg)
}
}
func displayChildPodsStatusProgress(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, showProgress bool) *utils.Table {
subT := t.SubTable(statusProgressSubTableRatio...)
st := &subT
st.Header("POD", "READY", "RESTARTS", "STATUS")
podsNames := make([]string, 0, len(pods))
for podName := range pods {
podsNames = append(podsNames, podName)
}
sort.Strings(podsNames)
var podRows [][]interface{}
newPodSet := make(map[string]struct{}, len(newPodsNames))
for _, name := range newPodsNames {
newPodSet[name] = struct{}{}
}
for _, podName := range podsNames {
var podRow []interface{}
_, isPodNew := newPodSet[podName]
prevPodStatus := prevPods[podName]
podStatus := pods[podName]
isReady := false
if podStatus.StatusIndicator != nil {
isReady = podStatus.StatusIndicator.IsReady()
}
resource := formatPodResourceCaption(podName, isReady, podStatus.IsFailed, isPodNew)
ready := fmt.Sprintf("%d/%d", podStatus.ReadyContainers, podStatus.TotalContainers)
status := "-"
if podStatus.StatusIndicator != nil {
status = podStatus.StatusIndicator.FormatTableElem(prevPodStatus.StatusIndicator, indicators.FormatTableElemOptions{
ShowProgress: showProgress,
IsResourceNew: isPodNew,
})
}
podRow = append(podRow, resource, ready, podStatus.Restarts, status)
if podStatus.IsFailed {
podRow = append(podRow, formatResourceError(podStatus.FailedReason))
}
podRows = append(podRows, podRow)
}
st.Rows(podRows...)
return st
}
func formatResourceCaption(caption string, isReady, isFailed bool) string {
switch {
case isReady:
return utils.GreenF("%s", caption)
case isFailed:
return utils.RedF("%s", caption)
default:
return utils.YellowF("%s", caption)
}
}
func formatPodResourceCaption(podName string, isReady, isFailed, isNew bool) string {
if !isNew {
return podName
}
return formatResourceCaption(podName, isReady, isFailed)
}
func formatResourceError(reason string) string {
return utils.RedF("error: %s", reason)
}
func formatResourceWarning(reason string) string {
return utils.YellowF("warning: %s", reason)
}
func termWidth() int {
if w, _, err := term.GetSize(int(os.Stderr.Fd())); err == nil && w > 0 {
return w
}
return 140
}
func displayCanaryStatus(out io.Writer, resourceCaption string, status CanaryStatusView) {
var parts []string
if status.Phase != "" {
parts = append(parts, fmt.Sprintf("phase %s", status.Phase))
}
if status.Age != "" {
parts = append(parts, fmt.Sprintf("age %s", status.Age))
}
msg := fmt.Sprintf("%s: %s", resourceCaption, strings.Join(parts, ", "))
if status.IsFailed {
msg = utils.RedF("%s", msg)
}
_, _ = fmt.Fprintln(out, msg)
}
type CanaryStatusView struct {
Phase string
Age string
IsFailed bool
}
func statusOutput() io.Writer {
return os.Stderr
}
+453
View File
@@ -0,0 +1,453 @@
package kubedog
import (
"bytes"
"os"
"strings"
"testing"
"github.com/gookit/color"
"github.com/stretchr/testify/assert"
"github.com/werf/kubedog/pkg/tracker/daemonset"
"github.com/werf/kubedog/pkg/tracker/deployment"
"github.com/werf/kubedog/pkg/tracker/job"
"github.com/werf/kubedog/pkg/tracker/pod"
"github.com/werf/kubedog/pkg/tracker/statefulset"
)
// TestMain forces ANSI color output so that tests asserting on escape codes
// pass in non-TTY environments such as CI runners.
func TestMain(m *testing.M) {
color.ForceColor()
os.Exit(m.Run())
}
// --- formatResourceCaption ---
func TestFormatResourceCaption_Ready(t *testing.T) {
result := formatResourceCaption("deploy/myapp", true, false)
assert.Contains(t, result, "deploy/myapp")
// Green ANSI escape should be present
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_Failed(t *testing.T) {
result := formatResourceCaption("deploy/myapp", false, true)
assert.Contains(t, result, "deploy/myapp")
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_InProgress(t *testing.T) {
result := formatResourceCaption("deploy/myapp", false, false)
assert.Contains(t, result, "deploy/myapp")
// Yellow for in-progress
assert.Contains(t, result, "\033[")
}
func TestFormatResourceCaption_ReadyTakesPrecedence(t *testing.T) {
// isReady=true should win over isFailed=true
resultReady := formatResourceCaption("x", true, false)
resultFailed := formatResourceCaption("x", false, true)
// Colors should differ
assert.NotEqual(t, resultReady, resultFailed)
}
// --- formatPodResourceCaption ---
func TestFormatPodResourceCaption_NotNew(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", true, false, false)
// Not a new pod: no coloring applied, just the plain name
assert.Equal(t, "my-pod-abc", result)
}
func TestFormatPodResourceCaption_NewAndReady(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", true, false, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
func TestFormatPodResourceCaption_NewAndFailed(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", false, true, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
func TestFormatPodResourceCaption_NewInProgress(t *testing.T) {
result := formatPodResourceCaption("my-pod-abc", false, false, true)
assert.Contains(t, result, "my-pod-abc")
assert.Contains(t, result, "\033[")
}
// --- formatResourceError / formatResourceWarning ---
func TestFormatResourceError(t *testing.T) {
result := formatResourceError("CrashLoopBackOff")
assert.Contains(t, result, "error:")
assert.Contains(t, result, "CrashLoopBackOff")
}
func TestFormatResourceWarning(t *testing.T) {
result := formatResourceWarning("PodNotScheduled")
assert.Contains(t, result, "warning:")
assert.Contains(t, result, "PodNotScheduled")
}
// --- termWidth ---
func TestTermWidth_ReturnsPositive(t *testing.T) {
w := termWidth()
assert.Greater(t, w, 0)
}
// --- displayDeploymentStatusProgress ---
func TestDisplayDeploymentStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
var prev deployment.DeploymentStatus
status := deployment.DeploymentStatus{}
// Must not panic and must produce some output
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.NotEmpty(t, out)
assert.Contains(t, out, "DEPLOYMENT")
}
func TestDisplayDeploymentStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, true)
var prev deployment.DeploymentStatus
status := deployment.DeploymentStatus{
IsFailed: true,
FailedReason: "ImagePullBackOff",
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "ImagePullBackOff")
}
func TestDisplayDeploymentStatusProgress_WithWaitingMessage(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
var prev deployment.DeploymentStatus
// WaitingForMessages is only rendered when there are pods
status := deployment.DeploymentStatus{
StatusGeneration: 1,
WaitingForMessages: []string{"up-to-date 1->3"},
Pods: map[string]pod.PodStatus{
"myapp-pod-abc": {ReadyContainers: 1, TotalContainers: 1},
},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "Waiting for:")
assert.Contains(t, out, "up-to-date 1->3")
}
func TestDisplayDeploymentStatusProgress_WithPods(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: map[string]pod.PodStatus{
"myapp-abc-123": {ReadyContainers: 1, TotalContainers: 1},
},
NewPodsNames: []string{"myapp-abc-123"},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "POD")
assert.Contains(t, out, "myapp-abc-123")
}
// --- displayStatefulSetStatusProgress ---
func TestDisplayStatefulSetStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, false)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "STATEFULSET")
}
func TestDisplayStatefulSetStatusProgress_WithWarnings(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, false)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{
WarningMessages: []string{"PodNotScheduled: insufficient resources"},
}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "warning:")
assert.Contains(t, out, "PodNotScheduled")
}
func TestDisplayStatefulSetStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("sts/myapp", false, true)
var prev statefulset.StatefulSetStatus
status := statefulset.StatefulSetStatus{
IsFailed: true,
FailedReason: "timeout waiting for ready",
}
assert.NotPanics(t, func() {
displayStatefulSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "timeout waiting for ready")
}
// --- displayDaemonSetStatusProgress ---
func TestDisplayDaemonSetStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("ds/myapp", false, false)
var prev daemonset.DaemonSetStatus
status := daemonset.DaemonSetStatus{}
assert.NotPanics(t, func() {
displayDaemonSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "DAEMONSET")
}
func TestDisplayDaemonSetStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("ds/myapp", false, true)
var prev daemonset.DaemonSetStatus
status := daemonset.DaemonSetStatus{
IsFailed: true,
FailedReason: "node not ready",
}
assert.NotPanics(t, func() {
displayDaemonSetStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "node not ready")
}
// --- displayJobStatusProgress ---
func TestDisplayJobStatusProgress_ZeroStatus(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "JOB")
}
func TestDisplayJobStatusProgress_Active(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{
StatusGeneration: 1,
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "ACTIVE")
}
func TestDisplayJobStatusProgress_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, true)
var prev job.JobStatus
status := job.JobStatus{
IsFailed: true,
FailedReason: "BackoffLimitExceeded",
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "error:")
assert.Contains(t, out, "BackoffLimitExceeded")
}
func TestDisplayJobStatusProgress_WithWaitingMessage(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("job/myjob", false, false)
var prev job.JobStatus
status := job.JobStatus{
WaitingForMessages: []string{"succeeded 0->1"},
Pods: map[string]pod.PodStatus{
"myjob-abc": {ReadyContainers: 0, TotalContainers: 1},
},
}
assert.NotPanics(t, func() {
displayJobStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "Waiting for:")
assert.Contains(t, out, "succeeded 0->1")
}
// --- displayChildPodsStatusProgress ---
func TestDisplayChildPodsStatusProgress_Empty(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
// With no pods, only the header should be rendered
prev := deployment.DeploymentStatus{}
status := deployment.DeploymentStatus{
Pods: map[string]pod.PodStatus{},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
// No POD sub-table header when pods is empty
out := buf.String()
assert.NotContains(t, out, "POD")
}
func TestDisplayChildPodsStatusProgress_NewPodSet(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
// Two pods: one new, one old
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: map[string]pod.PodStatus{
"pod-new-abc": {ReadyContainers: 0, TotalContainers: 1},
"pod-old-xyz": {ReadyContainers: 1, TotalContainers: 1},
},
NewPodsNames: []string{"pod-new-abc"},
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
out := buf.String()
assert.Contains(t, out, "pod-new-abc")
assert.Contains(t, out, "pod-old-xyz")
}
func TestDisplayChildPodsStatusProgress_ManyPodsO1Check(t *testing.T) {
// Verifies O(1) new-pod detection works correctly for many pods
var buf bytes.Buffer
caption := formatResourceCaption("deploy/myapp", false, false)
prev := deployment.DeploymentStatus{}
pods := make(map[string]pod.PodStatus)
newNames := make([]string, 0, 10)
for i := 0; i < 20; i++ {
name := strings.Repeat("a", i+1)
pods[name] = pod.PodStatus{ReadyContainers: 1, TotalContainers: 1}
if i%2 == 0 {
newNames = append(newNames, name)
}
}
status := deployment.DeploymentStatus{
StatusGeneration: 1,
Pods: pods,
NewPodsNames: newNames,
}
assert.NotPanics(t, func() {
displayDeploymentStatusProgress(&buf, caption, status, &prev)
})
assert.NotEmpty(t, buf.String())
}
// --- displayCanaryStatus ---
func TestDisplayCanaryStatus_Normal(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, false)
view := CanaryStatusView{Phase: "Progressing", Age: "1m"}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Progressing")
assert.Contains(t, out, "1m")
}
func TestDisplayCanaryStatus_Failed(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, true)
view := CanaryStatusView{Phase: "Failed", IsFailed: true}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Failed")
}
func TestDisplayCanaryStatus_Succeeded(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", true, false)
view := CanaryStatusView{Phase: "Succeeded"}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
out := buf.String()
assert.Contains(t, out, "Succeeded")
}
func TestDisplayCanaryStatus_EmptyPhaseAndAge(t *testing.T) {
var buf bytes.Buffer
caption := formatResourceCaption("canary/myapp", false, false)
view := CanaryStatusView{}
assert.NotPanics(t, func() {
displayCanaryStatus(&buf, caption, view)
})
// Should still produce output (at least the caption + newline)
assert.NotEmpty(t, buf.String())
}
// --- writeOut ---
func TestWriteOut(t *testing.T) {
var buf bytes.Buffer
writeOut(&buf, "hello world")
assert.Equal(t, "hello world", buf.String())
}
func TestWriteOut_Empty(t *testing.T) {
var buf bytes.Buffer
writeOut(&buf, "")
assert.Equal(t, "", buf.String())
}
+383 -58
View File
@@ -9,8 +9,15 @@ import (
"sync"
"time"
"github.com/werf/kubedog-for-werf-helm/pkg/tracker"
"github.com/werf/kubedog-for-werf-helm/pkg/trackers/rollout/multitrack"
"github.com/werf/kubedog/pkg/display"
"github.com/werf/kubedog/pkg/informer"
"github.com/werf/kubedog/pkg/tracker"
"github.com/werf/kubedog/pkg/tracker/canary"
"github.com/werf/kubedog/pkg/tracker/daemonset"
"github.com/werf/kubedog/pkg/tracker/deployment"
"github.com/werf/kubedog/pkg/tracker/job"
"github.com/werf/kubedog/pkg/tracker/statefulset"
"github.com/werf/kubedog/pkg/trackers/dyntracker/util"
"go.uber.org/zap"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
@@ -20,6 +27,7 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
watchtools "k8s.io/client-go/tools/watch"
"github.com/helmfile/helmfile/pkg/resource"
)
@@ -187,6 +195,12 @@ func getOrCreateClients(kubeContext, kubeconfig string, qps float32, burst int)
return cache, nil
}
type trackTarget struct {
kind string
name string
namespace string
}
func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Resource) error {
if len(resources) == 0 {
t.logger.Info("No resources to track")
@@ -201,81 +215,392 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
t.logger.Infof("Tracking %d resources with kubedog (filtered from %d total)", len(filtered), len(resources))
specs := multitrack.MultitrackSpecs{}
targets := t.buildTargets(filtered)
if len(targets) == 0 {
t.logger.Info("No trackable resources found (only Deployment, StatefulSet, DaemonSet, Job, and Canary are supported)")
return nil
}
for _, res := range filtered {
t.logger.Infof("Tracking breakdown: %s", t.targetSummary(targets))
watchErrCh := make(chan error, len(targets))
informerFactory := informer.NewConcurrentInformerFactory(
ctx.Done(),
watchErrCh,
t.dynamicClient,
informer.ConcurrentInformerFactoryOptions{},
)
opts := tracker.Options{
ParentContext: ctx,
Timeout: t.trackOptions.Timeout,
LogsFromTime: time.Now().Add(-t.trackOptions.LogsSince),
IgnoreLogs: !t.trackOptions.Logs,
}
var wg sync.WaitGroup
errCh := make(chan error, len(targets))
for _, target := range targets {
wg.Add(1)
go func(tgt trackTarget) {
defer wg.Done()
if err := t.trackSingleResource(tgt, informerFactory, opts); err != nil {
errCh <- fmt.Errorf("%s/%s tracking failed: %w", tgt.kind, tgt.name, err)
}
}(target)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case err := <-errCh:
return fmt.Errorf("tracking failed: %w", err)
case <-done:
t.logger.Info("All resources tracked successfully")
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled: %w", ctx.Err())
}
}
func (t *Tracker) trackSingleResource(target trackTarget, informerFactory *util.Concurrent[*informer.InformerFactory], opts tracker.Options) error {
parentContext := opts.ParentContext
if parentContext == nil {
parentContext = context.Background()
}
ctx, cancel := watchtools.ContextWithOptionalTimeout(parentContext, opts.Timeout)
defer cancel()
trackErrCh := make(chan error, 1)
doneCh := make(chan struct{})
switch target.kind {
case "deploy":
tr := deployment.NewTracker(target.name, target.namespace, t.clientSet, informerFactory, opts)
go t.runDeploymentTracker(ctx, tr, trackErrCh, doneCh)
return t.waitDeploymentTracker(ctx, tr, trackErrCh, doneCh)
case "sts":
tr := statefulset.NewTracker(target.name, target.namespace, t.clientSet, informerFactory, opts)
go t.runStatefulSetTracker(ctx, tr, trackErrCh, doneCh)
return t.waitStatefulSetTracker(ctx, tr, trackErrCh, doneCh)
case "ds":
tr := daemonset.NewTracker(target.name, target.namespace, t.clientSet, informerFactory, opts)
go t.runDaemonSetTracker(ctx, tr, trackErrCh, doneCh)
return t.waitDaemonSetTracker(ctx, tr, trackErrCh, doneCh)
case "job":
tr := job.NewTracker(target.name, target.namespace, t.clientSet, informerFactory, opts)
go t.runJobTracker(ctx, tr, trackErrCh, doneCh)
return t.waitJobTracker(ctx, tr, trackErrCh, doneCh)
case "canary":
tr := canary.NewTracker(target.name, target.namespace, t.clientSet, t.dynamicClient, informerFactory, opts)
go t.runCanaryTracker(ctx, tr, trackErrCh, doneCh)
return t.waitCanaryTracker(ctx, tr, trackErrCh, doneCh)
default:
return fmt.Errorf("unsupported resource kind: %s", target.kind)
}
}
func (t *Tracker) runDeploymentTracker(ctx context.Context, tr *deployment.Tracker, errCh chan<- error, doneCh chan<- struct{}) {
if err := tr.Track(ctx); err != nil {
errCh <- err
} else {
close(doneCh)
}
}
func (t *Tracker) waitDeploymentTracker(ctx context.Context, tr *deployment.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus deployment.DeploymentStatus
out := statusOutput()
resourceName := fmt.Sprintf("deploy/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("deployment %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("deploy/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("deploy/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message)
case <-tr.AddedReplicaSet:
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled for deployment %s/%s: %w", tr.Namespace, tr.ResourceName, ctx.Err())
}
}
}
func (t *Tracker) runStatefulSetTracker(ctx context.Context, tr *statefulset.Tracker, errCh chan<- error, doneCh chan<- struct{}) {
if err := tr.Track(ctx); err != nil {
errCh <- err
} else {
close(doneCh)
}
}
func (t *Tracker) waitStatefulSetTracker(ctx context.Context, tr *statefulset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus statefulset.StatefulSetStatus
out := statusOutput()
resourceName := fmt.Sprintf("sts/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("statefulset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("sts/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("sts/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled for statefulset %s/%s: %w", tr.Namespace, tr.ResourceName, ctx.Err())
}
}
}
func (t *Tracker) runDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker, errCh chan<- error, doneCh chan<- struct{}) {
if err := tr.Track(ctx); err != nil {
errCh <- err
} else {
close(doneCh)
}
}
func (t *Tracker) waitDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus daemonset.DaemonSetStatus
out := statusOutput()
resourceName := fmt.Sprintf("ds/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Ready:
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("daemonset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("ds/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("ds/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled for daemonset %s/%s: %w", tr.Namespace, tr.ResourceName, ctx.Err())
}
}
}
func (t *Tracker) runJobTracker(ctx context.Context, tr *job.Tracker, errCh chan<- error, doneCh chan<- struct{}) {
if err := tr.Track(ctx); err != nil {
errCh <- err
} else {
close(doneCh)
}
}
func (t *Tracker) waitJobTracker(ctx context.Context, tr *job.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
var prevStatus job.JobStatus
out := statusOutput()
resourceName := fmt.Sprintf("job/%s", tr.ResourceName)
for {
select {
case status := <-tr.Added:
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
case <-tr.Succeeded:
displayJobStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus)
t.logger.Infof("Job %s/%s succeeded", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus)
return fmt.Errorf("job %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
if status.StatusGeneration > prevStatus.StatusGeneration {
displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus)
prevStatus = status
}
case msg := <-tr.EventMsg:
t.logger.Infof("job/%s: %s", tr.ResourceName, msg)
case chunk := <-tr.PodLogChunk:
t.logPodLogChunk(chunk.PodName, chunk.LogLines)
case report := <-tr.PodError:
t.logger.Warnf("job/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message)
case <-tr.AddedPod:
case err := <-trackErrCh:
return err
case <-doneCh:
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled for job %s/%s: %w", tr.Namespace, tr.ResourceName, ctx.Err())
}
}
}
func (t *Tracker) runCanaryTracker(ctx context.Context, tr *canary.Tracker, errCh chan<- error, doneCh chan<- struct{}) {
if err := tr.Track(ctx); err != nil {
errCh <- err
} else {
close(doneCh)
}
}
func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error {
out := statusOutput()
resourceName := fmt.Sprintf("canary/%s", tr.ResourceName)
var lastView CanaryStatusView
for {
select {
case status := <-tr.Added:
view := CanaryStatusView{
Phase: string(status.CanaryStatus.Phase),
IsFailed: status.IsFailed,
}
displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view)
lastView = view
case <-tr.Succeeded:
displayCanaryStatus(out, formatResourceCaption(resourceName, true, false), CanaryStatusView{Phase: lastView.Phase})
t.logger.Infof("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName)
return nil
case status := <-tr.Failed:
displayCanaryStatus(out, formatResourceCaption(resourceName, false, true), CanaryStatusView{
Phase: status.FailedReason,
IsFailed: true,
})
return fmt.Errorf("canary %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason)
case status := <-tr.Status:
view := CanaryStatusView{
Phase: func() string {
if status.StatusIndicator != nil {
return status.StatusIndicator.Value
}
return ""
}(),
Age: status.Age,
IsFailed: status.IsFailed,
}
displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view)
lastView = view
case msg := <-tr.EventMsg:
t.logger.Infof("canary/%s: %s", tr.ResourceName, msg)
case err := <-trackErrCh:
return err
case <-doneCh:
return nil
case <-ctx.Done():
return fmt.Errorf("tracking canceled for canary %s/%s: %w", tr.Namespace, tr.ResourceName, ctx.Err())
}
}
}
func (t *Tracker) logPodLogChunk(podName string, logLines []display.LogLine) {
for _, line := range logLines {
t.logger.Infof("po/%s [%s] %s", podName, line.Timestamp, line.Message)
}
}
func (t *Tracker) buildTargets(resources []*resource.Resource) []trackTarget {
var targets []trackTarget
for _, res := range resources {
namespace := res.Namespace
if namespace == "" {
namespace = t.namespace
}
kind := ""
switch strings.ToLower(res.Kind) {
case "deployment", "deploy":
specs.Deployments = append(specs.Deployments, multitrack.MultitrackSpec{
ResourceName: res.Name,
Namespace: namespace,
SkipLogs: !t.trackOptions.Logs,
})
kind = "deploy"
case "statefulset", "sts":
specs.StatefulSets = append(specs.StatefulSets, multitrack.MultitrackSpec{
ResourceName: res.Name,
Namespace: namespace,
SkipLogs: !t.trackOptions.Logs,
})
kind = "sts"
case "daemonset", "ds":
specs.DaemonSets = append(specs.DaemonSets, multitrack.MultitrackSpec{
ResourceName: res.Name,
Namespace: namespace,
SkipLogs: !t.trackOptions.Logs,
})
kind = "ds"
case "job":
specs.Jobs = append(specs.Jobs, multitrack.MultitrackSpec{
ResourceName: res.Name,
Namespace: namespace,
SkipLogs: !t.trackOptions.Logs,
})
kind = "job"
case "canary":
specs.Canaries = append(specs.Canaries, multitrack.MultitrackSpec{
ResourceName: res.Name,
Namespace: namespace,
SkipLogs: !t.trackOptions.Logs,
})
kind = "canary"
default:
t.logger.Debugf("Skipping unsupported kind %s for resource %s/%s", res.Kind, namespace, res.Name)
continue
}
targets = append(targets, trackTarget{
kind: kind,
name: res.Name,
namespace: namespace,
})
}
return targets
}
totalResources := len(specs.Deployments) + len(specs.StatefulSets) +
len(specs.DaemonSets) + len(specs.Jobs) + len(specs.Canaries)
if totalResources == 0 {
t.logger.Info("No trackable resources found (only Deployment, StatefulSet, DaemonSet, Job, and Canary are supported)")
return nil
func (t *Tracker) targetSummary(targets []trackTarget) string {
counts := make(map[string]int)
for _, tgt := range targets {
counts[tgt.kind]++
}
t.logger.Infof("Tracking breakdown: Deployments=%d, StatefulSets=%d, DaemonSets=%d, Jobs=%d, Canaries=%d",
len(specs.Deployments), len(specs.StatefulSets), len(specs.DaemonSets),
len(specs.Jobs), len(specs.Canaries))
opts := multitrack.MultitrackOptions{
Options: tracker.Options{
ParentContext: ctx,
Timeout: t.trackOptions.Timeout,
LogsFromTime: time.Now().Add(-t.trackOptions.LogsSince),
},
StatusProgressPeriod: 5 * time.Second,
DynamicClient: t.dynamicClient,
DiscoveryClient: t.discovery,
Mapper: t.mapper,
parts := make([]string, 0, len(counts))
for kind, count := range counts {
parts = append(parts, fmt.Sprintf("%ss=%d", kind, count))
}
err := multitrack.Multitrack(t.clientSet, specs, opts)
if err != nil {
return fmt.Errorf("tracking failed: %w", err)
}
t.logger.Info("All resources tracked successfully")
return nil
return strings.Join(parts, ", ")
}
func (t *Tracker) filterResources(resources []*resource.Resource) []*resource.Resource {
+671 -65
View File
@@ -1,14 +1,22 @@
package state
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"go.uber.org/zap"
helmchart "helm.sh/helm/v3/pkg/chart"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/runtime"
"github.com/helmfile/helmfile/pkg/yaml"
)
func TestRewriteChartDependencies(t *testing.T) {
@@ -65,12 +73,10 @@ dependencies:
expectModified: true,
expectError: false,
validate: func(t *testing.T, modifiedChartYaml string) {
// Should have been converted to absolute path
if strings.Contains(modifiedChartYaml, "file://../relative-chart") {
t.Errorf("relative path should have been converted to absolute")
}
// Should now have an absolute path
if !strings.Contains(modifiedChartYaml, "file://") {
t.Errorf("should still have file:// prefix")
}
@@ -98,22 +104,18 @@ dependencies:
expectModified: true,
expectError: false,
validate: func(t *testing.T, modifiedChartYaml string) {
// HTTPS repo should remain unchanged
if !strings.Contains(modifiedChartYaml, "https://charts.example.com") {
t.Errorf("https repository should not be modified")
}
// Relative path should be converted
if strings.Contains(modifiedChartYaml, "file://../relative-chart") {
t.Errorf("relative file:// path should have been converted")
}
// Absolute path should remain unchanged
if !strings.Contains(modifiedChartYaml, "file:///absolute/chart") {
t.Errorf("absolute file:// path should not be modified")
}
// OCI repo should remain unchanged
if !strings.Contains(modifiedChartYaml, "oci://registry.example.com") {
t.Errorf("oci repository should not be modified")
}
@@ -138,7 +140,6 @@ dependencies:
expectModified: true,
expectError: false,
validate: func(t *testing.T, modifiedChartYaml string) {
// All relative paths should be converted
if strings.Contains(modifiedChartYaml, "file://../chart1") ||
strings.Contains(modifiedChartYaml, "file://./chart2") ||
strings.Contains(modifiedChartYaml, "file://../../../chart3") {
@@ -185,14 +186,12 @@ extra-field: aaa
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "helmfile-test-")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create Chart.yaml if provided
if tt.chartYaml != "" {
chartYamlPath := filepath.Join(tempDir, "Chart.yaml")
if err := os.WriteFile(chartYamlPath, []byte(tt.chartYaml), 0644); err != nil {
@@ -200,22 +199,13 @@ extra-field: aaa
}
}
// Create HelmState with logger
logger := zap.NewNop().Sugar()
st := &HelmState{
logger: logger,
fs: filesystem.DefaultFileSystem(),
}
// Read original content if it exists
var originalContent []byte
chartYamlPath := filepath.Join(tempDir, "Chart.yaml")
if _, err := os.Stat(chartYamlPath); err == nil {
originalContent, _ = os.ReadFile(chartYamlPath)
}
// Call rewriteChartDependencies
cleanup, err := st.rewriteChartDependencies(tempDir)
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if tt.expectError {
if err == nil {
@@ -224,44 +214,39 @@ extra-field: aaa
return
}
if tt.expectModified {
if rewrittenPath == tempDir {
t.Errorf("expected rewrittenPath != tempDir when modifications are needed, got same path")
}
} else {
if rewrittenPath != tempDir {
t.Errorf("expected rewrittenPath == tempDir when no modifications are needed, got %q", rewrittenPath)
}
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cleanup()
if tt.chartYaml == "" {
return
}
modifiedChartBytes, err := os.ReadFile(filepath.Join(tempDir, "Chart.yaml"))
modifiedChartBytes, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml"))
if err != nil {
t.Fatalf("failed to read Chart.yaml: %v", err)
}
modifiedChartYaml := string(modifiedChartBytes)
// Validate the modified Chart.yaml
if tt.validate != nil {
tt.validate(t, modifiedChartYaml)
}
// Call cleanup and verify restoration
cleanup()
// Read restored content
restoredContent, err := os.ReadFile(chartYamlPath)
if err != nil {
t.Fatalf("failed to read restored Chart.yaml: %v", err)
}
// Verify content was restored
if string(restoredContent) != string(originalContent) {
t.Errorf("cleanup did not restore original content\noriginal:\n%s\nrestored:\n%s",
string(originalContent), string(restoredContent))
}
})
}
}
func TestRewriteChartDependencies_CleanupRestoresOriginal(t *testing.T) {
func TestRewriteChartDependencies_OriginalNotModified(t *testing.T) {
tempDir, err := os.MkdirTemp("", "helmfile-test-")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -288,33 +273,44 @@ dependencies:
fs: filesystem.DefaultFileSystem(),
}
cleanup, err := st.rewriteChartDependencies(tempDir)
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify modification happened
modifiedContent, err := os.ReadFile(chartYamlPath)
t.Cleanup(func() {
if rewrittenPath == tempDir {
return
}
cleanup()
if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) {
t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath)
}
})
if rewrittenPath == tempDir {
t.Errorf("expected a different path when modifications are needed, got same path")
}
modifiedContent, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml"))
if err != nil {
t.Fatalf("failed to read modified Chart.yaml: %v", err)
}
if string(modifiedContent) == originalChart {
t.Errorf("Chart.yaml should have been modified")
t.Errorf("Chart.yaml in the copy should have been modified")
}
// Call cleanup
cleanup()
// Verify restoration
restoredContent, err := os.ReadFile(chartYamlPath)
originalContent, err := os.ReadFile(chartYamlPath)
if err != nil {
t.Fatalf("failed to read restored Chart.yaml: %v", err)
t.Fatalf("failed to read original Chart.yaml: %v", err)
}
if string(restoredContent) != originalChart {
t.Errorf("cleanup did not restore original content\nexpected:\n%s\ngot:\n%s",
originalChart, string(restoredContent))
if string(originalContent) != originalChart {
t.Errorf("original Chart.yaml should not have been modified\nexpected:\n%s\ngot:\n%s",
originalChart, string(originalContent))
}
}
@@ -353,21 +349,30 @@ dependencies:
fs: filesystem.DefaultFileSystem(),
}
cleanup, err := st.rewriteChartDependencies(tempDir)
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cleanup()
// Read modified content
modifiedContent, err := os.ReadFile(chartYamlPath)
t.Cleanup(func() {
if rewrittenPath == tempDir {
return
}
cleanup()
if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) {
t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath)
}
})
modifiedContent, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml"))
if err != nil {
t.Fatalf("failed to read modified Chart.yaml: %v", err)
}
content := string(modifiedContent)
// Verify fields are preserved
requiredFields := []string{
"apiVersion: v2",
"name: test-chart",
@@ -384,10 +389,17 @@ dependencies:
}
}
// Verify the dependency was rewritten
if strings.Contains(content, "file://../relative-chart") {
t.Errorf("relative path should have been converted to absolute")
}
originalContent, err := os.ReadFile(chartYamlPath)
if err != nil {
t.Fatalf("failed to read original Chart.yaml: %v", err)
}
if string(originalContent) != chartYaml {
t.Errorf("original Chart.yaml should not have been modified")
}
}
func TestRewriteChartDependencies_ErrorHandling(t *testing.T) {
@@ -395,7 +407,6 @@ func TestRewriteChartDependencies_ErrorHandling(t *testing.T) {
name string
setupFunc func(tempDir string) error
expectError bool
errorMsg string
}{
{
name: "invalid yaml in Chart.yaml",
@@ -444,7 +455,7 @@ dependencies:
fs: filesystem.DefaultFileSystem(),
}
_, err = st.rewriteChartDependencies(tempDir)
_, _, err = st.rewriteChartDependencies(tempDir)
if tt.expectError && err == nil {
t.Errorf("expected error but got none")
@@ -463,8 +474,6 @@ func TestRewriteChartDependencies_WindowsStylePath(t *testing.T) {
}
defer os.RemoveAll(tempDir)
// Test with backslash (Windows-style) paths
// Note: file:// URLs should use forward slashes, but test handling of edge cases
chartYaml := `apiVersion: v2
name: test-chart
version: 1.0.0
@@ -485,21 +494,618 @@ dependencies:
fs: filesystem.DefaultFileSystem(),
}
cleanup, err := st.rewriteChartDependencies(tempDir)
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cleanup()
// Should handle the path correctly
data, err := os.ReadFile(chartYamlPath)
t.Cleanup(func() {
if rewrittenPath == tempDir {
return
}
cleanup()
if _, statErr := os.Stat(rewrittenPath); !os.IsNotExist(statErr) {
t.Errorf("expected rewritten chart path %q to be removed after cleanup", rewrittenPath)
}
})
data, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml"))
if err != nil {
t.Fatalf("failed to read Chart.yaml: %v", err)
}
content := string(data)
// The relative path should have been converted to absolute
if strings.Contains(content, "file://./subdir/chart") {
t.Errorf("relative path with ./ should have been converted")
}
}
func TestRewriteChartDependencies_RaceCondition(t *testing.T) {
tempDir, err := os.MkdirTemp("", "helmfile-test-")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
chartYaml := `apiVersion: v2
name: test-chart
version: 1.0.0
dependencies:
- name: dep1
repository: file://../relative-chart
version: 1.0.0
`
chartYamlPath := filepath.Join(tempDir, "Chart.yaml")
if err := os.WriteFile(chartYamlPath, []byte(chartYaml), 0644); err != nil {
t.Fatalf("failed to write Chart.yaml: %v", err)
}
numGoroutines := 10
var wg sync.WaitGroup
var readyWg sync.WaitGroup
errCh := make(chan error, numGoroutines)
ready := make(chan struct{})
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
readyWg.Add(1)
go func() {
defer wg.Done()
readyWg.Done()
<-ready
logger := zap.NewNop().Sugar()
st := &HelmState{
logger: logger,
fs: filesystem.DefaultFileSystem(),
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
errCh <- err
return
}
defer cleanup()
data, readErr := os.ReadFile(filepath.Join(rewrittenPath, "Chart.yaml"))
if readErr != nil {
errCh <- readErr
return
}
type ChartDependency struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
}
type ChartMeta struct {
APIVersion string `yaml:"apiVersion"`
Name string `yaml:"name"`
Version string `yaml:"version"`
Dependencies []ChartDependency `yaml:"dependencies,omitempty"`
}
var meta ChartMeta
if unmarshalErr := yaml.Unmarshal(data, &meta); unmarshalErr != nil {
errCh <- unmarshalErr
return
}
if meta.Name != "test-chart" {
errCh <- fmt.Errorf("expected chart name 'test-chart', got %q", meta.Name)
return
}
}()
}
readyWg.Wait()
close(ready)
wg.Wait()
close(errCh)
for err := range errCh {
t.Errorf("goroutine error: %v", err)
}
data, err := os.ReadFile(chartYamlPath)
if err != nil {
t.Fatalf("failed to read original Chart.yaml: %v", err)
}
type ChartDependency struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
}
type ChartMeta struct {
APIVersion string `yaml:"apiVersion"`
Name string `yaml:"name"`
Version string `yaml:"version"`
Dependencies []ChartDependency `yaml:"dependencies,omitempty"`
}
var chartMeta ChartMeta
if err := yaml.Unmarshal(data, &chartMeta); err != nil {
t.Fatalf("original Chart.yaml is not valid YAML: %v", err)
}
if chartMeta.Name != "test-chart" {
t.Errorf("expected original chart name 'test-chart', got %q", chartMeta.Name)
}
if len(chartMeta.Dependencies) == 0 {
t.Fatalf("expected original Chart.yaml to contain at least one dependency, got %d", len(chartMeta.Dependencies))
}
const wantDependencyName = "dep1"
if chartMeta.Dependencies[0].Name != wantDependencyName {
t.Errorf("expected first dependency name %q, got %q", wantDependencyName, chartMeta.Dependencies[0].Name)
}
const wantRepository = "file://../relative-chart"
if chartMeta.Dependencies[0].Repository != wantRepository {
t.Errorf("expected original dependency repository %q, got %q", wantRepository, chartMeta.Dependencies[0].Repository)
}
}
// TestRewriteChartDependencies_RefreshesChartLock verifies that when Chart.yaml has
// its file:// dependencies rewritten to absolute paths, an existing Chart.lock is
// also updated in the temp copy: the digest is recomputed (otherwise `helm dep
// build` would error with "lock out of sync") and matching file:// repository URLs
// are mirrored over from the rewritten Chart.yaml (otherwise `helm dep build` would
// resolve the lock's relative file:// path against the temp directory and fail).
// Locked versions are preserved verbatim.
func TestRewriteChartDependencies_RefreshesChartLock(t *testing.T) {
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
- name: remote-dep
repository: https://example.com/charts
version: "*"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
chartLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
- name: remote-dep
repository: https://example.com/charts
version: 1.2.3
digest: ` + originalDigest + `
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
if rewrittenPath == tempDir {
t.Fatalf("expected a temp copy to be created, got original path %q", rewrittenPath)
}
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
} `yaml:"dependencies"`
Digest string `yaml:"digest"`
Generated string `yaml:"generated"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if lock.Digest == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", lock.Digest)
}
if !strings.HasPrefix(lock.Digest, "sha256:") {
t.Errorf("expected sha256 digest, got %q", lock.Digest)
}
if len(lock.Dependencies) != 2 {
t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies))
}
// The local file:// dependency's repository must be mirrored to the absolute
// path so `helm dep build` can resolve it from the temp chart directory.
localDep := lock.Dependencies[0]
if localDep.Name != "local-dep" {
t.Fatalf("expected first lock dep name 'local-dep', got %q", localDep.Name)
}
if !filepath.IsAbs(strings.TrimPrefix(localDep.Repository, "file://")) {
t.Errorf("expected local-dep repository to be an absolute file:// path, got %q", localDep.Repository)
}
if localDep.Version != "1.0.0" {
t.Errorf("expected local-dep version preserved as 1.0.0, got %q", localDep.Version)
}
// Remote (non-file://) deps must be untouched.
remoteDep := lock.Dependencies[1]
if remoteDep.Repository != "https://example.com/charts" {
t.Errorf("expected remote dep repository unchanged, got %q", remoteDep.Repository)
}
if remoteDep.Version != "1.2.3" {
t.Errorf("expected remote dep version preserved as 1.2.3, got %q", remoteDep.Version)
}
// The original Chart.lock on disk must be untouched.
originalLock, err := os.ReadFile(filepath.Join(tempDir, "Chart.lock"))
if err != nil {
t.Fatalf("reading original Chart.lock: %v", err)
}
if string(originalLock) != chartLock {
t.Errorf("original Chart.lock was modified; expected unchanged content")
}
}
// TestRewriteChartDependencies_RefreshesChartLockWithExtraFields verifies that
// Chart.lock digest recomputation includes all dependency fields (alias, condition,
// tags, import-values, enabled) — not just name/repository/version — so the digest
// stays compatible with Helm's resolver.HashReq for charts using those fields.
// It proves field coverage by running two chart variants under a shared root
// (so file:// paths resolve to the same absolute location) and asserting the
// digests differ only due to extra fields.
func TestRewriteChartDependencies_RefreshesChartLockWithExtraFields(t *testing.T) {
// Use a shared root so both chart variants resolve file://../local-dep to the
// same absolute path — isolating the digest difference to field content only.
sharedRoot := t.TempDir()
chartDir := filepath.Join(sharedRoot, "parent")
if err := os.MkdirAll(chartDir, 0755); err != nil {
t.Fatalf("creating chart dir: %v", err)
}
// Run rewriteChartDependencies for a given Chart.yaml and return the recomputed digest.
getDigest := func(t *testing.T, chartYaml, chartLock string) string {
t.Helper()
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: chartDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
return lock.Digest
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
baseLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
digest: ` + originalDigest + `
generated: "2024-01-01T00:00:00Z"
`
// Chart.yaml with extra fields (alias, condition, tags, import-values).
chartYamlWithExtras := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
condition: local-dep.enabled
tags:
- frontend
- optional
import-values:
- child: config
parent: global.config
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
`
// Same chart without condition/tags/import-values — only alias remains.
chartYamlWithoutExtras := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
alias: my-local
- name: local-dep
repository: file://../local-dep-alt
version: 2.0.0
alias: my-local-alt
`
digestWith := getDigest(t, chartYamlWithExtras, baseLock)
digestWithout := getDigest(t, chartYamlWithoutExtras, baseLock)
if !strings.HasPrefix(digestWith, "sha256:") {
t.Errorf("expected sha256 digest, got %q", digestWith)
}
if digestWith == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", digestWith)
}
if digestWith == digestWithout {
t.Errorf("digest should differ when extra fields (condition, tags, import-values) are present, but both are %q", digestWith)
}
// Also verify alias-based matching: both deps have name "local-dep" but
// different aliases; both should get their file:// paths rewritten.
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(chartYamlWithExtras), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(chartDir, "Chart.lock"), []byte(baseLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: chartDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(chartDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
Version string `yaml:"version"`
Alias string `yaml:"alias"`
} `yaml:"dependencies"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if len(lock.Dependencies) != 2 {
t.Fatalf("expected 2 lock dependencies, got %d", len(lock.Dependencies))
}
dep1 := lock.Dependencies[0]
if dep1.Alias != "my-local" {
t.Errorf("expected first lock dep alias 'my-local', got %q", dep1.Alias)
}
if !filepath.IsAbs(strings.TrimPrefix(dep1.Repository, "file://")) {
t.Errorf("expected first dep repository to be an absolute file:// path, got %q", dep1.Repository)
}
dep2 := lock.Dependencies[1]
if dep2.Alias != "my-local-alt" {
t.Errorf("expected second lock dep alias 'my-local-alt', got %q", dep2.Alias)
}
if !filepath.IsAbs(strings.TrimPrefix(dep2.Repository, "file://")) {
t.Errorf("expected second dep repository to be an absolute file:// path, got %q", dep2.Repository)
}
}
// TestRewriteChartDependencies_GoYamlV2ImportValues verifies that Chart.lock
// refresh works under go-yaml v2 (HELMFILE_GO_YAML_V3=false), where nested
// maps in import-values decode as map[interface{}]interface{} which json.Marshal
// cannot handle without normalization.
func TestRewriteChartDependencies_GoYamlV2ImportValues(t *testing.T) {
prev := runtime.GoYamlV3
runtime.GoYamlV3 = false
t.Cleanup(func() {
runtime.GoYamlV3 = prev
})
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: parent-chart
version: 1.0.0
dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
import-values:
- child: config
parent: global.config
`
chartLock := `dependencies:
- name: local-dep
repository: file://../local-dep
version: 1.0.0
import-values:
- child: config
parent: global.config
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
if !strings.HasPrefix(lock.Digest, "sha256:") {
t.Errorf("expected sha256 digest, got %q", lock.Digest)
}
const originalDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
if lock.Digest == originalDigest {
t.Errorf("expected digest to be recomputed; still %q", lock.Digest)
}
}
// TestRewriteChartDependencies_DigestMatchesHelmHashReq verifies the recomputed
// digest matches what Helm's resolver.HashReq would produce for a known input.
// This guards against producing a digest that is "different" but still rejected
// by `helm dependency build`.
func TestRewriteChartDependencies_DigestMatchesHelmHashReq(t *testing.T) {
tempDir := t.TempDir()
chartYaml := `apiVersion: v2
name: test-chart
version: 1.0.0
dependencies:
- name: dep-a
repository: file://../dep-a
version: 2.0.0
condition: dep-a.enabled
tags:
- backend
`
chartLock := `dependencies:
- name: dep-a
repository: file://../dep-a
version: 2.0.0
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
generated: "2024-01-01T00:00:00Z"
`
if err := os.WriteFile(filepath.Join(tempDir, "Chart.yaml"), []byte(chartYaml), 0644); err != nil {
t.Fatalf("writing Chart.yaml: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "Chart.lock"), []byte(chartLock), 0644); err != nil {
t.Fatalf("writing Chart.lock: %v", err)
}
logger := zap.NewNop().Sugar()
st := &HelmState{
basePath: tempDir,
fs: filesystem.DefaultFileSystem(),
logger: logger,
}
rewrittenPath, cleanup, err := st.rewriteChartDependencies(tempDir)
if err != nil {
t.Fatalf("rewriteChartDependencies failed: %v", err)
}
defer cleanup()
lockData, err := os.ReadFile(filepath.Join(rewrittenPath, "Chart.lock"))
if err != nil {
t.Fatalf("reading rewritten Chart.lock: %v", err)
}
var lock struct {
Dependencies []*helmchart.Dependency `yaml:"dependencies"`
Digest string `yaml:"digest"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
t.Fatalf("parsing rewritten Chart.lock: %v", err)
}
// Compute the expected digest independently using Helm's HashReq algorithm:
// sha256(json.Marshal([2][]*chart.Dependency{req, lock}))
// where req = rewritten Chart.yaml deps, lock = rewritten Chart.lock deps.
absDepA, err := filepath.Abs(filepath.Join(tempDir, "../dep-a"))
if err != nil {
t.Fatalf("resolving absolute path: %v", err)
}
req := []*helmchart.Dependency{
{
Name: "dep-a",
Repository: "file://" + absDepA,
Version: "2.0.0",
Condition: "dep-a.enabled",
Tags: []string{"backend"},
},
}
lockDeps := []*helmchart.Dependency{
{
Name: "dep-a",
Repository: "file://" + absDepA,
Version: "2.0.0",
},
}
payload, err := json.Marshal([2][]*helmchart.Dependency{req, lockDeps})
if err != nil {
t.Fatalf("marshaling expected digest payload: %v", err)
}
sum := sha256.Sum256(payload)
expectedDigest := "sha256:" + hex.EncodeToString(sum[:])
if lock.Digest != expectedDigest {
t.Errorf("digest mismatch with Helm's HashReq algorithm:\n got: %s\n want: %s", lock.Digest, expectedDigest)
}
}
+16
View File
@@ -129,6 +129,20 @@ func (d *ResolvedDependencies) Get(chart, versionConstraint string) (string, err
return "", fmt.Errorf("no resolved dependency found for \"%s\", running \"helmfile deps\" may resolve the issue", chart)
}
func dedupResolvedDependencies(deps []ResolvedChartDependency) []ResolvedChartDependency {
seen := map[string]bool{}
result := make([]ResolvedChartDependency, 0, len(deps))
for _, dep := range deps {
key := dep.ChartName + "|" + dep.Repository + "|" + dep.Version
if seen[key] {
continue
}
seen[key] = true
result = append(result, dep)
}
return result
}
func (st *HelmState) mergeLockedDependencies() (*HelmState, error) {
filename, unresolved := getUnresolvedDependenciess(st)
@@ -369,6 +383,8 @@ func (m *chartDependencyManager) doUpdate(chartLockFile string, unresolved *Unre
return lockedReqs.ResolvedDependencies[i].ChartName < lockedReqs.ResolvedDependencies[j].ChartName
})
lockedReqs.ResolvedDependencies = dedupResolvedDependencies(lockedReqs.ResolvedDependencies)
lockedReqs.Version = version.Version()
updatedLockFileContent, err = yaml.Marshal(lockedReqs)
+63
View File
@@ -6,6 +6,69 @@ import (
"github.com/stretchr/testify/require"
)
func TestDedupResolvedDependencies(t *testing.T) {
tests := []struct {
name string
input []ResolvedChartDependency
expected []ResolvedChartDependency
}{
{
name: "no duplicates",
input: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "redis", Repository: "https://charts.bitnami.com/bitnami", Version: "17.0.7"},
},
expected: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "redis", Repository: "https://charts.bitnami.com/bitnami", Version: "17.0.7"},
},
},
{
name: "duplicates removed",
input: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
},
expected: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
},
},
{
name: "same chart different versions kept",
input: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.1"},
},
expected: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.1"},
},
},
{
name: "same chart different repos kept",
input: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "app-template", Repository: "https://other.com", Version: "4.6.2"},
},
expected: []ResolvedChartDependency{
{ChartName: "app-template", Repository: "https://example.com", Version: "4.6.2"},
{ChartName: "app-template", Repository: "https://other.com", Version: "4.6.2"},
},
},
{
name: "empty input",
input: []ResolvedChartDependency{},
expected: []ResolvedChartDependency{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := dedupResolvedDependencies(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
func TestGetUnresolvedDependenciess(t *testing.T) {
tests := []struct {
name string
+24 -9
View File
@@ -169,7 +169,7 @@ func (c *StateCreator) LoadEnvValues(target *HelmState, env string, failOnMissin
return nil, &StateLoadError{fmt.Sprintf("failed to read %s", state.FilePath), err}
}
newDefaults, err := state.loadValuesEntries(nil, state.DefaultValues, c.remote, ctxEnv, env)
newDefaults, err := state.loadValuesEntries(nil, state.DefaultValues, c.remote, ctxEnv, env, "")
if err != nil {
return nil, err
}
@@ -237,17 +237,12 @@ func mergeEnvironments(dst, src map[string]EnvironmentSpec) {
for envName, srcEnv := range src {
if dstEnv, exists := dst[envName]; exists {
// Environment exists in both - merge the Values slices
mergedValues := append([]any{}, dstEnv.Values...)
mergedValues = append(mergedValues, srcEnv.Values...)
// Merge Secrets slices
mergedSecrets := append([]string{}, dstEnv.Secrets...)
mergedSecrets = append(mergedSecrets, srcEnv.Secrets...)
// Create merged environment
merged := EnvironmentSpec{
Values: mergedValues,
Secrets: mergedSecrets,
}
@@ -272,6 +267,26 @@ func mergeEnvironments(dst, src map[string]EnvironmentSpec) {
merged.MissingFileHandlerConfig = dstEnv.MissingFileHandlerConfig
}
// Override MergeStrategy if src has it
if srcEnv.MergeStrategy != "" {
merged.MergeStrategy = srcEnv.MergeStrategy
} else {
merged.MergeStrategy = dstEnv.MergeStrategy
}
// Concatenate Values so the later layer (src, e.g. main helmfile)
// always takes precedence over the earlier one (dst, e.g. a base
// helmfile). Under override the natural append order achieves that
// (last-file-wins). Under fallback we have to prepend src so that
// "first-file-wins" still resolves to "later layer wins".
if merged.MergeStrategy == MergeStrategyFallback {
mergedValues := append([]any{}, srcEnv.Values...)
merged.Values = append(mergedValues, dstEnv.Values...)
} else {
mergedValues := append([]any{}, dstEnv.Values...)
merged.Values = append(mergedValues, srcEnv.Values...)
}
dst[envName] = merged
} else {
// Environment only exists in src - just copy it
@@ -394,7 +409,7 @@ func (c *StateCreator) loadEnvValues(st *HelmState, name string, failOnMissingEn
if err != nil {
return nil, err
}
valuesVals, err = st.loadValuesEntries(envSpec.MissingFileHandler, envValuesEntries, c.remote, loadValuesEntriesEnv, name)
valuesVals, err = st.loadValuesEntries(envSpec.MissingFileHandler, envValuesEntries, c.remote, loadValuesEntriesEnv, name, envSpec.MergeStrategy)
if err != nil {
return nil, err
}
@@ -550,13 +565,13 @@ func (c *StateCreator) scatterGatherEnvSecretFiles(st *HelmState, envSecretFiles
return decryptedFilesKeeper, nil
}
func (st *HelmState) loadValuesEntries(missingFileHandler *string, entries []any, remote *remote.Remote, ctxEnv *environment.Environment, envName string) (map[string]any, error) {
func (st *HelmState) loadValuesEntries(missingFileHandler *string, entries []any, remote *remote.Remote, ctxEnv *environment.Environment, envName string, mergeStrategy string) (map[string]any, error) {
var envVals map[string]any
valuesEntries := append([]any{}, entries...)
ld := NewEnvironmentValuesLoader(st.storage(), st.fs, st.logger, remote)
var err error
envVals, err = ld.LoadEnvironmentValues(missingFileHandler, valuesEntries, ctxEnv, envName)
envVals, err = ld.LoadEnvironmentValues(missingFileHandler, valuesEntries, ctxEnv, envName, mergeStrategy)
if err != nil {
return nil, err
}
+93
View File
@@ -932,3 +932,96 @@ releases:
})
}
}
// mergeEnvironments must keep the established "later layer wins over earlier
// layer" semantics even when the resolved strategy is fallback. Under override
// the natural append order (dst then src) achieves that because last-wins
// chooses src; under fallback we have to prepend src so that first-wins still
// resolves to src. Without this adjustment, a base helmfile's values would
// silently override values declared in the main helmfile that includes it.
func TestMergeEnvironments_LaterLayerWinsRegardlessOfStrategy(t *testing.T) {
tests := []struct {
name string
strategy string
wantOrdering []any
}{
{
name: "override appends src after dst (last wins)",
strategy: MergeStrategyOverride,
wantOrdering: []any{"base.yaml", "main.yaml"},
},
{
name: "empty strategy defaults to override semantics",
strategy: "",
wantOrdering: []any{"base.yaml", "main.yaml"},
},
{
name: "fallback prepends src (first wins) so main still beats base",
strategy: MergeStrategyFallback,
wantOrdering: []any{"main.yaml", "base.yaml"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := map[string]EnvironmentSpec{
"prod": {Values: []any{"base.yaml"}, MergeStrategy: tt.strategy},
}
src := map[string]EnvironmentSpec{
"prod": {Values: []any{"main.yaml"}, MergeStrategy: tt.strategy},
}
mergeEnvironments(dst, src)
got := dst["prod"].Values
if !reflect.DeepEqual(got, tt.wantOrdering) {
t.Errorf("Values ordering: want %v, got %v", tt.wantOrdering, got)
}
})
}
}
// mergeEnvironments must preserve MergeStrategy when layering multiple
// helmfiles (bases). Without this, an environment that opted into
// fallback in a base helmfile would silently fall back to the default
// override behavior in any helmfile that re-declares the environment.
func TestMergeEnvironments_PreservesMergeStrategy(t *testing.T) {
tests := []struct {
name string
dst EnvironmentSpec
src EnvironmentSpec
expected string
}{
{
name: "src declares fallback, dst empty",
dst: EnvironmentSpec{},
src: EnvironmentSpec{MergeStrategy: MergeStrategyFallback},
expected: MergeStrategyFallback,
},
{
name: "dst declares fallback, src empty preserves dst",
dst: EnvironmentSpec{MergeStrategy: MergeStrategyFallback},
src: EnvironmentSpec{},
expected: MergeStrategyFallback,
},
{
name: "src override wins over dst fallback",
dst: EnvironmentSpec{MergeStrategy: MergeStrategyFallback},
src: EnvironmentSpec{MergeStrategy: MergeStrategyOverride},
expected: MergeStrategyOverride,
},
{
name: "both empty stays empty",
dst: EnvironmentSpec{},
src: EnvironmentSpec{},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := map[string]EnvironmentSpec{"prod": tt.dst}
src := map[string]EnvironmentSpec{"prod": tt.src}
mergeEnvironments(dst, src)
if got := dst["prod"].MergeStrategy; got != tt.expected {
t.Errorf("MergeStrategy after merge: want %q, got %q", tt.expected, got)
}
})
}
}
+13
View File
@@ -15,4 +15,17 @@ type EnvironmentSpec struct {
MissingFileHandler *string `yaml:"missingFileHandler,omitempty"`
// MissingFileHandlerConfig is composed of various settings for the MissingFileHandler
MissingFileHandlerConfig *MissingFileHandlerConfig `yaml:"missingFileHandlerConfig,omitempty"`
// MergeStrategy controls precedence when multiple values files are listed under `values`.
//
// "override" (default): later files override earlier files (the historical helmfile behavior).
// "fallback": earlier files take precedence; later files only fill gaps.
//
// Under the "fallback" strategy, an explicit non-nil value in an earlier file (including
// the zero values false, 0, "", and empty list) is preserved against any later file. Maps
// are deep-merged, so an earlier map does not block later files from adding nested keys.
// An explicit null in an earlier file falls through to a later file's value (matching how
// helmfile's MergeMaps treats nil from the override side elsewhere). Subsequent .gotmpl
// values files can also reference values from earlier files via .Values.
MergeStrategy string `yaml:"mergeStrategy,omitempty"`
}
+61 -28
View File
@@ -36,7 +36,13 @@ func NewEnvironmentValuesLoader(storage *Storage, fs *filesystem.FileSystem, log
}
}
func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *string, valuesEntries []any, ctxEnv *environment.Environment, envName string) (map[string]any, error) {
func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *string, valuesEntries []any, ctxEnv *environment.Environment, envName string, mergeStrategy string) (map[string]any, error) {
switch mergeStrategy {
case "", MergeStrategyOverride, MergeStrategyFallback:
default:
return nil, fmt.Errorf("environment %q: invalid mergeStrategy %q (must be %q or %q)",
envName, mergeStrategy, MergeStrategyOverride, MergeStrategyFallback)
}
var (
result = map[string]any{}
hclLoader = hcllang.NewHCLLoader(ld.fs, ld.logger)
@@ -44,8 +50,6 @@ func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *str
)
for _, entry := range valuesEntries {
maps := []any{}
switch strOrMap := entry.(type) {
case string:
files, skipped, err := ld.storage.resolveFile(missingFileHandler, "environment values", entry.(string))
@@ -64,37 +68,56 @@ func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *str
}
if strings.HasSuffix(f, ".hcl") {
hclLoader.AddFile(f)
} else {
// Use merged values (Defaults + Values + CLIOverrides) for template rendering
// so that CLI values are accessible via .Values in environment value files.
mergedVals, err := env.GetMergedValues()
if err != nil {
return nil, fmt.Errorf("failed to get merged values for environment file \"%s\": %v", f, err)
continue
}
// Use merged values (Defaults + Values + CLIOverrides) for template rendering
// so that CLI values are accessible via .Values in environment value files.
mergedVals, err := env.GetMergedValues()
if err != nil {
return nil, fmt.Errorf("failed to get merged values for environment file \"%s\": %v", f, err)
}
// Under fallback strategy, also expose values accumulated from earlier files
// in this same `values:` list, including earlier files in this same glob
// expansion, so a later .gotmpl can reference them via .Values (e.g.
// `{{ .Values.cluster.domain }}`). Env CLI overrides and values still win,
// layered on top with WithOverride.
if mergeStrategy == MergeStrategyFallback && len(result) > 0 {
enriched := map[string]any{}
if err := mergo.Merge(&enriched, result); err != nil {
return nil, fmt.Errorf("failed to build template context for \"%s\": %v", f, err)
}
tmplData := NewEnvironmentTemplateData(env, "", mergedVals)
r := tmpl.NewFileRenderer(ld.fs, filepath.Dir(f), tmplData)
bytes, err := r.RenderToBytes(f)
if err != nil {
return nil, fmt.Errorf("failed to load environment values file \"%s\": %v", f, err)
if err := mergo.Merge(&enriched, mergedVals, mergo.WithOverride); err != nil {
return nil, fmt.Errorf("failed to build template context for \"%s\": %v", f, err)
}
m := map[string]any{}
if err := yaml.Unmarshal(bytes, &m); err != nil {
return nil, fmt.Errorf("failed to load environment values file \"%s\": %v\n\nOffending YAML:\n%s", f, err, bytes)
}
maps = append(maps, m)
ld.logger.Debugf("envvals_loader: loaded %s:%v", strOrMap, m)
mergedVals = enriched
}
tmplData := NewEnvironmentTemplateData(env, "", mergedVals)
r := tmpl.NewFileRenderer(ld.fs, filepath.Dir(f), tmplData)
bytes, err := r.RenderToBytes(f)
if err != nil {
return nil, fmt.Errorf("failed to load environment values file \"%s\": %v", f, err)
}
m := map[string]any{}
if err := yaml.Unmarshal(bytes, &m); err != nil {
return nil, fmt.Errorf("failed to load environment values file \"%s\": %v\n\nOffending YAML:\n%s", f, err, bytes)
}
ld.logger.Debugf("envvals_loader: loaded %s:%v", strOrMap, m)
// Merge each file into result immediately so subsequent files in the same
// entry's expansion (e.g. a glob) can see prior files' values via .Values
// when rendered as templates.
result, err = mapMerge(result, []any{m}, mergeStrategy)
if err != nil {
return nil, err
}
}
case map[any]any, map[string]any:
maps = append(maps, strOrMap)
result, err = mapMerge(result, []any{strOrMap}, mergeStrategy)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unexpected type of value: value=%v, type=%T", strOrMap, strOrMap)
}
result, err = mapMerge(result, maps)
if err != nil {
return nil, err
}
}
maps := []any{}
if hclLoader.Length() > 0 {
@@ -104,14 +127,14 @@ func (ld *EnvironmentValuesLoader) LoadEnvironmentValues(missingFileHandler *str
}
maps = append(maps, m)
}
result, err = mapMerge(result, maps)
result, err = mapMerge(result, maps, mergeStrategy)
if err != nil {
return nil, err
}
return result, nil
}
func mapMerge(dest map[string]any, maps []any) (map[string]any, error) {
func mapMerge(dest map[string]any, maps []any, mergeStrategy string) (map[string]any, error) {
for _, m := range maps {
// All the nested map key should be string. Otherwise we get strange errors due to that
// mergo or reflect is unable to merge map[any]any with map[string]any or vice versa.
@@ -120,6 +143,16 @@ func mapMerge(dest map[string]any, maps []any) (map[string]any, error) {
if err != nil {
return nil, err
}
if mergeStrategy == MergeStrategyFallback {
// First-file-wins: the new file is the base and the
// accumulator overlays it, so keys already accumulated keep
// their value while keys only present in the new file fill
// in. MergeMaps is used instead of mergo because mergo's
// isEmptyValue rule would silently let a later fallback's
// `enabled: true` clobber an explicit `enabled: false`.
dest = maputil.MergeMaps(vals, dest)
continue
}
if err := mergo.Merge(&dest, &vals, mergo.WithOverride); err != nil {
return nil, fmt.Errorf("failed to merge %v: %v", m, err)
}
+298 -8
View File
@@ -2,6 +2,7 @@ package state
import (
"io"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -29,7 +30,7 @@ func newLoader() *EnvironmentValuesLoader {
func TestEnvValsLoad_SingleValuesFile(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.5.yaml"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.5.yaml"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -87,7 +88,7 @@ func TestEnvValsLoad_EnvironmentNameFile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.6.yaml.gotmpl"}, tt.env, tt.envName)
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.6.yaml.gotmpl"}, tt.env, tt.envName, "")
if err != nil {
t.Fatal(err)
}
@@ -103,7 +104,7 @@ func TestEnvValsLoad_EnvironmentNameFile(t *testing.T) {
func TestEnvValsLoad_SingleValuesFileRemote(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"git::https://github.com/helm/helm.git@cmd/helm/testdata/output/values.yaml?ref=v3.8.1"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"git::https://github.com/helm/helm.git@cmd/helm/testdata/output/values.yaml?ref=v3.8.1"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -121,7 +122,7 @@ func TestEnvValsLoad_SingleValuesFileRemote(t *testing.T) {
func TestEnvValsLoad_OverwriteNilValue_Issue1150(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.1.yaml", "testdata/values.2.yaml"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.1.yaml", "testdata/values.2.yaml"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -143,7 +144,7 @@ func TestEnvValsLoad_OverwriteNilValue_Issue1150(t *testing.T) {
func TestEnvValsLoad_OverwriteWithNilValue_Issue1154(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.3.yaml", "testdata/values.4.yaml"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.3.yaml", "testdata/values.4.yaml"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -166,7 +167,7 @@ func TestEnvValsLoad_OverwriteWithNilValue_Issue1154(t *testing.T) {
func TestEnvValsLoad_OverwriteEmptyValue_Issue1168(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/issues/1168/addons.yaml", "testdata/issues/1168/addons2.yaml"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/issues/1168/addons.yaml", "testdata/issues/1168/addons2.yaml"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -191,7 +192,7 @@ func TestEnvValsLoad_OverwriteEmptyValue_Issue1168(t *testing.T) {
func TestEnvValsLoad_MultiHCL(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.7.hcl", "testdata/values.8.hcl"}, nil, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.7.hcl", "testdata/values.8.hcl"}, nil, "", "")
if err != nil {
t.Fatal(err)
}
@@ -234,7 +235,7 @@ func TestEnvValsLoad_EnvironmentValues(t *testing.T) {
env := environment.New("test")
env.Values["foo"] = "bar"
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.9.yaml.gotmpl"}, env, "")
actual, err := l.LoadEnvironmentValues(nil, []any{"testdata/values.9.yaml.gotmpl"}, env, "", "")
if err != nil {
t.Fatal(err)
}
@@ -247,3 +248,292 @@ func TestEnvValsLoad_EnvironmentValues(t *testing.T) {
t.Error(diff)
}
}
// --- mergeStrategy: fallback ---
// Earlier files take precedence. Same conflicting key in two files →
// the value from default.yaml (loaded first) wins.
func TestEnvValsLoad_FallbackStrategy_EarlierWins(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
cluster := actual["cluster"].(map[string]any)
if got := cluster["domain"]; got != "example.com" {
t.Errorf("cluster.domain: want %q (from default.yaml), got %v", "example.com", got)
}
}
// Later files only fill keys that are missing from earlier files.
func TestEnvValsLoad_FallbackStrategy_FillsGaps(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
cluster := actual["cluster"].(map[string]any)
if got := cluster["region"]; got != "us-east-1" {
t.Errorf("cluster.region: want %q (from fallback.yaml, missing in default.yaml), got %v", "us-east-1", got)
}
service := actual["service"].(map[string]any)
if got := service["port"]; got != 8080 {
t.Errorf("service.port: want 8080 (from fallback.yaml), got %v", got)
}
}
// Nested maps merge recursively: top-level cluster is not replaced
// wholesale; both files contribute keys.
func TestEnvValsLoad_FallbackStrategy_DeepMerge(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
expected := map[string]any{
"cluster": map[string]any{
"domain": "example.com", // from default (wins)
"region": "us-east-1", // from fallback (gap filled)
},
"service": map[string]any{
"port": 8080, // from fallback (gap filled)
},
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("deep merge mismatch (-want +got):\n%s", diff)
}
}
// First-wins precedence holds across an arbitrarily long chain, not just
// pairwise. Three files exercise the accumulator state across iterations.
func TestEnvValsLoad_FallbackStrategy_ChainedFiles(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{
"testdata/mergestrategy/chain_a.yaml",
"testdata/mergestrategy/chain_b.yaml",
"testdata/mergestrategy/chain_c.yaml",
},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
expected := map[string]any{
"letter": "a", // only in a
"only_a": "from-a", // only in a
"only_b": "from-b", // only in b
"only_c": "from-c", // only in c
"both_ab": "from-a", // a and b → a wins (earlier)
"both_bc": "from-b", // b and c → b wins (earlier)
"all_three": "from-a", // a, b, c → a wins (earliest)
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("chain mismatch (-want +got):\n%s", diff)
}
}
// Explicit zero values in the earlier file MUST be preserved. Without the
// hand-rolled fallbackDeepMerge, mergo's isEmptyValue would silently let
// `enabled: true` from fallback overwrite `enabled: false` from default.
func TestEnvValsLoad_FallbackStrategy_PreservesExplicitZeroValues(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/zero_default.yaml", "testdata/mergestrategy/zero_fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
expected := map[string]any{
"enabled": false,
"replicas": 0,
"name": "",
"tags": []any{},
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("explicit zero values not preserved (-want +got):\n%s", diff)
}
}
// Explicit nil in the earlier file does NOT win under fallback: it falls
// through to the fallback file's value. This matches helmfile's existing
// MergeMaps treatment of nil ("nil from the override side only fills missing
// keys"; here, by argument-swap, nil from the winner is treated as
// "no preference, let the fallback fill it"). Documented as the deliberate
// difference from override mode, where mergo.WithOverride lets nil overwrite
// (see TestEnvValsLoad_OverwriteWithNilValue_Issue1154).
func TestEnvValsLoad_FallbackStrategy_NilFallsThroughToFallback(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/nil_default.yaml", "testdata/mergestrategy/nil_fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
expected := map[string]any{"value": "from-fallback"}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("explicit nil should fall through to fallback (-want +got):\n%s", diff)
}
}
// Inline map entries (not file paths) also honor the fallback strategy.
func TestEnvValsLoad_FallbackStrategy_InlineMapEntry(t *testing.T) {
l := newLoader()
inline := map[string]any{
"cluster": map[string]any{"domain": "inline.example"},
"extra": "from-inline",
}
actual, err := l.LoadEnvironmentValues(nil,
[]any{inline, "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
cluster := actual["cluster"].(map[string]any)
if got := cluster["domain"]; got != "inline.example" {
t.Errorf("cluster.domain: want inline value to win, got %v", got)
}
if got := actual["extra"]; got != "from-inline" {
t.Errorf("extra: want %q, got %v", "from-inline", got)
}
// fallback.yaml still fills gaps the inline map did not set.
if got := cluster["region"]; got != "us-east-1" {
t.Errorf("cluster.region: want %q from fallback file, got %v", "us-east-1", got)
}
}
// Regression guard: explicit "override" matches today's behavior
// (last file wins).
func TestEnvValsLoad_OverrideStrategy_PreservesCurrentBehavior(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyOverride)
if err != nil {
t.Fatal(err)
}
cluster := actual["cluster"].(map[string]any)
if got := cluster["domain"]; got != "cluster.local" {
t.Errorf("cluster.domain under override: want %q (from fallback.yaml), got %v", "cluster.local", got)
}
}
// Empty strategy is identical to explicit "override".
func TestEnvValsLoad_DefaultStrategy_MatchesOverride(t *testing.T) {
l := newLoader()
asDefault, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", "")
if err != nil {
t.Fatal(err)
}
asOverride, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml"},
nil, "", MergeStrategyOverride)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(asOverride, asDefault); diff != "" {
t.Errorf("default strategy diverges from override (-override +default):\n%s", diff)
}
}
// Within a single `values:` entry that expands to multiple files (a glob), a
// later .gotmpl in the expansion can reference earlier files in that same
// expansion. Matters because the inner file loop must merge each parsed file
// into the accumulator before rendering the next, not buffer the whole
// expansion and merge once at the end.
func TestEnvValsLoad_FallbackStrategy_GlobTemplateSeesPriorFileInSameExpansion(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/glob_*.yaml*"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
service := actual["service"].(map[string]any)
if got := service["domain"]; got != "service.example.com" {
t.Errorf("service.domain: want %q (templated from sibling glob match), got %v",
"service.example.com", got)
}
}
// The headline use case: under fallback, a later .gotmpl values file can
// reference values defined by earlier files in the same list via .Values.
// default.yaml sets cluster.domain; fallback.yaml.gotmpl renders
// `service.domain: "service.{{ .Values.cluster.domain }}"`.
func TestEnvValsLoad_FallbackStrategy_TemplateAccessesPriorFile(t *testing.T) {
l := newLoader()
actual, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml.gotmpl"},
nil, "", MergeStrategyFallback)
if err != nil {
t.Fatal(err)
}
service := actual["service"].(map[string]any)
if got := service["domain"]; got != "service.example.com" {
t.Errorf("service.domain: want %q (templated from prior file), got %v",
"service.example.com", got)
}
}
// Symmetric guard: under override, the same .gotmpl reference does NOT
// see prior files in the same list. Documents the deliberate scoping:
// the cross-file template enrichment is opt-in via mergeStrategy: fallback.
func TestEnvValsLoad_OverrideStrategy_TemplateContextUnchanged(t *testing.T) {
l := newLoader()
_, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml", "testdata/mergestrategy/fallback.yaml.gotmpl"},
nil, "", MergeStrategyOverride)
if err == nil {
t.Fatal("expected template render error: under override, .Values.cluster.domain should not resolve to a prior file's value")
}
// The exact error wording is owned by the template renderer; we only
// assert that we got an error rather than a bogus successful render.
}
// Unknown strategy values produce a clear error that names both the bad
// value and the valid options.
func TestEnvValsLoad_InvalidStrategy_Errors(t *testing.T) {
l := newLoader()
_, err := l.LoadEnvironmentValues(nil,
[]any{"testdata/mergestrategy/default.yaml"},
nil, "prod", "bogus")
if err == nil {
t.Fatal("expected error for invalid mergeStrategy, got nil")
}
for _, want := range []string{"prod", "bogus", MergeStrategyOverride, MergeStrategyFallback} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error message missing %q: %v", want, err)
}
}
}
+84 -14
View File
@@ -13,10 +13,12 @@ import (
"github.com/helmfile/chartify"
"helm.sh/helm/v4/pkg/storage/driver"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/kubedog"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/resource"
"github.com/helmfile/helmfile/pkg/tmpl"
)
type Dependency struct {
@@ -115,7 +117,7 @@ func (st *HelmState) appendPostRenderFlags(flags []string, release *ReleaseSpec,
}
// append post-renderer-args flags to helm flags
func (st *HelmState) appendPostRenderArgsFlags(flags []string, release *ReleaseSpec, postRendererArgs []string) []string {
func (st *HelmState) appendPostRenderArgsFlags(flags []string, release *ReleaseSpec, postRendererArgs []string) ([]string, error) {
postRendererArgsFlags := []string{}
switch {
case len(release.PostRendererArgs) != 0:
@@ -123,32 +125,70 @@ func (st *HelmState) appendPostRenderArgsFlags(flags []string, release *ReleaseS
case len(postRendererArgs) != 0:
postRendererArgsFlags = postRendererArgs
case len(st.HelmDefaults.PostRendererArgs) != 0:
postRendererArgsFlags = st.HelmDefaults.PostRendererArgs
rendered, err := st.renderPostRendererArgs(release, st.HelmDefaults.PostRendererArgs)
if err != nil {
return nil, err
}
postRendererArgsFlags = rendered
}
for _, arg := range postRendererArgsFlags {
if arg != "" {
flags = append(flags, "--post-renderer-args", arg)
flags = append(flags, "--post-renderer-args="+arg)
}
}
return flags
return flags, nil
}
func (st *HelmState) renderPostRendererArgs(release *ReleaseSpec, args []string) ([]string, error) {
vals := st.RenderedValues
if vals == nil {
vals = make(map[string]any)
}
fs := st.fs
if fs == nil {
fs = filesystem.DefaultFileSystem()
}
tmplData := st.createReleaseTemplateData(release, vals)
renderer := tmpl.NewFileRenderer(fs, st.basePath, tmplData)
result := make([]string, 0, len(args))
for _, arg := range args {
rendered, err := renderer.RenderTemplateContentToString([]byte(arg))
if err != nil {
return nil, fmt.Errorf("failed rendering postRendererArg %q for release %q: %w", arg, release.Name, err)
}
result = append(result, rendered)
}
return result, nil
}
// append skip-schema-validation flags to helm flags
func (st *HelmState) appendSkipSchemaValidationFlags(flags []string, release *ReleaseSpec, skipSchemaValidation bool) []string {
switch {
// Check if SkipSchemaValidation is true in the release spec.
case release.SkipSchemaValidation != nil && *release.SkipSchemaValidation:
flags = append(flags, "--skip-schema-validation")
// Check if skipSchemaValidation argument is true.
case skipSchemaValidation:
flags = append(flags, "--skip-schema-validation")
// Check if SkipSchemaValidation is true in HelmDefaults.
case st.HelmDefaults.SkipSchemaValidation != nil && *st.HelmDefaults.SkipSchemaValidation:
if st.shouldSkipSchemaValidation(release, skipSchemaValidation) {
flags = append(flags, "--skip-schema-validation")
}
return flags
}
func (st *HelmState) shouldSkipSchemaValidation(release *ReleaseSpec, skipSchemaValidation bool) bool {
switch {
// Check if SkipSchemaValidation is true in the release spec.
case release.SkipSchemaValidation != nil && *release.SkipSchemaValidation:
return true
// Check if skipSchemaValidation argument is true.
case skipSchemaValidation:
return true
// Check if SkipSchemaValidation is true in HelmDefaults.
case st.HelmDefaults.SkipSchemaValidation != nil && *st.HelmDefaults.SkipSchemaValidation:
return true
default:
return false
}
}
// append suppress-output-line-regex flags to helm diff flags
func (st *HelmState) appendSuppressOutputLineRegexFlags(flags []string, release *ReleaseSpec, suppressOutputLineRegex []string) []string {
suppressOutputLineRegexFlags := []string{}
@@ -189,6 +229,32 @@ func (st *HelmState) shouldUseKubedog(release *ReleaseSpec, ops *SyncOpts) bool
return st.getTrackMode(release, ops) == string(kubedog.TrackModeKubedog)
}
func (st *HelmState) shouldFailOnTrackError(release *ReleaseSpec, ops *SyncOpts) bool {
if release.TrackFailOnError != nil {
return *release.TrackFailOnError
}
if ops != nil {
return ops.TrackFailOnError
}
return false
}
// trackReleaseIfEnabled performs kubedog tracking for a release if trackMode is "kubedog".
// It returns a ReleaseError if tracking fails and shouldFailOnTrackError is true.
// The caller is responsible for mutating affectedReleases when needed.
func (st *HelmState) trackReleaseIfEnabled(ctx context.Context, release *ReleaseSpec, helm helmexec.Interface, opts *SyncOpts) *ReleaseError {
if !st.shouldUseKubedog(release, opts) {
return nil
}
if trackErr := st.trackWithKubedog(ctx, release, helm, opts); trackErr != nil {
st.logger.Warnf("kubedog tracking failed for release %s: %v", release.Name, trackErr)
if st.shouldFailOnTrackError(release, opts) {
return newReleaseFailedError(release, trackErr)
}
}
return nil
}
func (st *HelmState) getTrackMode(release *ReleaseSpec, ops *SyncOpts) string {
trackMode := release.TrackMode
if trackMode == "" && ops != nil && ops.TrackMode != "" {
@@ -382,7 +448,8 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp
for _, d := range release.Dependencies {
chart := d.Chart
if st.fs.DirectoryExistsAt(chart) {
normalizedChart := normalizeChart(st.basePath, chart)
if st.fs.DirectoryExistsAt(normalizedChart) {
var err error
// Otherwise helm-dependency-up on the temporary chart generated by chartify ends up errors like:
@@ -393,6 +460,9 @@ func (st *HelmState) PrepareChartify(helm helmexec.Interface, release *ReleaseSp
if err != nil {
return nil, clean, err
}
} else if rewritten, ok := st.resolveOCIAdhocDepChart(d.Chart); ok {
st.logger.Debugf("ad-hoc dependency %q rewritten to %q (matched OCI repo entry)", d.Chart, rewritten)
chart = rewritten
}
c.Opts.AdhocChartDependencies = append(c.Opts.AdhocChartDependencies, chartify.ChartDependency{
+86
View File
@@ -0,0 +1,86 @@
package state
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestAppendSkipSchemaValidationFlagToChartifyTemplateArgs(t *testing.T) {
enable := true
tests := []struct {
name string
defaults HelmSpec
release *ReleaseSpec
fromCLI bool
templateArgs string
want string
}{
{
name: "adds flag from release setting",
release: &ReleaseSpec{
SkipSchemaValidation: &enable,
},
want: "--skip-schema-validation",
},
{
name: "adds flag from helm defaults",
defaults: HelmSpec{
SkipSchemaValidation: &enable,
},
release: &ReleaseSpec{},
want: "--skip-schema-validation",
},
{
name: "appends flag to existing args",
release: &ReleaseSpec{
SkipSchemaValidation: &enable,
},
templateArgs: "--kube-context default",
want: "--kube-context default --skip-schema-validation",
},
{
name: "does not duplicate existing flag",
release: &ReleaseSpec{
SkipSchemaValidation: &enable,
},
templateArgs: "--skip-schema-validation --kube-context default",
want: "--skip-schema-validation --kube-context default",
},
{
name: "does not treat similar flag values as existing flag",
release: &ReleaseSpec{
SkipSchemaValidation: &enable,
},
templateArgs: "--set name=foo--skip-schema-validation",
want: "--set name=foo--skip-schema-validation --skip-schema-validation",
},
{
name: "adds flag from cli setting",
release: &ReleaseSpec{},
fromCLI: true,
templateArgs: "--kube-context default",
want: "--kube-context default --skip-schema-validation",
},
{
name: "does not add flag when disabled",
release: &ReleaseSpec{},
templateArgs: "--kube-context default",
want: "--kube-context default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
HelmDefaults: tt.defaults,
},
}
got := st.appendSkipSchemaValidationFlagToChartifyTemplateArgs(tt.templateArgs, tt.release, tt.fromCLI)
require.Equal(t, tt.want, got)
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package state
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/filesystem"
)
// TestLocalDependencyChartPathNormalization tests that relative chart paths in
// release dependencies (like "../chart") are normalized to absolute paths
// relative to basePath before checking if the directory exists.
// This is a regression test for issue #2596.
//
// Background: When helmfile.d/ contains multiple release files and one release
// has a local chart dependency (chart: ../chart), the dependency chart path was
// passed to DirectoryExistsAt without normalization, causing it to be resolved
// relative to the CWD instead of basePath. This made helmfile fail to detect
// the local chart and instead try to resolve it as a remote repo, resulting in
// "failed reading adhoc dependencies: no helm list entry found for repository".
func TestLocalDependencyChartPathNormalization(t *testing.T) {
tempDir := t.TempDir()
chartDir := filepath.Join(tempDir, "chart")
require.NoError(t, os.MkdirAll(chartDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: test-chart
version: 0.1.0
`), 0644))
helmfileDir := filepath.Join(tempDir, "helmfile.d")
require.NoError(t, os.MkdirAll(helmfileDir, 0755))
tests := []struct {
name string
chartPath string
basePath string
expectLocal bool
}{
{
name: "relative path ../chart normalized from helmfile.d",
chartPath: "../chart",
basePath: helmfileDir,
expectLocal: true,
},
{
name: "absolute path works unchanged",
chartPath: chartDir,
basePath: helmfileDir,
expectLocal: true,
},
{
name: "non-existent relative path not detected as local",
chartPath: "../nonexistent",
basePath: helmfileDir,
expectLocal: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalizedChart := normalizeChart(tt.basePath, tt.chartPath)
fs := filesystem.DefaultFileSystem()
isLocal := fs.DirectoryExistsAt(normalizedChart)
assert.Equal(t, tt.expectLocal, isLocal,
"normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v",
tt.basePath, tt.chartPath, normalizedChart, isLocal, tt.expectLocal)
})
}
}
// TestDependencyChartPathResolutionWithPrepareChartify verifies that the dependency
// chart path is normalized using basePath before calling DirectoryExistsAt,
// which is the core of the fix for issue #2596.
func TestDependencyChartPathResolutionWithPrepareChartify(t *testing.T) {
tempDir := t.TempDir()
chartDir := filepath.Join(tempDir, "chart")
require.NoError(t, os.MkdirAll(chartDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte(`
apiVersion: v2
name: test-chart
version: 0.1.0
`), 0644))
helmfileDir := filepath.Join(tempDir, "helmfile.d")
require.NoError(t, os.MkdirAll(helmfileDir, 0755))
fs := filesystem.DefaultFileSystem()
tests := []struct {
name string
depChartPath string
basePath string
expectDetected bool
}{
{
name: "relative ../chart from helmfile.d detected as local",
depChartPath: "../chart",
basePath: helmfileDir,
expectDetected: true,
},
{
name: "absolute path detected as local",
depChartPath: chartDir,
basePath: helmfileDir,
expectDetected: true,
},
{
name: "non-existent relative path not detected",
depChartPath: "../nonexistent",
basePath: helmfileDir,
expectDetected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalizedChart := normalizeChart(tt.basePath, tt.depChartPath)
isLocal := fs.DirectoryExistsAt(normalizedChart)
assert.Equal(t, tt.expectDetected, isLocal,
"normalizeChart(%q, %q) = %q, DirectoryExistsAt = %v, want %v",
tt.basePath, tt.depChartPath, normalizedChart, isLocal, tt.expectDetected)
if tt.expectDetected && !filepath.IsAbs(tt.depChartPath) {
absChart, err := filepath.Abs(filepath.Join(tt.basePath, tt.depChartPath))
require.NoError(t, err)
assert.Equal(t, absChart, normalizedChart,
"normalized path should match expected absolute path")
}
})
}
}
+66 -5
View File
@@ -47,6 +47,69 @@ func (l LabelFilter) Match(r ReleaseSpec) bool {
return true
}
// SelectorsAreCompatible checks whether any pair of selectors from two sets could
// potentially match the same release. It compares only positive labels (key=value):
// two selectors conflict if they require the same key to have different values.
// Returns true if at least one pair is compatible, false only if all pairs conflict.
// On parse error, returns (true, err): the conservative true ensures subhelmfiles
// are never incorrectly skipped due to malformed input, while the non-nil error
// allows callers to log or handle the parse failure if desired.
func SelectorsAreCompatible(selectorsA, selectorsB []string) (bool, error) {
if len(selectorsA) == 0 || len(selectorsB) == 0 {
return true, nil
}
filtersA, err := parseLabelFilters(selectorsA)
if err != nil {
return true, err
}
filtersB, err := parseLabelFilters(selectorsB)
if err != nil {
return true, err
}
for _, a := range filtersA {
for _, b := range filtersB {
if a.positiveLabelsCompatibleWith(b) {
return true, nil
}
}
}
return false, nil
}
func parseLabelFilters(selectors []string) ([]LabelFilter, error) {
filters := make([]LabelFilter, 0, len(selectors))
for _, s := range selectors {
f, err := ParseLabels(s)
if err != nil {
return nil, err
}
filters = append(filters, f)
}
return filters, nil
}
// positiveLabelsCompatibleWith returns true if the positive labels of two filters
// do not conflict (i.e., no shared key with a different value).
func (l LabelFilter) positiveLabelsCompatibleWith(other LabelFilter) bool {
for _, a := range l.positiveLabels {
for _, b := range other.positiveLabels {
if a[0] == b[0] && a[1] != b[1] {
return false
}
}
}
return true
}
var (
reLabelMismatch = regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+!=[a-zA-Z0-9_\.\/\+-]+$`)
reLabelMatch = regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+=[a-zA-Z0-9_\.\/\+-]+$`)
)
// ParseLabels takes a label in the form foo=bar,baz!=bat and returns a LabelFilter that will match the labels
func ParseLabels(l string) (LabelFilter, error) {
lf := LabelFilter{}
@@ -54,16 +117,14 @@ func ParseLabels(l string) (LabelFilter, error) {
lf.negativeLabels = [][]string{}
var err error
labels := strings.Split(l, ",")
reMissmatch := regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+!=[a-zA-Z0-9_\.\/\+-]+$`)
reMatch := regexp.MustCompile(`^[a-zA-Z0-9_\.\/\+-]+=[a-zA-Z0-9_\.\/\+-]+$`)
for _, label := range labels {
if match := reMissmatch.MatchString(label); match { // k!=v case
if match := reLabelMismatch.MatchString(label); match {
kv := strings.Split(label, "!=")
lf.negativeLabels = append(lf.negativeLabels, kv)
} else if match := reMatch.MatchString(label); match { // k=v case
} else if match := reLabelMatch.MatchString(label); match {
kv := strings.Split(label, "=")
lf.positiveLabels = append(lf.positiveLabels, kv)
} else { // malformed case
} else {
return lf, fmt.Errorf("malformed label: %s. Expected label in form k=v or k!=v", label)
}
}
+102
View File
@@ -67,3 +67,105 @@ func TestParseLabelsInvalidFormat(t *testing.T) {
expectedErrorMsg := "malformed label: invalid_label. Expected label in form k=v or k!=v"
assert.EqualError(t, err, expectedErrorMsg, "unexpected error message")
}
func TestSelectorsAreCompatible(t *testing.T) {
tests := []struct {
name string
selectorsA []string
selectorsB []string
compatible bool
wantErr bool
}{
{
name: "same key different value",
selectorsA: []string{"name=b"},
selectorsB: []string{"name=a"},
compatible: false,
},
{
name: "same key same value",
selectorsA: []string{"name=b"},
selectorsB: []string{"name=b"},
compatible: true,
},
{
name: "different keys no conflict",
selectorsA: []string{"name=b"},
selectorsB: []string{"env=prod"},
compatible: true,
},
{
name: "one compatible pair among multiple selectors",
selectorsA: []string{"name=b"},
selectorsB: []string{"name=a", "name=b"},
compatible: true,
},
{
name: "all pairs conflict",
selectorsA: []string{"name=b"},
selectorsB: []string{"name=a", "name=c"},
compatible: false,
},
{
name: "one compatible pair with same key same value",
selectorsA: []string{"name=b", "env=prod"},
selectorsB: []string{"name=b"},
compatible: true,
},
{
name: "empty selectorsA always compatible",
selectorsA: []string{},
selectorsB: []string{"name=a"},
compatible: true,
},
{
name: "empty selectorsB always compatible",
selectorsA: []string{"name=a"},
selectorsB: []string{},
compatible: true,
},
{
name: "negative labels not compared treated as compatible",
selectorsA: []string{"name!=a"},
selectorsB: []string{"name=a"},
compatible: true,
},
{
name: "compound selector with conflicting key",
selectorsA: []string{"name=a,env=prod"},
selectorsB: []string{"name=a,env=staging"},
compatible: false,
},
{
name: "compound selector with matching keys",
selectorsA: []string{"name=a,env=prod"},
selectorsB: []string{"name=a,env=prod"},
compatible: true,
},
{
name: "compound selector partial overlap different key",
selectorsA: []string{"name=a,env=prod"},
selectorsB: []string{"name=a"},
compatible: true,
},
{
name: "malformed selector returns conservative true with error",
selectorsA: []string{"name=b"},
selectorsB: []string{"invalid_label"},
compatible: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SelectorsAreCompatible(tt.selectorsA, tt.selectorsB)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.compatible, got)
})
}
}
+303 -61
View File
@@ -4,7 +4,9 @@ import (
"bytes"
gocontext "context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
@@ -27,6 +29,7 @@ import (
"github.com/helmfile/vals"
"github.com/tatsushid/go-prettytable"
"go.uber.org/zap"
helmchart "helm.sh/helm/v3/pkg/chart"
cliv3 "helm.sh/helm/v3/pkg/cli"
cliv4 "helm.sh/helm/v4/pkg/cli"
@@ -36,6 +39,7 @@ import (
"github.com/helmfile/helmfile/pkg/event"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/maputil"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/tmpl"
"github.com/helmfile/helmfile/pkg/yaml"
@@ -49,6 +53,12 @@ const (
// Valid enum for updateStrategy values
UpdateStrategyReinstallIfForbidden = "reinstallIfForbidden"
// Valid values for environment mergeStrategy.
// MergeStrategyOverride (default) makes later values files override earlier ones.
// MergeStrategyFallback flips the precedence: earlier files win and later files only fill gaps.
MergeStrategyOverride = "override"
MergeStrategyFallback = "fallback"
)
// ReleaseSetSpec is release set spec
@@ -85,6 +95,11 @@ type ReleaseSetSpec struct {
Templates map[string]TemplateSpec `yaml:"templates"`
// DefaultInherit is a list of template names that all releases inherit by default.
// Each release will automatically inherit these templates unless it already explicitly
// inherits from the same template.
DefaultInherit DefaultInherits `yaml:"defaultInherit,omitempty"`
Env environment.Environment `yaml:"-"`
// If set to "Error", return an error when a subhelmfile points to a
@@ -156,7 +171,7 @@ type SubHelmfileSpec struct {
// SubhelmfileEnvironmentSpec is the environment spec for a subhelmfile
type SubhelmfileEnvironmentSpec struct {
OverrideValues []any `yaml:"values,omitempty"`
OverrideValuesAreCLI bool `yaml:"-"`
OverrideCLISetValues []any `yaml:"-"` // CLI --state-values-set values only, merged element-by-element
}
// HelmSpec to defines helmDefault values
@@ -478,6 +493,8 @@ type ReleaseSpec struct {
KubedogQPS *float32 `yaml:"kubedogQPS,omitempty"`
// KubedogBurst specifies the burst for kubedog kubernetes client
KubedogBurst *int `yaml:"kubedogBurst,omitempty"`
// TrackFailOnError controls whether kubedog tracking failures cause a non-zero exit code
TrackFailOnError *bool `yaml:"trackFailOnError,omitempty"`
}
// TrackResourceSpec specifies a resource to track
@@ -502,6 +519,45 @@ func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}
type DefaultInherits []string
func (r *DefaultInherits) UnmarshalYAML(unmarshal func(any) error) error {
var list []string
if err := unmarshal(&list); err == nil {
*r = normalizeDefaultInherits(list)
return nil
}
var single string
if err := unmarshal(&single); err != nil {
return err
}
*r = normalizeDefaultInherits([]string{single})
return nil
}
// normalizeDefaultInherits trims names, drops empty entries, and returns nil for an empty result.
func normalizeDefaultInherits(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, name := range in {
name = strings.TrimSpace(name)
if name == "" {
continue
}
out = append(out, name)
}
if len(out) == 0 {
return nil
}
return out
}
// ChartPathOrName returns ChartPath if it is non-empty, and returns Chart otherwise.
// This is useful to redirect helm commands like `helm template`, `helm dependency update`, `helm diff`, and `helm upgrade --install` to
// our modified version of the chart, in case the user configured Helmfile to do modify the chart before being passed to Helm.
@@ -920,6 +976,7 @@ type SyncOpts struct {
TrackMode string
TrackTimeout int
TrackLogs bool
TrackFailOnError bool
Description string
}
@@ -1146,10 +1203,8 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme
}
} else if release.UpdateStrategy == UpdateStrategyReinstallIfForbidden {
relErr = st.performSyncOrReinstallOfRelease(affectedReleases, helm, context, release, chart, m, flags...)
if relErr == nil && st.shouldUseKubedog(release, opts) {
if trackErr := st.trackWithKubedog(gocontext.Background(), release, helm, opts); trackErr != nil {
st.logger.Warnf("kubedog tracking failed for release %s: %v", release.Name, trackErr)
}
if relErr == nil {
relErr = st.trackReleaseIfEnabled(gocontext.Background(), release, helm, opts)
}
} else {
if err := helm.SyncRelease(context, release.Name, chart, release.Namespace, flags...); err != nil {
@@ -1168,10 +1223,11 @@ func (st *HelmState) SyncReleases(affectedReleases *AffectedReleases, helm helme
release.installedVersion = installedVersion
}
if st.shouldUseKubedog(release, opts) {
if trackErr := st.trackWithKubedog(gocontext.Background(), release, helm, opts); trackErr != nil {
st.logger.Warnf("kubedog tracking failed for release %s: %v", release.Name, trackErr)
}
if trackErr := st.trackReleaseIfEnabled(gocontext.Background(), release, helm, opts); trackErr != nil {
m.Lock()
affectedReleases.Failed = append(affectedReleases.Failed, release)
m.Unlock()
relErr = trackErr
}
}
}
@@ -1349,6 +1405,8 @@ type ChartPrepareOptions struct {
SkipRefresh bool
SkipResolve bool
SkipCleanup bool
// SkipSchemaValidation configures chartify to pass --skip-schema-validation to helm-template run by it.
SkipSchemaValidation bool
// Validate configures chartify to pass --validate to helm-template run by it.
// It's required when one of your chart relies on Capabilities.APIVersions in a template
Validate bool
@@ -1397,6 +1455,28 @@ func (st *HelmState) GetRepositoryAndNameFromChartName(chartName string) (*Repos
return nil, chartName
}
// resolveOCIAdhocDepChart rewrites a release `dependencies[].chart` value that
// uses the named-repo prefix form ("repoName/chartName") into a full oci:// URL
// when the prefix matches a `repositories:` entry with `oci: true`. It returns
// (rewritten, true) on a hit and ("", false) otherwise.
//
// This avoids the chartify path that does `helm repo list` to look up the
// repository URL: that lookup never finds OCI repos because helm 3+ does not
// register OCI registries as named repos (it uses `helm registry login`
// instead). By the time chartify sees an `oci://` URL it already takes the
// correct branch, so rewriting here is enough to make the named-repo form
// behave the same as the explicit URL form.
func (st *HelmState) resolveOCIAdhocDepChart(chart string) (string, bool) {
if strings.HasPrefix(chart, "oci://") {
return "", false
}
repo, name := st.GetRepositoryAndNameFromChartName(chart)
if repo == nil || !repo.OCI {
return "", false
}
return "oci://" + strings.TrimSuffix(repo.URL, "/") + "/" + name, true
}
var rwMutexMap sync.Map
// getNamedRWMutex retrieves or creates a sync.RWMutex for a given name.
@@ -1416,42 +1496,28 @@ type PrepareChartKey struct {
Namespace, Name, KubeContext string
}
// PrepareCharts creates temporary directories of charts.
//
// Each resulting "chart" can be one of the followings:
//
// (1) local chart
// (2) temporary local chart generated from kustomization or manifests
// (3) remote chart
//
// When running `helmfile template` on helm v2, or `helmfile lint` on both helm v2 and v3,
// PrepareCharts will download and untar charts for linting and templating.
//
// rewriteChartDependencies rewrites relative file:// dependencies in Chart.yaml to absolute paths
// to ensure they can be resolved from chartify's temporary directory
func (st *HelmState) rewriteChartDependencies(chartPath string) (func(), error) {
// to ensure they can be resolved from chartify's temporary directory.
// Instead of modifying the original chart in-place (which causes race conditions when multiple
// releases reference the same local chart), it creates a temporary copy only when a rewrite is
// needed and rewrites that copy. When a temp copy is created, it reflects the current chart
// contents so prepare hooks or other steps that mutate the local chart directory are honored.
// The returned cleanup function removes the temporary directory when one was created and is
// otherwise a no-op.
func (st *HelmState) rewriteChartDependencies(chartPath string) (string, func(), error) {
chartYamlPath := filepath.Join(chartPath, "Chart.yaml")
// Check if Chart.yaml exists
if _, err := os.Stat(chartYamlPath); os.IsNotExist(err) {
return func() {}, nil
if _, err := st.fs.Stat(chartYamlPath); os.IsNotExist(err) {
return chartPath, func() {}, nil
} else if err != nil {
return chartPath, func() {}, err
}
// Read Chart.yaml
data, err := os.ReadFile(chartYamlPath)
data, err := st.fs.ReadFile(chartYamlPath)
if err != nil {
return func() {}, err
return chartPath, func() {}, err
}
originalContent := data
cleanup := func() {
// Restore original Chart.yaml
if err := os.WriteFile(chartYamlPath, originalContent, 0644); err != nil {
st.logger.Warnf("Failed to restore original Chart.yaml at %s: %v", chartYamlPath, err)
}
}
// Parse Chart.yaml
type ChartDependency struct {
Name string `yaml:"name"`
Repository string `yaml:"repository"`
@@ -1464,23 +1530,20 @@ func (st *HelmState) rewriteChartDependencies(chartPath string) (func(), error)
var chartMeta ChartMeta
if err := yaml.Unmarshal(data, &chartMeta); err != nil {
return cleanup, err
return chartPath, func() {}, err
}
// Rewrite relative file:// dependencies to absolute paths
modified := false
for i := range chartMeta.Dependencies {
dep := &chartMeta.Dependencies[i]
if strings.HasPrefix(dep.Repository, "file://") {
relPath := strings.TrimPrefix(dep.Repository, "file://")
// Check if it's a relative path
if !filepath.IsAbs(relPath) {
// Convert to absolute path relative to the chart directory
absPath := filepath.Join(chartPath, relPath)
absPath, err = filepath.Abs(absPath)
if err != nil {
return cleanup, fmt.Errorf("failed to resolve absolute path for dependency %s: %w", dep.Name, err)
return chartPath, func() {}, fmt.Errorf("failed to resolve absolute path for dependency %s: %w", dep.Name, err)
}
st.logger.Debugf("Rewriting Chart dependency %s from %s to file://%s", dep.Name, dep.Repository, absPath)
@@ -1490,21 +1553,159 @@ func (st *HelmState) rewriteChartDependencies(chartPath string) (func(), error)
}
}
// Write back if modified
if modified {
updatedData, err := yaml.Marshal(&chartMeta)
if err != nil {
return cleanup, fmt.Errorf("failed to marshal Chart.yaml: %w", err)
}
if err := os.WriteFile(chartYamlPath, updatedData, 0644); err != nil {
return cleanup, fmt.Errorf("failed to write Chart.yaml: %w", err)
}
st.logger.Debugf("Rewrote Chart.yaml with absolute dependency paths at %s", chartYamlPath)
if !modified {
return chartPath, func() {}, nil
}
return cleanup, nil
updatedData, err := yaml.Marshal(&chartMeta)
if err != nil {
return chartPath, func() {}, fmt.Errorf("failed to marshal Chart.yaml: %w", err)
}
tempDir, err := st.fs.MkdirTemp("", "chart-deps-rewrite-*")
if err != nil {
return chartPath, func() {}, fmt.Errorf("failed to create temp directory for chart rewrite: %w", err)
}
if err := st.fs.CopyDir(chartPath, tempDir); err != nil {
if removeErr := st.fs.RemoveAll(tempDir); removeErr != nil {
st.logger.Warnf("Failed to remove temp chart directory %s: %v", tempDir, removeErr)
}
return chartPath, func() {}, fmt.Errorf("failed to copy chart to temp directory: %w", err)
}
tempChartYamlPath := filepath.Join(tempDir, "Chart.yaml")
if err := st.fs.WriteFile(tempChartYamlPath, updatedData, 0644); err != nil {
if removeErr := st.fs.RemoveAll(tempDir); removeErr != nil {
st.logger.Warnf("Failed to remove temp chart directory %s: %v", tempDir, removeErr)
}
return chartPath, func() {}, fmt.Errorf("failed to write Chart.yaml: %w", err)
}
st.logger.Debugf("Rewrote Chart.yaml with absolute dependency paths at %s", tempChartYamlPath)
// Rewriting Chart.yaml invalidates Chart.lock's digest, since helm computes the
// digest over the JSON-marshaled dependencies block. If the lock isn't refreshed,
// downstream `helm dependency build` errors with "lock file is out of sync with
// the dependencies file" and falls back to `dependency update`, which re-resolves
// version constraints (e.g. `version: "*"`) against the chart repo and silently
// pulls newer dependency versions. The version pins in the lock are still the
// intended truth — only the rewritten file:// repository URL changed. Mirror the
// rewrite into the lock and recompute the digest so `dep build` accepts it.
tempChartLockPath := filepath.Join(tempDir, "Chart.lock")
lockData, lockErr := st.fs.ReadFile(tempChartLockPath)
if lockErr != nil && !os.IsNotExist(lockErr) {
st.logger.Warnf("Failed to read Chart.lock at %s: %v", tempChartLockPath, lockErr)
}
if lockErr == nil {
var lock struct {
Dependencies []*helmchart.Dependency `yaml:"dependencies,omitempty"`
Digest string `yaml:"digest,omitempty"`
Generated string `yaml:"generated,omitempty"`
}
if err := yaml.Unmarshal(lockData, &lock); err != nil {
st.logger.Warnf("Failed to parse Chart.lock at %s: %v", tempChartLockPath, err)
} else {
// Build the request slice (rewritten Chart.yaml dependencies) using helm's
// own chart.Dependency type so the JSON used for hashing matches helm's
// exactly. All supported fields must be mapped, not just name/repository/
// version, because helm's digest algorithm hashes the full Dependency struct.
req := make([]*helmchart.Dependency, 0, len(chartMeta.Dependencies))
for _, d := range chartMeta.Dependencies {
dep := &helmchart.Dependency{
Name: d.Name,
Repository: d.Repository,
}
if v, ok := d.Data["version"].(string); ok {
dep.Version = v
}
if v, ok := d.Data["condition"].(string); ok {
dep.Condition = v
}
if v, ok := d.Data["alias"].(string); ok {
dep.Alias = v
}
if v, ok := d.Data["enabled"].(bool); ok {
dep.Enabled = v
}
if v, ok := d.Data["tags"].([]interface{}); ok {
tags := make([]string, 0, len(v))
for _, t := range v {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
dep.Tags = tags
}
if v, ok := d.Data["import-values"].([]interface{}); ok {
normalized, err := maputil.RecursivelyStringifyMapKey(v)
if err != nil {
st.logger.Warnf("Failed to normalize import-values for dependency %s: %v", d.Name, err)
} else {
dep.ImportValues = normalized.([]interface{})
}
}
req = append(req, dep)
}
// Mirror the rewritten file:// repository URLs onto matching lock entries.
// Without this, `helm dependency build` would resolve the lock's relative
// file:// paths against the (moved) chart directory and fail with
// "directory ... not found". Versions in the lock are left untouched.
// Match on Name + Alias to handle charts with duplicate dependency names
// distinguished by alias.
for _, ld := range lock.Dependencies {
if !strings.HasPrefix(ld.Repository, "file://") {
continue
}
for _, rd := range req {
if rd.Name == ld.Name && rd.Alias == ld.Alias && strings.HasPrefix(rd.Repository, "file://") {
ld.Repository = rd.Repository
break
}
}
}
// Normalize lock.Dependencies ImportValues to avoid json.Marshal failures
// when go-yaml v2 decodes nested maps as map[interface{}]interface{}.
for _, ld := range lock.Dependencies {
if ld.ImportValues != nil {
normalized, err := maputil.RecursivelyStringifyMapKey(ld.ImportValues)
if err != nil {
st.logger.Warnf("Failed to normalize import-values in Chart.lock for dependency %s: %v", ld.Name, err)
} else {
ld.ImportValues = normalized.([]interface{})
}
}
}
// Replicates helm's resolver.HashReq:
// json.Marshal([2][]*chart.Dependency{req, lock}) → sha256 hex.
// resolver.HashReq lives in helm.sh/helm/v3/internal/resolver, so we
// inline the (small, stable) algorithm rather than importing it.
if payload, err := json.Marshal([2][]*helmchart.Dependency{req, lock.Dependencies}); err != nil {
st.logger.Warnf("Failed to marshal deps for Chart.lock digest at %s: %v", tempChartLockPath, err)
} else {
sum := sha256.Sum256(payload)
lock.Digest = "sha256:" + hex.EncodeToString(sum[:])
if updated, err := yaml.Marshal(&lock); err != nil {
st.logger.Warnf("Failed to marshal Chart.lock at %s: %v", tempChartLockPath, err)
} else if err := st.fs.WriteFile(tempChartLockPath, updated, 0644); err != nil {
st.logger.Warnf("Failed to write Chart.lock at %s: %v", tempChartLockPath, err)
} else {
st.logger.Debugf("Refreshed Chart.lock digest at %s after Chart.yaml rewrite", tempChartLockPath)
}
}
}
}
cleanup := func() {
if removeErr := st.fs.RemoveAll(tempDir); removeErr != nil {
st.logger.Warnf("Failed to remove temp chart directory %s: %v", tempDir, removeErr)
}
}
return tempDir, cleanup, nil
}
// Otherwise, if a chart is not a helm chart, it will call "chartify" to turn it into a chart.
@@ -1516,11 +1717,12 @@ func (st *HelmState) processChartification(chartification *Chartify, release *Re
// This prevents errors like "Error: directory /tmp/chartify.../argocd-application not found"
// when Chart.yaml contains dependencies like "file://../argocd-application"
if st.fs.DirectoryExistsAt(chartPath) {
restoreChart, err := st.rewriteChartDependencies(chartPath)
rewrittenPath, cleanupTempChart, err := st.rewriteChartDependencies(chartPath)
if err != nil {
return "", false, fmt.Errorf("failed to rewrite chart dependencies: %w", err)
}
defer restoreChart()
chartPath = rewrittenPath
defer cleanupTempChart()
}
c := chartify.New(
@@ -1624,6 +1826,12 @@ func (st *HelmState) processChartification(chartification *Chartify, release *Re
}
}
chartifyOpts.TemplateArgs = st.appendSkipSchemaValidationFlagToChartifyTemplateArgs(
chartifyOpts.TemplateArgs,
release,
opts.SkipSchemaValidation,
)
out, err := c.Chartify(release.Name, chartPath, chartify.WithChartifyOpts(chartifyOpts))
if err != nil {
return "", false, err
@@ -1636,6 +1844,30 @@ func (st *HelmState) processChartification(chartification *Chartify, release *Re
return chartPath, buildDeps, nil
}
func (st *HelmState) appendSkipSchemaValidationFlagToChartifyTemplateArgs(templateArgs string, release *ReleaseSpec, skipSchemaValidation bool) string {
if !st.shouldSkipSchemaValidation(release, skipSchemaValidation) || hasTemplateArg(templateArgs, "--skip-schema-validation") {
return templateArgs
}
return appendTemplateArg(templateArgs, "--skip-schema-validation")
}
func hasTemplateArg(templateArgs, arg string) bool {
for _, token := range strings.Fields(templateArgs) {
if token == arg || strings.HasPrefix(token, arg+"=") {
return true
}
}
return false
}
func appendTemplateArg(templateArgs, arg string) string {
if templateArgs == "" {
return arg
}
return templateArgs + " " + arg
}
// processLocalChart handles local chart processing
func (st *HelmState) processLocalChart(normalizedChart, dir string, release *ReleaseSpec, helmfileCommand string, opts ChartPrepareOptions, isLocal bool) (string, error) {
chartPath := normalizedChart
@@ -3605,7 +3837,10 @@ func (st *HelmState) flagsForUpgrade(helm helmexec.Interface, release *ReleaseSp
if opt != nil {
postRendererArgs = opt.PostRendererArgs
}
flags = st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
flags, err = st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
if err != nil {
return nil, nil, err
}
skipSchemaValidation := false
if opt != nil {
@@ -3633,6 +3868,7 @@ func (st *HelmState) flagsForTemplate(helm helmexec.Interface, release *ReleaseS
var flags []string
flags = st.appendChartVersionFlags(flags, release)
flags = st.appendHelmXFlags(flags, release)
flags = st.appendEnableDNSFlags(flags, release)
var postRendererArgs []string
var showOnly []string
@@ -3647,7 +3883,10 @@ func (st *HelmState) flagsForTemplate(helm helmexec.Interface, release *ReleaseS
skipSchemaValidation = opt.SkipSchemaValidation
}
flags = st.appendPostRenderFlags(flags, release, postRenderer, helm)
flags = st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
flags, err := st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
if err != nil {
return nil, nil, err
}
flags = st.appendApiVersionsFlags(flags, release, kubeVersion)
flags = st.appendChartDownloadFlags(flags, release)
flags = st.appendShowOnlyFlags(flags, showOnly)
@@ -3770,7 +4009,11 @@ func (st *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec,
if opt != nil {
postRendererArgs = opt.PostRendererArgs
}
flags = st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
var err error
flags, err = st.appendPostRenderArgsFlags(flags, release, postRendererArgs)
if err != nil {
return nil, nil, err
}
skipSchemaValidation := false
if opt != nil {
@@ -3800,7 +4043,6 @@ func (st *HelmState) flagsForDiff(helm helmexec.Interface, release *ReleaseSpec,
takeOwnership = opt.TakeOwnership
}
var err error
flags, err = st.appendTakeOwnershipFlagsForDiff(flags, release, takeOwnership, pluginsDir)
if err != nil {
return nil, nil, err
+37 -1
View File
@@ -85,7 +85,10 @@ func (st *HelmState) ExecuteTemplates() (*HelmState, error) {
vals := st.Values()
for i, rt := range st.Releases {
release, err := st.releaseWithInheritedTemplate(&rt, nil)
rtWithDefaults := rt
rtWithDefaults.Inherit = st.applyDefaultInherit(rt.Inherit)
release, err := st.releaseWithInheritedTemplate(&rtWithDefaults, nil)
if err != nil {
var cyclicInheritanceErr CyclicReleaseTemplateInheritanceError
if errors.As(err, &cyclicInheritanceErr) {
@@ -224,3 +227,36 @@ func (st *HelmState) releaseWithInheritedTemplate(r *ReleaseSpec, inheritancePat
return &merged, nil
}
// applyDefaultInherit prepends default inherit templates to the release's inherit list.
// Templates that are already explicitly referenced by the release are not duplicated.
func (st *HelmState) applyDefaultInherit(releaseInherit Inherits) Inherits {
if len(st.DefaultInherit) == 0 {
return releaseInherit
}
// Build the deduplication set and filter out blank entries in one pass.
existing := make(map[string]bool, len(releaseInherit))
filtered := make(Inherits, 0, len(releaseInherit))
for _, inh := range releaseInherit {
if name := strings.TrimSpace(inh.Template); name != "" {
existing[name] = true
filtered = append(filtered, inh)
}
}
result := make(Inherits, 0, len(st.DefaultInherit)+len(filtered))
for _, name := range st.DefaultInherit {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !existing[name] {
result = append(result, Inherit{Template: name})
existing[name] = true
}
}
result = append(result, filtered...)
return result
}
+210
View File
@@ -7,9 +7,12 @@ import (
"testing"
"github.com/go-test/deep"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/runtime"
"github.com/helmfile/helmfile/pkg/yaml"
)
func boolPtrToString(ptr *bool) string {
@@ -294,3 +297,210 @@ func TestHelmState_recursiveRefsTemplates(t *testing.T) {
})
}
}
func TestApplyDefaultInherit(t *testing.T) {
tests := []struct {
name string
defaultInherit DefaultInherits
releaseInherit Inherits
want Inherits
}{
{
name: "no default inherit",
defaultInherit: nil,
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "foo"}},
},
{
name: "default inherit prepended",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
{
name: "default inherit already in release inherit is not duplicated",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: "default"}, {Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
{
name: "multiple default inherits",
defaultInherit: DefaultInherits{"a", "b"},
releaseInherit: Inherits{{Template: "c"}},
want: Inherits{{Template: "a"}, {Template: "b"}, {Template: "c"}},
},
{
name: "release inherit empty with defaults",
defaultInherit: DefaultInherits{"default"},
releaseInherit: nil,
want: Inherits{{Template: "default"}},
},
{
name: "default inherit deduplicates and skips empty values",
defaultInherit: DefaultInherits{"default", " ", "default", "ops"},
releaseInherit: Inherits{{Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "ops"}, {Template: "foo"}},
},
{
// Whitespace-only template names in releaseInherit are used verbatim for dedup
// (trimmed for map lookup), so the user's explicit entry is preserved in the output
// and the default is not prepended again.
name: "release inherit with whitespace template is deduplicated correctly",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: " default "}, {Template: "foo"}},
want: Inherits{{Template: " default "}, {Template: "foo"}},
},
{
name: "release inherit with blank template is skipped",
defaultInherit: DefaultInherits{"default"},
releaseInherit: Inherits{{Template: ""}, {Template: "foo"}},
want: Inherits{{Template: "default"}, {Template: "foo"}},
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
st := &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
DefaultInherit: tt.defaultInherit,
},
}
got := st.applyDefaultInherit(tt.releaseInherit)
if len(got) != len(tt.want) {
t.Fatalf("expected %d inherits, got %d", len(tt.want), len(got))
}
for j := range got {
if got[j].Template != tt.want[j].Template {
t.Errorf("inherit[%d]: expected template %q, got %q", j, tt.want[j].Template, got[j].Template)
}
if len(got[j].Except) != len(tt.want[j].Except) {
t.Errorf("inherit[%d]: expected %d except, got %d", j, len(tt.want[j].Except), len(got[j].Except))
}
}
})
}
}
func TestHelmState_executeTemplatesWithDefaultTemplates(t *testing.T) {
logger := zap.NewNop().Sugar()
state := &HelmState{
logger: logger,
fs: &filesystem.FileSystem{
Glob: func(s string) ([]string, error) { return nil, nil },
},
basePath: ".",
ReleaseSetSpec: ReleaseSetSpec{
HelmDefaults: HelmSpec{
KubeContext: "test_context",
},
Env: environment.Environment{Name: "test_env"},
Templates: map[string]TemplateSpec{
"default": {
ReleaseSpec: ReleaseSpec{
Namespace: "default-ns",
Labels: map[string]string{"managed": "true"},
},
},
},
DefaultInherit: DefaultInherits{"default"},
Releases: []ReleaseSpec{
{
Name: "app1",
Chart: "test-chart",
},
{
Name: "app2",
Chart: "test-chart-2",
Inherit: Inherits{
{Template: "default", Except: []string{"labels"}},
},
},
},
},
RenderedValues: map[string]any{},
}
r, err := state.ExecuteTemplates()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
app1 := r.Releases[0]
if app1.Namespace != "default-ns" {
t.Errorf("app1: expected namespace %q, got %q", "default-ns", app1.Namespace)
}
if app1.Labels["managed"] != "true" {
t.Errorf("app1: expected label managed=true, got %v", app1.Labels)
}
app2 := r.Releases[1]
if app2.Namespace != "default-ns" {
t.Errorf("app2: expected namespace %q, got %q", "default-ns", app2.Namespace)
}
if _, ok := app2.Labels["managed"]; ok {
t.Errorf("app2: expected labels to be excluded, but got %v", app2.Labels)
}
}
func TestDefaultInherits_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
input string
want DefaultInherits
}{
{
name: "single string",
input: `default`,
want: DefaultInherits{"default"},
},
{
name: "list of strings",
input: `["a", "b"]`,
want: DefaultInherits{"a", "b"},
},
{
name: "null value",
input: `null`,
want: nil,
},
{
name: "empty string value",
input: `""`,
want: nil,
},
{
name: "list trims and drops empty names",
input: `[" a ", "", " ", "b"]`,
want: DefaultInherits{"a", "b"},
},
}
for _, enableGoYamlV3 := range []bool{true, false} {
t.Run(fmt.Sprintf("GoYamlV3=%t", enableGoYamlV3), func(t *testing.T) {
prev := runtime.GoYamlV3
runtime.GoYamlV3 = enableGoYamlV3
defer func() {
runtime.GoYamlV3 = prev
}()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got DefaultInherits
err := yaml.Unmarshal([]byte(tt.input), &got)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("expected %d items, got %d", len(tt.want), len(got))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("item[%d]: expected %q, got %q", i, tt.want[i], got[i])
}
}
})
}
})
}
}
+396
View File
@@ -908,6 +908,191 @@ func TestHelmState_flagsForUpgrade(t *testing.T) {
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--arg1", "--arg2"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--arg1",
"--post-renderer-args=--arg2",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-release",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-release-prior-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--default-arg"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-cli-overrides-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--default-arg"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
syncOpts: &SyncOpts{
PostRendererArgs: []string{"--cli-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--cli-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-release-overrides-cli",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
syncOpts: &SyncOpts{
PostRendererArgs: []string{"--cli-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-short-flag-value",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"-v"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=-v",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-helmdefault-templated-with-release-name",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"{{ .Release.Name }}", "--chart={{ .Release.Chart }}"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "my-release",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
want: []string{
"--version", "0.1",
"--post-renderer-args=my-release",
"--post-renderer-args=--chart=test/chart",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-helmdefault-templated-with-namespace",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"{{ .Release.Namespace }}/{{ .Release.Name }}"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "my-release",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
want: []string{
"--version", "0.1",
"--post-renderer-args=test-namespace/my-release",
"--namespace", "test-namespace",
},
},
{
name: "description-from-release",
defaults: HelmSpec{
@@ -1204,6 +1389,146 @@ func TestHelmState_flagsForTemplate(t *testing.T) {
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--arg1", "--arg2"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--arg1",
"--post-renderer-args=--arg2",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-release",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-flags-use-release-prior-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--default-arg"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-cli-overrides-helmdefault",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
PostRendererArgs: []string{"--default-arg"},
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
},
templateOpts: TemplateOpts{
PostRendererArgs: []string{"--cli-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--cli-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-release-overrides-cli",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"--release-arg"},
},
templateOpts: TemplateOpts{
PostRendererArgs: []string{"--cli-arg"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=--release-arg",
"--namespace", "test-namespace",
},
},
{
name: "post-renderer-args-short-flag-value",
defaults: HelmSpec{
Verify: false,
CreateNamespace: &enable,
},
version: semver.MustParse("3.10.0"),
release: &ReleaseSpec{
Chart: "test/chart",
Version: "0.1",
Verify: &disable,
Name: "test-charts",
Namespace: "test-namespace",
CreateNamespace: &disable,
PostRendererArgs: []string{"-v"},
},
want: []string{
"--version", "0.1",
"--post-renderer-args=-v",
"--namespace", "test-namespace",
},
},
{
name: "kube-version-flag-should-be-used",
defaults: HelmSpec{
@@ -5759,3 +6084,74 @@ func TestHelmState_getKubeContext(t *testing.T) {
})
}
}
// resolveOCIAdhocDepChart should rewrite a release `dependencies[].chart` value
// that uses the named-repo prefix form into a full oci:// URL whenever the
// matching `repositories:` entry has `oci: true`. All other inputs must pass
// through unchanged so we never disturb existing behavior.
func TestResolveOCIAdhocDepChart(t *testing.T) {
state := &HelmState{
ReleaseSetSpec: ReleaseSetSpec{
Repositories: []RepositorySpec{
{Name: "ociregistry", URL: "registry.example.com:5000/charts", OCI: true},
{Name: "ociregistry-trailing", URL: "registry.example.com:5000/charts/", OCI: true},
{Name: "stable", URL: "https://charts.helm.sh/stable"},
},
},
}
tests := []struct {
name string
chart string
wantOK bool
wantChart string
}{
{
name: "named OCI repo prefix is rewritten to oci:// URL",
chart: "ociregistry/redis",
wantOK: true,
wantChart: "oci://registry.example.com:5000/charts/redis",
},
{
name: "trailing slash on repo URL does not produce a double slash",
chart: "ociregistry-trailing/redis",
wantOK: true,
wantChart: "oci://registry.example.com:5000/charts/redis",
},
{
name: "non-OCI repo prefix is left alone for chartify's helm-repo-list path",
chart: "stable/nginx",
wantOK: false,
},
{
name: "explicit oci:// URL is left alone (already in chartify's OCI branch)",
chart: "oci://registry.example.com:5000/charts/redis",
wantOK: false,
},
{
name: "unknown repo prefix is left alone",
chart: "unknownrepo/something",
wantOK: false,
},
{
name: "single-segment chart (no slash) is left alone",
chart: "localchart",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := state.resolveOCIAdhocDepChart(tt.chart)
if ok != tt.wantOK {
t.Errorf("ok: want %v, got %v", tt.wantOK, ok)
}
if tt.wantOK && got != tt.wantChart {
t.Errorf("rewritten chart: want %q, got %q", tt.wantChart, got)
}
if !tt.wantOK && got != "" {
t.Errorf("expected empty rewrite when ok=false, got %q", got)
}
})
}
}
+6 -6
View File
@@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) {
run(testcase{
subject: "baseline",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
want: "foo-values-6ccb848dcd",
want: "foo-values-7f6f8d74dd",
})
run(testcase{
subject: "different bytes content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: []byte(`{"k":"v"}`),
want: "foo-values-5bcbbc4c85",
want: "foo-values-5fc74c864c",
})
run(testcase{
subject: "different map content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: map[string]any{"k": "v"},
want: "foo-values-7c6468f955",
want: "foo-values-77df88dd65",
})
run(testcase{
subject: "different chart",
release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"},
want: "foo-values-8645f5847f",
want: "foo-values-77c96457f7",
})
run(testcase{
subject: "different name",
release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"},
want: "bar-values-54bd8c865",
want: "bar-values-6695f7ff4c",
})
run(testcase{
subject: "specific ns",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"},
want: "myns-foo-values-b4849b445",
want: "myns-foo-values-9b9484d4c",
})
for id, n := range ids {
+4
View File
@@ -0,0 +1,4 @@
letter: a
only_a: from-a
both_ab: from-a
all_three: from-a
+4
View File
@@ -0,0 +1,4 @@
only_b: from-b
both_ab: from-b
both_bc: from-b
all_three: from-b
+3
View File
@@ -0,0 +1,3 @@
only_c: from-c
both_bc: from-c
all_three: from-c
+2
View File
@@ -0,0 +1,2 @@
cluster:
domain: example.com
+5
View File
@@ -0,0 +1,5 @@
cluster:
domain: cluster.local
region: us-east-1
service:
port: 8080
+3
View File
@@ -0,0 +1,3 @@
service:
domain: "service.{{ .Values.cluster.domain }}"
port: 8080
+2
View File
@@ -0,0 +1,2 @@
cluster:
domain: example.com
+2
View File
@@ -0,0 +1,2 @@
service:
domain: "service.{{ .Values.cluster.domain }}"
+1
View File
@@ -0,0 +1 @@
value: ~
+1
View File
@@ -0,0 +1 @@
value: from-fallback
+4
View File
@@ -0,0 +1,4 @@
enabled: false
replicas: 0
name: ""
tags: []
+6
View File
@@ -0,0 +1,6 @@
enabled: true
replicas: 3
name: from-fallback
tags:
- a
- b
+3 -4
View File
@@ -2,6 +2,7 @@ package state
import (
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/maputil"
)
// TemplateSpec defines the structure of a reusable and composable template for helm releases.
@@ -21,11 +22,9 @@ type EnvironmentTemplateData struct {
}
func NewEnvironmentTemplateData(env environment.Environment, namespace string, values map[string]any) *EnvironmentTemplateData {
// Create a copy of the environment with merged values for template access.
// This ensures templates accessing .Environment.Values see the same merged values
// (Defaults + Values + CLIOverrides) as templates accessing .Values directly.
envCopy := env
envCopy.Values = values
envCopy.Values = maputil.MergeMaps(env.Values, env.CLIOverrides,
maputil.MergeOptions{ArrayStrategy: maputil.ArrayMergeStrategyMerge})
d := EnvironmentTemplateData{envCopy, namespace, values, nil}
d.StateValues = &d.Values
return &d
+52
View File
@@ -0,0 +1,52 @@
package state
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/environment"
)
func TestNewEnvironmentTemplateData_EnvironmentValuesExcludesDefaults_Issue2527(t *testing.T) {
env := environment.Environment{
Name: "myenv",
Defaults: map[string]any{
"helmDefaults": map[string]any{
"atomic": true,
"wait": true,
"timeout": 300,
},
},
Values: map[string]any{
"appName": "my-app",
},
CLIOverrides: map[string]any{
"cliFlag": "cliValue",
},
}
mergedVals := map[string]any{
"appName": "my-app",
"cliFlag": "cliValue",
"helmDefaults": map[string]any{
"atomic": true,
"wait": true,
"timeout": 300,
},
}
tmplData := NewEnvironmentTemplateData(env, "ns", mergedVals)
require.NotNil(t, tmplData)
assert.Equal(t, mergedVals, tmplData.Values, ".Values should contain full merged values")
_, hasDefaults := tmplData.Environment.Values["helmDefaults"]
assert.False(t, hasDefaults, ".Environment.Values should NOT contain Defaults (helmDefaults)")
assert.Equal(t, "my-app", tmplData.Environment.Values["appName"],
".Environment.Values should contain env Values")
assert.Equal(t, "cliValue", tmplData.Environment.Values["cliFlag"],
".Environment.Values should contain CLI overrides")
}