Skip to content

Commit d30f424

Browse files
hisconadaverell
authored andcommitted
feat(helm): edit values when upgrading a release
Add an optional "Adjust your values" step to the Helm release upgrade dialog. It is collapsed by default, so routine upgrades are unchanged (pick a version, confirm). When expanded, it pre-fills an editor with the release's current user-supplied values, lets the user edit them, preview a manifest-level dry-run diff against the target chart version, and upgrade with exactly those values. Backend: extract loadTargetChart from upgradeWith and share it across the upgrade and preview paths so both resolve the target version identically. PreviewValuesChange and the upgrade stream accept an optional target {version, repository}; the edit path applies values WYSIWYG (ResetValues) while the untouched no-edit path keeps ResetThenReuseValues. Frontend: reuse YamlEditor and ValuesDiffPreview (now portaled to body so it layers over the dialog instead of being trapped by the drawer's transformed ancestor). Invalid YAML disables the confirm action via a new ConfirmDialog confirmDisabled prop, derived from the same parser used at submit. Flux-managed releases never reach this dialog and are unaffected. Closes #1113 Claude-Session: https://claude.ai/code/session_01SpbCXPyqThe723pBDTJDnn
1 parent 3e580a0 commit d30f424

11 files changed

Lines changed: 447 additions & 69 deletions

File tree

internal/helm/client.go

