pull/31554/merge
ⁱⁿ 4 days ago committed by GitHub
commit b4644150a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,369 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"fmt"
"strconv"
"strings"
"helm.sh/helm/v4/pkg/chart/common/util"
release "helm.sh/helm/v4/pkg/release"
"helm.sh/helm/v4/pkg/release/common"
rspb "helm.sh/helm/v4/pkg/release/v1"
)
// MergeValues is the action for intelligently merging values from multiple release revisions.
//
// It provides the implementation of 'helm values merge'.
type MergeValues struct {
cfg *Configuration
// Revisions specifies the revisions to merge. If empty, all revisions will be used.
Revisions []int
// AllRevisions indicates whether to include all revisions of the release.
AllRevisions bool
// OutputFormat specifies the output format (yaml, json).
OutputFormat string
// MergeStrategy defines how to handle conflicts between revisions.
MergeStrategy string
}
// NewMergeValues creates a new MergeValues object with the given configuration.
func NewMergeValues(cfg *Configuration) *MergeValues {
return &MergeValues{
cfg: cfg,
OutputFormat: "yaml",
MergeStrategy: "latest", // default strategy: use latest value for conflicts
}
}
// Run executes 'helm values merge' against the given release.
func (m *MergeValues) Run(name string) (map[string]interface{}, error) {
if err := m.cfg.KubeClient.IsReachable(); err != nil {
return nil, err
}
// Get the release history to determine which revisions to merge
history, err := m.cfg.Releases.History(name)
if err != nil {
return nil, fmt.Errorf("could not get history for release %q: %w", name, err)
}
if len(history) == 0 {
return nil, fmt.Errorf("release %q not found", name)
}
// Determine which revisions to merge
revisionsToMerge, err := m.getRevisionsToMerge(history)
if err != nil {
return nil, err
}
if len(revisionsToMerge) == 0 {
return nil, fmt.Errorf("no revisions found to merge for release %q", name)
}
// Collect values from specified revisions
var allValues []map[string]interface{}
var revisionInfo []string
for _, rev := range revisionsToMerge {
rel, err := m.cfg.Releases.Get(name, rev)
if err != nil {
return nil, fmt.Errorf("could not get release %q revision %d: %w", name, rev, err)
}
r, err := releaserToV1Release(rel)
if err != nil {
return nil, err
}
var _ *rspb.Release = r // Explicitly use the type
// Get computed values (merged with chart defaults)
computedVals, err := util.CoalesceValues(r.Chart, r.Config)
if err != nil {
return nil, fmt.Errorf("could not coalesce values for revision %d: %w", rev, err)
}
allValues = append(allValues, computedVals)
revisionInfo = append(revisionInfo, fmt.Sprintf("revision:%d,version:%s,updated:%s",
rev, r.Chart.Metadata.Version, r.Info.LastDeployed.Format("2006-01-02T15:04:05Z")))
}
// Merge all values using the specified strategy
mergedValues, err := m.mergeValues(allValues)
if err != nil {
return nil, fmt.Errorf("could not merge values: %w", err)
}
// Add metadata about the merge operation if not empty
if len(mergedValues) > 0 {
if mergedValues["helm"] == nil {
mergedValues["helm"] = map[string]interface{}{}
}
helmMeta := mergedValues["helm"].(map[string]interface{})
helmMeta["mergeMetadata"] = map[string]interface{}{
"releaseName": name,
"revisions": revisionsToMerge,
"revisionInfo": revisionInfo,
"mergeStrategy": m.MergeStrategy,
}
}
return mergedValues, nil
}
// getRevisionsToMerge determines which revisions to merge based on configuration
func (m *MergeValues) getRevisionsToMerge(history []release.Releaser) ([]int, error) {
var revisions []int
if m.AllRevisions {
// Include all revisions
for _, rel := range history {
r, err := releaserToV1Release(rel)
if err != nil {
return nil, err
}
revisions = append(revisions, r.Version)
}
} else if len(m.Revisions) > 0 {
// Use specified revisions
revisions = m.Revisions
} else {
// Default: use all deployed revisions
for _, rel := range history {
r, err := releaserToV1Release(rel)
if err != nil {
return nil, err
}
if r.Info.Status == common.StatusDeployed {
revisions = append(revisions, r.Version)
}
}
}
// Validate that revisions exist
availableVersions := make(map[int]bool)
for _, rel := range history {
r, err := releaserToV1Release(rel)
if err != nil {
return nil, err
}
var _ *rspb.Release = r // Explicitly use the type
availableVersions[r.Version] = true
}
var validRevisions []int
for _, rev := range revisions {
if !availableVersions[rev] {
return nil, fmt.Errorf("revision %d does not exist for release", rev)
}
validRevisions = append(validRevisions, rev)
}
return validRevisions, nil
}
// mergeValues merges multiple values maps using the specified strategy
func (m *MergeValues) mergeValues(valuesList []map[string]interface{}) (map[string]interface{}, error) {
if len(valuesList) == 0 {
return make(map[string]interface{}), nil
}
if len(valuesList) == 1 {
return valuesList[0], nil
}
// Start with an empty map
result := make(map[string]interface{})
switch m.MergeStrategy {
case "latest":
// Latest wins strategy: later values override earlier ones
for _, values := range valuesList {
result = util.CoalesceTables(result, values)
}
case "first":
// First wins strategy: earlier values take precedence
// We need to reverse the order and then use CoalesceTables
for i := len(valuesList) - 1; i >= 0; i-- {
result = util.CoalesceTables(result, valuesList[i])
}
case "merge":
// Deep merge strategy: attempt to intelligently merge arrays and objects
result = m.deepMergeValues(valuesList)
default:
return nil, fmt.Errorf("unknown merge strategy: %s", m.MergeStrategy)
}
return result, nil
}
// deepMergeValues performs a deep merge of values with intelligent conflict resolution
func (m *MergeValues) deepMergeValues(valuesList []map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Track which keys were set by which revision for debugging
keySources := make(map[string]int)
for i, values := range valuesList {
for key, value := range values {
if _, exists := result[key]; !exists {
// Key doesn't exist yet, add it
result[key] = m.deepCopyValue(value)
keySources[key] = i + 1 // revision numbers start from 1
} else {
// Key exists, merge the values
result[key] = m.mergeTwoValues(result[key], value)
// Update source to latest revision that modified this key
keySources[key] = i + 1
}
}
}
// Add merge metadata for debugging
if len(result) > 0 {
result["_mergeSources"] = keySources
}
return result
}
// mergeTwoValues merges two values intelligently
func (m *MergeValues) mergeTwoValues(existing, newValue interface{}) interface{} {
switch existing := existing.(type) {
case map[string]interface{}:
// If existing is a map, try to merge with new value
if newMap, ok := newValue.(map[string]interface{}); ok {
result := make(map[string]interface{})
// Copy existing values
for k, v := range existing {
result[k] = v
}
// Merge new values
for k, v := range newMap {
if existingV, exists := result[k]; exists {
result[k] = m.mergeTwoValues(existingV, v)
} else {
result[k] = v
}
}
return result
}
// Types don't match, new value wins
return newValue
case []interface{}:
// If existing is a slice, append new values if they're also slices
if newSlice, ok := newValue.([]interface{}); ok {
// Concatenate slices, avoiding duplicates
result := make([]interface{}, len(existing))
copy(result, existing)
for _, v := range newSlice {
if !m.sliceContains(result, v) {
result = append(result, v)
}
}
return result
}
// Types don't match, new value wins
return newValue
default:
// For primitive types, new value wins
return newValue
}
}
// sliceContains checks if a slice contains a specific value
func (m *MergeValues) sliceContains(slice []interface{}, value interface{}) bool {
for _, v := range slice {
if m.valuesEqual(v, value) {
return true
}
}
return false
}
// valuesEqual compares two values for equality
func (m *MergeValues) valuesEqual(a, b interface{}) bool {
// Simple equality check - could be enhanced for more complex types
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
// deepCopyValue creates a deep copy of a value
func (m *MergeValues) deepCopyValue(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{})
for key, val := range v {
result[key] = m.deepCopyValue(val)
}
return result
case []interface{}:
result := make([]interface{}, len(v))
for i, val := range v {
result[i] = m.deepCopyValue(val)
}
return result
default:
return v
}
}
// ParseRevisions parses a revision specification string into a slice of revision numbers
func ParseRevisions(revisionSpec string) ([]int, error) {
if revisionSpec == "" {
return nil, nil
}
var revisions []int
parts := strings.Split(revisionSpec, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.Contains(part, "..") {
// Handle range specification like "1..5"
rangeParts := strings.Split(part, "..")
if len(rangeParts) != 2 {
return nil, fmt.Errorf("invalid revision range: %s", part)
}
start, err := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
if err != nil {
return nil, fmt.Errorf("invalid start revision in range %s: %w", part, err)
}
end, err := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
if err != nil {
return nil, fmt.Errorf("invalid end revision in range %s: %w", part, err)
}
if start > end {
return nil, fmt.Errorf("start revision %d cannot be greater than end revision %d", start, end)
}
for i := start; i <= end; i++ {
revisions = append(revisions, i)
}
} else {
// Handle single revision
rev, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid revision number %s: %w", part, err)
}
revisions = append(revisions, rev)
}
}
return revisions, nil
}

