Skip to content

Commit 693b702

Browse files
authored
Edit Helm values during release upgrades (#1139)
1 parent 44c08c1 commit 693b702

11 files changed

Lines changed: 668 additions & 91 deletions

File tree

internal/helm/client.go

Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/skyhook-io/radar/pkg/helmhistory"
2727

2828
"helm.sh/helm/v3/pkg/action"
29+
"helm.sh/helm/v3/pkg/chart"
2930
"helm.sh/helm/v3/pkg/chart/loader"
3031
"helm.sh/helm/v3/pkg/cli"
3132
"helm.sh/helm/v3/pkg/registry"
@@ -2315,6 +2316,32 @@ func (c *Client) UpgradeAsUser(namespace, name, targetVersion, repositoryName st
23152316
return c.upgradeWith(actionConfig, name, targetVersion, repositoryName, noop)
23162317
}
23172318

2319+
// UpgradeWithValuesProgress upgrades a release to a target version applying the
2320+
// supplied user values (WYSIWYG), reporting progress via a channel.
2321+
func (c *Client) UpgradeWithValuesProgress(namespace, name, targetVersion, repositoryName string, newValues map[string]any, progressCh chan<- InstallProgress) error {
2322+
sendProgress := progressSender(progressCh)
2323+
sendProgress("preparing", fmt.Sprintf("Getting current release %s...", name), "")
2324+
2325+
actionConfig, err := c.getActionConfig(namespace)
2326+
if err != nil {
2327+
return err
2328+
}
2329+
return c.upgradeWithValues(actionConfig, name, targetVersion, repositoryName, newValues, sendProgress)
2330+
}
2331+
2332+
// UpgradeWithValuesProgressAsUser upgrades a release to a target version applying
2333+
// the supplied user values with K8s impersonation and progress reporting.
2334+
func (c *Client) UpgradeWithValuesProgressAsUser(namespace, name, targetVersion, repositoryName string, newValues map[string]any, username string, groups []string, progressCh chan<- InstallProgress) error {
2335+
sendProgress := progressSender(progressCh)
2336+
sendProgress("preparing", fmt.Sprintf("Getting current release %s...", name), "")
2337+
2338+
actionConfig, err := c.getActionConfigForUser(namespace, username, groups)
2339+
if err != nil {
2340+
return err
2341+
}
2342+
return c.upgradeWithValues(actionConfig, name, targetVersion, repositoryName, newValues, sendProgress)
2343+
}
2344+
23182345
func progressSender(progressCh chan<- InstallProgress) func(phase, message, detail string) {
23192346
return func(phase, message, detail string) {
23202347
if progressCh == nil {
@@ -2335,16 +2362,11 @@ func (c *Client) upgradeWith(actionConfig *action.Configuration, name, targetVer
23352362
return fmt.Errorf("failed to get current release: %w", err)
23362363
}
23372364

2338-
chartName := rel.Chart.Metadata.Name
2339-
sendProgress("resolving", fmt.Sprintf("Finding %s version %s in repositories...", chartName, targetVersion), "")
2340-
2341-
chartPath, resolvedRepo, err := c.resolveUpgradeChartPath(chartName, targetVersion, repositoryName, chartSourceHosts(rel.Chart.Metadata.Home, rel.Chart.Metadata.Sources))
2365+
targetChart, err := c.chartForUpgradeTarget(actionConfig, rel, targetVersion, repositoryName, sendProgress)
23422366
if err != nil {
23432367
return err
23442368
}
23452369

2346-
sendProgress("downloading", fmt.Sprintf("Downloading %s-%s from %s...", chartName, targetVersion, resolvedRepo), chartPath)
2347-
23482370
// Create upgrade action — don't use Wait=true because Radar already
23492371
// shows real-time resource status via SSE. Waiting blocks the dialog
23502372
// for minutes with zero feedback; users can monitor the rollout in the UI.
@@ -2357,49 +2379,105 @@ func (c *Client) upgradeWith(actionConfig *action.Configuration, name, targetVer
23572379
// for keys a newer chart added (a cross-version upgrade footgun).
23582380
upgradeAction.ResetThenReuseValues = true
23592381

2382+
sendProgress("upgrading", fmt.Sprintf("Applying %s %s...", rel.Chart.Metadata.Name, targetVersion), "")
2383+
2384+
// Run the upgrade
2385+
_, err = upgradeAction.Run(name, targetChart, rel.Config)
2386+
if err != nil {
2387+
return fmt.Errorf("upgrade failed: %w", err)
2388+
}
2389+
2390+
sendProgress("complete", fmt.Sprintf("Successfully upgraded %s to %s", name, targetVersion), "")
2391+
return nil
2392+
}
2393+
2394+
// upgradeWithValues upgrades a release to a target chart version applying exactly
2395+
// the supplied user values (WYSIWYG — ResetValues, no merge with prior overrides).
2396+
// The plain upgradeWith path keeps ResetThenReuseValues for the blind carry-over case.
2397+
func (c *Client) upgradeWithValues(actionConfig *action.Configuration, name, targetVersion, repositoryName string, newValues map[string]any, sendProgress func(phase, message, detail string)) error {
2398+
getAction := action.NewGet(actionConfig)
2399+
rel, err := getAction.Run(name)
2400+
if err != nil {
2401+
return fmt.Errorf("failed to get current release: %w", err)
2402+
}
2403+
2404+
targetChart, err := c.chartForUpgradeTarget(actionConfig, rel, targetVersion, repositoryName, sendProgress)
2405+
if err != nil {
2406+
return err
2407+
}
2408+
2409+
upgradeAction := action.NewUpgrade(actionConfig)
2410+
upgradeAction.Namespace = rel.Namespace
2411+
upgradeAction.Timeout = 120 * time.Second
2412+
upgradeAction.ResetValues = true // WYSIWYG: apply only the edited values
2413+
2414+
sendProgress("upgrading", fmt.Sprintf("Applying %s %s...", rel.Chart.Metadata.Name, targetVersion), "")
2415+
2416+
_, err = upgradeAction.Run(name, targetChart, newValues)
2417+
if err != nil {
2418+
return fmt.Errorf("upgrade failed: %w", err)
2419+
}
2420+
2421+
sendProgress("complete", fmt.Sprintf("Successfully upgraded %s to %s", name, targetVersion), "")
2422+
return nil
2423+
}
2424+
2425+
func (c *Client) chartForUpgradeTarget(actionConfig *action.Configuration, rel *release.Release, targetVersion, repositoryName string, sendProgress func(phase, message, detail string)) (*chart.Chart, error) {
2426+
if targetVersion == "" || targetVersion == rel.Chart.Metadata.Version {
2427+
return rel.Chart, nil
2428+
}
2429+
return c.loadTargetChart(actionConfig, rel, targetVersion, repositoryName, sendProgress)
2430+
}
2431+
2432+
// loadTargetChart resolves, downloads and loads the chart for a target upgrade
2433+
// version, refusing a silent chart-swap (the resolved source must publish the
2434+
// same chart the release runs). Shared by the upgrade and preview paths so they
2435+
// resolve the target version identically.
2436+
func (c *Client) loadTargetChart(actionConfig *action.Configuration, rel *release.Release, targetVersion, repositoryName string, sendProgress func(phase, message, detail string)) (*chart.Chart, error) {
2437+
chartName := rel.Chart.Metadata.Name
2438+
sendProgress("resolving", fmt.Sprintf("Finding %s version %s in repositories...", chartName, targetVersion), "")
2439+
2440+
chartPath, resolvedRepo, err := c.resolveUpgradeChartPath(chartName, targetVersion, repositoryName, chartSourceHosts(rel.Chart.Metadata.Home, rel.Chart.Metadata.Sources))
2441+
if err != nil {
2442+
return nil, err
2443+
}
2444+
2445+
sendProgress("downloading", fmt.Sprintf("Downloading %s-%s from %s...", chartName, targetVersion, resolvedRepo), chartPath)
2446+
23602447
// Use ChartPathOptions to locate/download the chart
2361-
client := action.NewInstall(actionConfig)
2362-
client.Version = targetVersion
2448+
locate := action.NewInstall(actionConfig)
2449+
locate.Version = targetVersion
23632450

23642451
// OCI pulls need a registry client on the action; Radar's action config
23652452
// doesn't carry one by default. Wire it from the user's helm registry login.
23662453
if registry.IsOCI(chartPath) {
23672454
rc, err := c.newRegistryClientConcrete()
23682455
if err != nil {
2369-
return fmt.Errorf("failed to build OCI registry client: %w", err)
2456+
return nil, fmt.Errorf("failed to build OCI registry client: %w", err)
23702457
}
2371-
client.SetRegistryClient(rc)
2458+
locate.SetRegistryClient(rc)
23722459
}
23732460

2374-
cp, err := client.ChartPathOptions.LocateChart(chartPath, c.settings)
2461+
cp, err := locate.ChartPathOptions.LocateChart(chartPath, c.settings)
23752462
if err != nil {
2376-
return fmt.Errorf("failed to locate chart: %w", err)
2463+
return nil, fmt.Errorf("failed to locate chart: %w", err)
23772464
}
23782465

23792466
sendProgress("loading", "Loading chart...", cp)
23802467

2381-
chart, err := loader.Load(cp)
2468+
targetChart, err := loader.Load(cp)
23822469
if err != nil {
2383-
return fmt.Errorf("failed to load chart: %w", err)
2470+
return nil, fmt.Errorf("failed to load chart: %w", err)
23842471
}
23852472

23862473
// Refuse a silent chart-swap: the resolved source must publish the SAME chart
23872474
// the release runs, not merely a chart at the same version. Matters most for
23882475
// OCI prefix probing, where "<prefix>/<chartName>" is derived, not asserted.
2389-
if chart.Metadata != nil && chart.Metadata.Name != chartName {
2390-
return fmt.Errorf("resolved chart is %q but release %q runs chart %q — refusing to swap charts", chart.Metadata.Name, name, chartName)
2391-
}
2392-
2393-
sendProgress("upgrading", fmt.Sprintf("Applying %s %s...", chartName, targetVersion), "")
2394-
2395-
// Run the upgrade
2396-
_, err = upgradeAction.Run(name, chart, rel.Config)
2397-
if err != nil {
2398-
return fmt.Errorf("upgrade failed: %w", err)
2476+
if targetChart.Metadata != nil && targetChart.Metadata.Name != chartName {
2477+
return nil, fmt.Errorf("resolved chart is %q but release %q runs chart %q — refusing to swap charts", targetChart.Metadata.Name, rel.Name, chartName)
23992478
}
24002479

2401-
sendProgress("complete", fmt.Sprintf("Successfully upgraded %s to %s", name, targetVersion), "")
2402-
return nil
2480+
return targetChart, nil
24032481
}
24042482

24052483
type chartPathCandidate struct {
@@ -2445,7 +2523,7 @@ func (c *Client) resolveUpgradeChartPathWithOCIResolver(chartName, targetVersion
24452523
continue
24462524
}
24472525
path := entry.URLs[0]
2448-
if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
2526+
if !isAbsoluteChartURL(path) {
24492527
path = strings.TrimSuffix(r.URL, "/") + "/" + path
24502528
}
24512529
candidates = append(candidates, chartPathCandidate{repoName: r.Name, repoURL: r.URL, chartPath: path})
@@ -2492,6 +2570,10 @@ func (c *Client) resolveUpgradeChartPathWithOCIResolver(chartName, targetVersion
24922570
return "", "", fmt.Errorf("chart %s version %s not found in configured repositories or registered OCI sources", chartName, targetVersion)
24932571
}
24942572

2573+
func isAbsoluteChartURL(path string) bool {
2574+
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || registry.IsOCI(path)
2575+
}
2576+
24952577
// BatchCheckUpgrades checks for upgrades for all releases at once (more efficient)
24962578
func (c *Client) BatchCheckUpgrades(namespace string) (*BatchUpgradeInfo, error) {
24972579
return c.batchCheckUpgrades(namespace, "", nil)
@@ -2689,13 +2771,26 @@ func (c *Client) batchCheckUpgrades(namespace, username string, groups []string)
26892771
return result, nil
26902772
}
26912773

2692-
// PreviewValuesChange previews the effect of new values on a release via dry-run
2693-
func (c *Client) PreviewValuesChange(namespace, name string, newValues map[string]any) (*ValuesPreviewResponse, error) {
2774+
// PreviewValuesChange previews the effect of new values on a release via dry-run.
2775+
// When targetVersion is non-empty and differs from the running chart version, the
2776+
// dry-run renders against that target chart instead of the current chart.
2777+
func (c *Client) PreviewValuesChange(namespace, name string, newValues map[string]any, targetVersion, repositoryName string) (*ValuesPreviewResponse, error) {
26942778
actionConfig, err := c.getActionConfig(namespace)
26952779
if err != nil {
26962780
return nil, err
26972781
}
2782+
return c.previewValuesChangeWith(actionConfig, name, newValues, targetVersion, repositoryName)
2783+
}
2784+
2785+
func (c *Client) PreviewValuesChangeAsUser(namespace, name string, newValues map[string]any, targetVersion, repositoryName string, username string, groups []string) (*ValuesPreviewResponse, error) {
2786+
actionConfig, err := c.getActionConfigForUser(namespace, username, groups)
2787+
if err != nil {
2788+
return nil, err
2789+
}
2790+
return c.previewValuesChangeWith(actionConfig, name, newValues, targetVersion, repositoryName)
2791+
}
26982792

2793+
func (c *Client) previewValuesChangeWith(actionConfig *action.Configuration, name string, newValues map[string]any, targetVersion, repositoryName string) (*ValuesPreviewResponse, error) {
26992794
// Get the current release
27002795
getAction := action.NewGet(actionConfig)
27012796
rel, err := getAction.Run(name)
@@ -2713,6 +2808,12 @@ func (c *Client) PreviewValuesChange(namespace, name string, newValues map[strin
27132808
// Get current manifest
27142809
currentManifest := rel.Manifest
27152810

2811+
noop := func(phase, message, detail string) {}
2812+
previewChart, err := c.chartForUpgradeTarget(actionConfig, rel, targetVersion, repositoryName, noop)
2813+
if err != nil {
2814+
return nil, err
2815+
}
2816+
27162817
// Perform a dry-run upgrade with the new values
27172818
upgradeAction := action.NewUpgrade(actionConfig)
27182819
upgradeAction.Namespace = rel.Namespace
@@ -2721,7 +2822,7 @@ func (c *Client) PreviewValuesChange(namespace, name string, newValues map[strin
27212822
upgradeAction.ResetValues = true // Use only the provided values, don't merge
27222823

27232824
// Run the dry-run upgrade
2724-
newRel, err := upgradeAction.Run(name, rel.Chart, newValues)
2825+
newRel, err := upgradeAction.Run(name, previewChart, newValues)
27252826
if err != nil {
27262827
return nil, fmt.Errorf("failed to preview values change: %w", err)
27272828
}

internal/helm/client_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,22 @@ func TestGetValuesWithAllValuesKeepsComputedWhenUserValuesReadFails(t *testing.T
637637
}
638638
}
639639

640+
func TestChartForUpgradeTargetReusesReleaseChartForSameVersion(t *testing.T) {
641+
client := testHelmClientWithRepoConfigOnly(t)
642+
rel := helmTestRelease("argo-cd", "demo", 1, release.StatusDeployed, "deployed")
643+
rel.Chart.Metadata.Version = "9.5.11"
644+
645+
got, err := client.chartForUpgradeTarget(nil, rel, "9.5.11", "missing-repo", func(phase, message, detail string) {
646+
t.Fatalf("same-version chart selection should not resolve/download chart, got progress %q %q %q", phase, message, detail)
647+
})
648+
if err != nil {
649+
t.Fatal(err)
650+
}
651+
if got != rel.Chart {
652+
t.Fatal("chartForUpgradeTarget returned a different chart, want current release chart")
653+
}
654+
}
655+
640656
func TestDiffResourceRefs(t *testing.T) {
641657
left := []ResourceRef{
642658
{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "demo", Name: "cart"},
@@ -1437,6 +1453,49 @@ func TestResolveUpgradeChartPath_UsesRepositoryHint(t *testing.T) {
14371453
}
14381454
}
14391455

1456+
func TestResolveUpgradeChartPath_RepositoryIndexOCIURLIsAbsolute(t *testing.T) {
1457+
dir := t.TempDir()
1458+
cacheDir := filepath.Join(dir, "cache")
1459+
if err := os.Mkdir(cacheDir, 0o755); err != nil {
1460+
t.Fatal(err)
1461+
}
1462+
repoFile := filepath.Join(dir, "repositories.yaml")
1463+
if err := os.WriteFile(repoFile, []byte(`apiVersion: v1
1464+
generated: "2026-05-05T00:00:00Z"
1465+
repositories:
1466+
- name: bitnami
1467+
url: https://charts.bitnami.com/bitnami
1468+
`), 0o644); err != nil {
1469+
t.Fatal(err)
1470+
}
1471+
if err := os.WriteFile(filepath.Join(cacheDir, "bitnami-index.yaml"), []byte(`apiVersion: v1
1472+
entries:
1473+
nginx:
1474+
- name: nginx
1475+
version: 25.0.5
1476+
urls:
1477+
- oci://registry-1.docker.io/bitnamicharts/nginx
1478+
generated: "2026-05-05T00:00:00Z"
1479+
`), 0o644); err != nil {
1480+
t.Fatal(err)
1481+
}
1482+
client := &Client{settings: &cli.EnvSettings{
1483+
RepositoryConfig: repoFile,
1484+
RepositoryCache: cacheDir,
1485+
}}
1486+
1487+
chartPath, repoName, err := client.resolveUpgradeChartPath("nginx", "25.0.5", "bitnami", nil)
1488+
if err != nil {
1489+
t.Fatal(err)
1490+
}
1491+
if repoName != "bitnami" {
1492+
t.Fatalf("repo = %q, want bitnami", repoName)
1493+
}
1494+
if chartPath != "oci://registry-1.docker.io/bitnamicharts/nginx" {
1495+
t.Fatalf("chart path = %q, want OCI URL unchanged", chartPath)
1496+
}
1497+
}
1498+
14401499
func TestResolveUpgradeChartPath_AmbiguousWithoutHintOrAffinity(t *testing.T) {
14411500
client := testHelmClientWithRepos(t)
14421501

0 commit comments

Comments
 (0)