Lines changed: 126 additions & 29 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 {
@@ -2689,13 +2767,26 @@ func (c *Client) batchCheckUpgrades(namespace, username string, groups []string)
26892767
return result, nil
26902768
}
26912769

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) {
2770+
// PreviewValuesChange previews the effect of new values on a release via dry-run.
2771+
// When targetVersion is non-empty and differs from the running chart version, the
2772+
// dry-run renders against that target chart instead of the current chart.
2773+
func (c *Client) PreviewValuesChange(namespace, name string, newValues map[string]any, targetVersion, repositoryName string) (*ValuesPreviewResponse, error) {
26942774
actionConfig, err := c.getActionConfig(namespace)
26952775
if err != nil {
26962776
return nil, err
26972777
}
2778+
return c.previewValuesChangeWith(actionConfig, name, newValues, targetVersion, repositoryName)
2779+
}
26982780

2781+
func (c *Client) PreviewValuesChangeAsUser(namespace, name string, newValues map[string]any, targetVersion, repositoryName string, username string, groups []string) (*ValuesPreviewResponse, error) {
2782+
actionConfig, err := c.getActionConfigForUser(namespace, username, groups)
2783+
if err != nil {
2784+
return nil, err
2785+
}
2786+
return c.previewValuesChangeWith(actionConfig, name, newValues, targetVersion, repositoryName)
2787+
}
2788+
2789+
func (c *Client) previewValuesChangeWith(actionConfig *action.Configuration, name string, newValues map[string]any, targetVersion, repositoryName string) (*ValuesPreviewResponse, error) {
26992790
// Get the current release
27002791
getAction := action.NewGet(actionConfig)
27012792
rel, err := getAction.Run(name)
@@ -2713,6 +2804,12 @@ func (c *Client) PreviewValuesChange(namespace, name string, newValues map[strin
27132804
// Get current manifest
27142805
currentManifest := rel.Manifest
27152806

2807+
noop := func(phase, message, detail string) {}
2808+
previewChart, err := c.chartForUpgradeTarget(actionConfig, rel, targetVersion, repositoryName, noop)
2809+
if err != nil {
2810+
return nil, err
2811+
}
2812+
27162813
// Perform a dry-run upgrade with the new values
27172814
upgradeAction := action.NewUpgrade(actionConfig)
27182815
upgradeAction.Namespace = rel.Namespace
@@ -2721,7 +2818,7 @@ func (c *Client) PreviewValuesChange(namespace, name string, newValues map[strin
27212818
upgradeAction.ResetValues = true // Use only the provided values, don't merge
27222819

27232820
// Run the dry-run upgrade
2724-
newRel, err := upgradeAction.Run(name, rel.Chart, newValues)
2821+
newRel, err := upgradeAction.Run(name, previewChart, newValues)
27252822
if err != nil {
27262823
return nil, fmt.Errorf("failed to preview values change: %w", err)
27272824
}

internal/helm/client_test.go

Lines changed: 16 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"},

internal/helm/handlers.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package helm
22

33
import (
44
"encoding/json"
5+
"io"
56
"log"
67
"net/http"
78
"strconv"
@@ -32,6 +33,20 @@ func userCreds(r *http.Request) (string, []string) {
3233
return "", nil
3334
}
3435

36+
func decodeOptionalApplyValuesRequest(body io.Reader) (map[string]any, error) {
37+
if body == nil {
38+
return nil, nil
39+
}
40+
var req ApplyValuesRequest
41+
if err := json.NewDecoder(body).Decode(&req); err != nil {
42+
if err == io.EOF {
43+
return nil, nil
44+
}
45+
return nil, err
46+
}
47+
return req.Values, nil
48+
}
49+
3550
// Handlers provides HTTP handlers for Helm endpoints
3651
type Handlers struct {
3752
// resolveNamespaces maps a request to the namespaces a Helm list should
@@ -717,6 +732,12 @@ func (h *Handlers) handleUpgradeStream(w http.ResponseWriter, r *http.Request) {
717732
}
718733
repositoryName := r.URL.Query().Get("repository")
719734

735+
editedValues, err := decodeOptionalApplyValuesRequest(r.Body)
736+
if err != nil {
737+
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
738+
return
739+
}
740+
720741
// Set up SSE headers
721742
w.Header().Set("Content-Type", "text/event-stream")
722743
w.Header().Set("Cache-Control", "no-cache")
@@ -732,9 +753,21 @@ func (h *Handlers) handleUpgradeStream(w http.ResponseWriter, r *http.Request) {
732753
progressCh := make(chan InstallProgress, 10)
733754
defer close(progressCh)
734755

756+
auth.AuditLog(r, namespace, name)
735757
resultCh := make(chan error, 1)
736758
go func() {
737-
if user := auth.UserFromContext(r.Context()); user != nil {
759+
user := auth.UserFromContext(r.Context())
760+
// Use != nil, not len > 0: an explicit empty map ({}) means
761+
// "clear all my overrides" and must NOT fall back to carry-over.
762+
if editedValues != nil {
763+
if user != nil {
764+
resultCh <- client.UpgradeWithValuesProgressAsUser(namespace, name, version, repositoryName, editedValues, user.Username, user.Groups, progressCh)
765+
return
766+
}
767+
resultCh <- client.UpgradeWithValuesProgress(namespace, name, version, repositoryName, editedValues, progressCh)
768+
return
769+
}
770+
if user != nil {
738771
resultCh <- client.UpgradeWithProgressAsUser(namespace, name, version, repositoryName, user.Username, user.Groups, progressCh)
739772
return
740773
}
@@ -806,7 +839,8 @@ func (h *Handlers) handlePreviewValues(w http.ResponseWriter, r *http.Request) {
806839
return
807840
}
808841

809-
preview, err := client.PreviewValuesChange(namespace, name, req.Values)
842+
username, groups := userCreds(r)
843+
preview, err := client.PreviewValuesChangeAsUser(namespace, name, req.Values, req.Version, req.Repository, username, groups)
810844
if err != nil {
811845
writeError(w, http.StatusInternalServerError, err.Error())
812846
return

internal/helm/handlers_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package helm
33
import (
44
"context"
55
"encoding/json"
6+
"io"
67
"net/http"
78
"net/http/httptest"
9+
"reflect"
10+
"strings"
811
"testing"
912

1013
"github.com/skyhook-io/radar/internal/auth"
@@ -83,6 +86,48 @@ func TestRequireCloudRole(t *testing.T) {
8386
}
8487
}
8588

89+
func TestDecodeOptionalApplyValuesRequest(t *testing.T) {
90+
cases := []struct {
91+
name string
92+
body string
93+
hasBody bool
94+
want map[string]any
95+
wantErr bool
96+
}{
97+
{name: "nil body"},
98+
{name: "empty body", hasBody: true},
99+
{name: "explicit empty values stays non nil", body: `{"values":{}}`, hasBody: true, want: map[string]any{}},
100+
{name: "populated values", body: `{"values":{"replicaCount":2}}`, hasBody: true, want: map[string]any{"replicaCount": float64(2)}},
101+
{name: "invalid json", body: `{"values":`, hasBody: true, wantErr: true},
102+
}
103+
104+
for _, tc := range cases {
105+
t.Run(tc.name, func(t *testing.T) {
106+
var body io.Reader
107+
if tc.hasBody {
108+
body = strings.NewReader(tc.body)
109+
}
110+
111+
got, err := decodeOptionalApplyValuesRequest(body)
112+
if tc.wantErr {
113+
if err == nil {
114+
t.Fatal("expected error")
115+
}
116+
return
117+
}
118+
if err != nil {
119+
t.Fatalf("decodeOptionalApplyValuesRequest returned error: %v", err)
120+
}
121+
if !reflect.DeepEqual(got, tc.want) {
122+
t.Fatalf("values = %#v, want %#v", got, tc.want)
123+
}
124+
if tc.want != nil && got == nil {
125+
t.Fatal("values = nil, want explicit non-nil map")
126+
}
127+
})
128+
}
129+
}
130+
86131
// TestSensitiveHelmHandlers_GateOnViewer asserts that every Helm
87132
// handler we believe is gated actually 403s a Cloud viewer with
88133
// error_code=cloud_role_insufficient. The unit test above

0 commit comments

Comments
 (0)