@ -0,0 +1,81 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package action
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseRevisions(t *testing.T) {
tests := []struct {
input string
expected []int
expectErr bool
}{
{
input: "",
expected: nil,
},
{
input: "1",
expected: []int{1},
},
{
input: "1,3,5",
expected: []int{1, 3, 5},
},
{
input: "1..5",
expected: []int{1, 2, 3, 4, 5},
},
{
input: "1,3..5,7",
expected: []int{1, 3, 4, 5, 7},
},
{
input: "5..1",
expected: nil,
expectErr: true,
},
{
input: "invalid",
expected: nil,
expectErr: true,
},
{
input: "1..invalid",
expected: nil,
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result, err := ParseRevisions(tt.input)
if tt.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

@ -287,6 +287,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg
newTemplateCmd(actionConfig, out),
newUninstallCmd(actionConfig, out),
newUpgradeCmd(actionConfig, out),
newValuesCmd(actionConfig, out),
newCompletionCmd(out),
newEnvCmd(out),

@ -0,0 +1,40 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"io"
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/action"
)
// newValuesCmd creates the values command
func newValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "values",
Short: "manage release values",
Long: `This command consists of multiple subcommands to interact with release values.`,
}
cmd.AddCommand(
newValuesMergeCmd(cfg, out),
)
return cmd
}

@ -0,0 +1,176 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"fmt"
"io"
"log"
"strings"
"github.com/spf13/cobra"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli/output"
"helm.sh/helm/v4/pkg/cmd/require"
)
var valuesMergeHelp = `
This command intelligently merges values from multiple release revisions into a single
values file, helping to solve the "version hell" problem when migrating applications
that have undergone many releases, updates, and rollbacks.
It supports different merge strategies:
- latest: Later values override earlier ones (default)
- first: Earlier values take precedence
- merge: Deep merge with intelligent conflict resolution
Examples:
# Merge all deployed revisions of a release
helm values merge my-release
# Merge specific revisions
helm values merge my-release --revisions 1,3,5
# Merge a range of revisions
helm values merge my-release --revisions 1..5
# Use first-wins strategy instead of latest-wins
helm values merge my-release --strategy first
# Output in JSON format
helm values merge my-release --output json
`
type mergeValuesWriter struct {
vals map[string]interface{}
}
func newValuesMergeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
var outfmt output.Format
var revisions string
var strategy string
var allRevisions bool
client := action.NewMergeValues(cfg)
cmd := &cobra.Command{
Use: "merge RELEASE_NAME",
Short: "intelligently merge values from multiple release revisions",
Long: valuesMergeHelp,
Args: require.ExactArgs(1),
ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
RunE: func(_ *cobra.Command, args []string) error {
// Parse revisions if specified
if revisions != "" {
parsedRevisions, err := action.ParseRevisions(revisions)
if err != nil {
return fmt.Errorf("invalid revisions specification: %w", err)
}
client.Revisions = parsedRevisions
}
client.AllRevisions = allRevisions
client.MergeStrategy = strategy
client.OutputFormat = string(outfmt)
mergedVals, err := client.Run(args[0])
if err != nil {
return err
}
// Remove merge metadata if user doesn't want it
if helmMeta, ok := mergedVals["helm"].(map[string]interface{}); ok {
if mergeMeta, ok := helmMeta["mergeMetadata"].(map[string]interface{}); ok {
fmt.Fprintf(out, "# Merged values for release: %s\n", args[0])
if revisionsInfo, ok := mergeMeta["revisionInfo"].([]interface{}); ok {
fmt.Fprintf(out, "# Revisions merged: %d\n", len(revisionsInfo))
fmt.Fprintf(out, "# Merge strategy: %s\n", strategy)
for _, revInfo := range revisionsInfo {
fmt.Fprintf(out, "# %s\n", revInfo)
}
fmt.Fprintf(out, "\n")
}
// Remove metadata from output
delete(helmMeta, "mergeMetadata")
if len(helmMeta) == 0 {
delete(mergedVals, "helm")
}
}
}
// Remove internal merge sources if present
delete(mergedVals, "_mergeSources")
return outfmt.Write(out, &mergeValuesWriter{mergedVals})
},
}
f := cmd.Flags()
f.StringVar(&revisions, "revisions", "", "comma-separated list of revisions to merge (e.g., '1,3,5' or '1..5')")
f.StringVar(&strategy, "strategy", "latest", "merge strategy: latest, first, or merge (default: latest)")
f.BoolVar(&allRevisions, "all", false, "merge all revisions of the release")
bindOutputFlag(cmd, &outfmt)
// Register completion for the revisions flag
err := cmd.RegisterFlagCompletionFunc("revisions", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
return nil, cobra.ShellCompDirectiveNoFileComp
})
if err != nil {
log.Fatal(err)
}
// Register completion for the strategy flag
err = cmd.RegisterFlagCompletionFunc("strategy", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
strategies := []string{"latest\tLater values override earlier ones (default)",
"first\tEarlier values take precedence",
"merge\tDeep merge with intelligent conflict resolution"}
var completions []string
for _, s := range strategies {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
})
if err != nil {
log.Fatal(err)
}
return cmd
}
func (m mergeValuesWriter) WriteTable(out io.Writer) error {
fmt.Fprintln(out, "MERGED VALUES:")
return output.EncodeYAML(out, m.vals)
}
func (m mergeValuesWriter) WriteJSON(out io.Writer) error {
return output.EncodeJSON(out, m.vals)
}
func (m mergeValuesWriter) WriteYAML(out io.Writer) error {
return output.EncodeYAML(out, m.vals)
}

@ -0,0 +1,138 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"bytes"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/chart"
"helm.sh/helm/v4/pkg/release"
"helm.sh/helm/v4/pkg/storage"
"helm.sh/helm/v4/pkg/storage/driver"
)
func TestValuesMergeCmd(t *testing.T) {
tests := []struct {
name string
args []string
flags map[string]string
expectedErr bool
}{
{
name: "requires release name",
args: []string{},
expectedErr: true,
},
{
name: "valid merge command",
args: []string{"test-release"},
flags: map[string]string{
"revisions": "1,2",
},
expectedErr: false,
},
{
name: "invalid revisions",
args: []string{"test-release"},
flags: map[string]string{
"revisions": "invalid",
},
expectedErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test action configuration
store := storage.Init(driver.NewMemory())
actionConfig := action.NewConfiguration()
actionConfig.Releases = store
// Create test releases
release1 := createTestReleaseForCmd("test-release", 1)
release2 := createTestReleaseForCmd("test-release", 2)
// Add releases to storage
err := actionConfig.Releases.Create(release1)
assert.NoError(t, err)
err = actionConfig.Releases.Create(release2)
assert.NoError(t, err)
// Create command
buf := new(bytes.Buffer)
cmd := newValuesMergeCmd(actionConfig, buf)
// Set flags
for flag, value := range tt.flags {
cmd.Flags().Set(flag, value)
}
// Run command
err = cmd.RunE(cmd, tt.args)
if tt.expectedErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValuesMergeHelp(t *testing.T) {
store := storage.Init(driver.NewMemory())
actionConfig := action.NewConfiguration()
actionConfig.Releases = store
buf := new(bytes.Buffer)
cmd := newValuesMergeCmd(actionConfig, buf)
assert.Equal(t, "merge", cmd.Name())
assert.Equal(t, "intelligently merge values from multiple release revisions", cmd.Short)
assert.Contains(t, cmd.Long, "version hell")
assert.Contains(t, cmd.Long, "merge strategies")
}
func createTestReleaseForCmd(name string, version int) *release.Release {
chrt := &chart.Chart{
Metadata: &chart.Metadata{
Name: name,
Version: "1.0.0",
},
}
return &release.Release{
Name: name,
Version: version,
Config: map[string]interface{}{
"replicas": version,
"image": "nginx:1.0",
},
Chart: chrt,
Manifest: "# Manifest",
Info: &release.Info{
Status: release.StatusDeployed,
LastDeployed: "2024-01-01T00:00:00Z",
Description: "Release description",
},
}
}
Loading…
Cancel
Save