From 04a63d06bfd9427d35248d71cd1a0e1b4d755875 Mon Sep 17 00:00:00 2001 From: jaylonmcshan19-x Date: Fri, 11 Jul 2025 07:55:44 -0600 Subject: [PATCH 1/6] Add upgrade_install support --- helm/resource_helm_release.go | 141 +++++++++++++++++++++++++---- helm/resource_helm_release_test.go | 114 +++++++++++++++++++++++ 2 files changed, 238 insertions(+), 17 deletions(-) diff --git a/helm/resource_helm_release.go b/helm/resource_helm_release.go index c10921fa4..2594c1d1b 100644 --- a/helm/resource_helm_release.go +++ b/helm/resource_helm_release.go @@ -36,7 +36,6 @@ import ( "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/downloader" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/postrender" @@ -108,6 +107,7 @@ type HelmReleaseModel struct { SkipCrds types.Bool `tfsdk:"skip_crds"` Status types.String `tfsdk:"status"` Timeout types.Int64 `tfsdk:"timeout"` + UpgradeInstall types.Bool `tfsdk:"upgrade_install"` Values types.List `tfsdk:"values"` Verify types.Bool `tfsdk:"verify"` Version types.String `tfsdk:"version"` @@ -137,6 +137,7 @@ var defaultAttributes = map[string]interface{}{ "verify": false, "wait": true, "wait_for_jobs": false, + "upgrade_install": false, } type releaseMetaData struct { @@ -619,6 +620,12 @@ func (r *HelmRelease) Schema(ctx context.Context, req resource.SchemaRequest, re }, }, }, + "upgrade_install": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(defaultAttributes["upgrade_install"].(bool)), + Description: "If true, the provider will install the release at the specified version even if a release not controlled by the provider is present. This is equivalent to running 'helm upgrade --install'. WARNING: this may not be suitable for production use -- see the 'Upgrade Mode' note in the provider documentation. Defaults to `false`.", + }, "postrender": schema.SingleNestedAttribute{ Description: "Postrender command config", Optional: true, @@ -709,6 +716,25 @@ func mergeMaps(a, b map[string]interface{}) map[string]interface{} { return out } +func getInstalledReleaseVersion(ctx context.Context, m *Meta, cfg *action.Configuration, name string) (string, error) { + logID := fmt.Sprintf("[getInstalledReleaseVersion: %s]", name) + histClient := action.NewHistory(cfg) + histClient.Max = 1 + + hist, err := histClient.Run(name) + if err != nil { + if strings.Contains(err.Error(), "not found") { + tflog.Debug(ctx, fmt.Sprintf("%s Chart %s is not yet installed", logID, name)) + return "", nil + } + return "", err + } + + installedVersion := hist[0].Chart.Metadata.Version + tflog.Debug(ctx, fmt.Sprintf("%s Chart %s is installed as release %s", logID, name, installedVersion)) + return installedVersion, nil +} + func (r *HelmRelease) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var state HelmReleaseModel diags := req.Plan.Get(ctx, &state) @@ -808,27 +834,87 @@ func (r *HelmRelease) Create(ctx context.Context, req resource.CreateRequest, re client.Description = state.Description.ValueString() client.CreateNamespace = state.CreateNamespace.ValueBool() - if state.PostRender != nil { - binaryPath := state.PostRender.BinaryPath.ValueString() - argsList := state.PostRender.Args.Elements() + var releaseAlreadyExists bool + var installedVersion string + var rel *release.Release - if binaryPath != "" { - var args []string - for _, arg := range argsList { - args = append(args, arg.(basetypes.StringValue).ValueString()) - } - tflog.Debug(ctx, fmt.Sprintf("Creating post-renderer with binary path: %s and args: %v", binaryPath, args)) - pr, err := postrender.NewExec(binaryPath, args...) - if err != nil { - resp.Diagnostics.AddError("Error creating post-renderer", fmt.Sprintf("Could not create post-renderer: %s", err)) - return - } + releaseName := state.Name.ValueString() - client.PostRenderer = pr + if state.UpgradeInstall.ValueBool() { + tflog.Debug(ctx, fmt.Sprintf("Checking if %q is already installed", releaseName)) + + ver, err := getInstalledReleaseVersion(ctx, meta, actionConfig, releaseName) + if err != nil { + resp.Diagnostics.AddError("Error checking installed release", fmt.Sprintf("Failed to determine if release exists: %s", err)) + return + } + installedVersion = ver + if installedVersion != "" { + tflog.Debug(ctx, fmt.Sprintf("Release %q is installed (version: %s)", releaseName, installedVersion)) + releaseAlreadyExists = true + } else { + tflog.Debug(ctx, fmt.Sprintf("Release %q is not installed", releaseName)) } } - rel, err := client.Run(c, values) + if state.UpgradeInstall.ValueBool() && releaseAlreadyExists { + tflog.Debug(ctx, fmt.Sprintf("Upgrade-installing chart %q", releaseName)) + + upgradeClient := action.NewUpgrade(actionConfig) + upgradeClient.ChartPathOptions = *cpo + upgradeClient.DryRun = false + upgradeClient.DisableHooks = state.DisableWebhooks.ValueBool() + upgradeClient.Wait = state.Wait.ValueBool() + upgradeClient.Devel = state.Devel.ValueBool() + upgradeClient.Timeout = time.Duration(state.Timeout.ValueInt64()) * time.Second + upgradeClient.Namespace = state.Namespace.ValueString() + upgradeClient.Atomic = state.Atomic.ValueBool() + upgradeClient.SkipCRDs = state.SkipCrds.ValueBool() + upgradeClient.SubNotes = state.RenderSubchartNotes.ValueBool() + upgradeClient.DisableOpenAPIValidation = state.DisableOpenapiValidation.ValueBool() + upgradeClient.Description = state.Description.ValueString() + + if state.PostRender != nil { + binaryPath := state.PostRender.BinaryPath.ValueString() + argsList := state.PostRender.Args.Elements() + if binaryPath != "" { + var args []string + for _, arg := range argsList { + args = append(args, arg.(basetypes.StringValue).ValueString()) + } + pr, err := postrender.NewExec(binaryPath, args...) + if err != nil { + resp.Diagnostics.AddError("Post-render Error", fmt.Sprintf("Could not create post-renderer: %s", err)) + return + } + upgradeClient.PostRenderer = pr + } + } + + rel, err = upgradeClient.Run(releaseName, c, values) + } else { + tflog.Debug(ctx, fmt.Sprintf("Installing chart %q", releaseName)) + if state.PostRender != nil { + binaryPath := state.PostRender.BinaryPath.ValueString() + argsList := state.PostRender.Args.Elements() + + if binaryPath != "" { + var args []string + for _, arg := range argsList { + args = append(args, arg.(basetypes.StringValue).ValueString()) + } + tflog.Debug(ctx, fmt.Sprintf("Creating post-renderer with binary path: %s and args: %v", binaryPath, args)) + pr, err := postrender.NewExec(binaryPath, args...) + if err != nil { + resp.Diagnostics.AddError("Error creating post-renderer", fmt.Sprintf("Could not create post-renderer: %s", err)) + return + } + + client.PostRenderer = pr + } + } + rel, err = client.Run(c, values) + } if err != nil && rel == nil { resp.Diagnostics.AddError("installation failed", err.Error()) return @@ -2030,6 +2116,27 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq tflog.Debug(ctx, fmt.Sprintf("%s Done", logID)) + if plan.UpgradeInstall.ValueBool() && plan.Version.IsNull() { + tflog.Debug(ctx, fmt.Sprintf("%s upgrade_install is enabled and version attribute is empty", logID)) + + installedVersion, err := getInstalledReleaseVersion(ctx, meta, actionConfig, name) + if err != nil { + resp.Diagnostics.AddError("Failed to check installed release version", err.Error()) + return + } + + if installedVersion != "" { + tflog.Debug(ctx, fmt.Sprintf("%s setting version to installed version %s", logID, installedVersion)) + plan.Version = types.StringValue(installedVersion) + } else if len(chart.Metadata.Version) > 0 { + tflog.Debug(ctx, fmt.Sprintf("%s setting version to chart version %s", logID, chart.Metadata.Version)) + plan.Version = types.StringValue(chart.Metadata.Version) + } else { + tflog.Debug(ctx, fmt.Sprintf("%s setting version to computed", logID)) + plan.Version = types.StringNull() + } + } + if len(chart.Metadata.Version) > 0 { plan.Version = types.StringValue(chart.Metadata.Version) } else { diff --git a/helm/resource_helm_release_test.go b/helm/resource_helm_release_test.go index 898668cbc..557754aed 100644 --- a/helm/resource_helm_release_test.go +++ b/helm/resource_helm_release_test.go @@ -592,6 +592,120 @@ func TestAccResourceRelease_updateAfterFail(t *testing.T) { }) } +func TestAccResourceRelease_upgradeInstall_coldstart(t *testing.T) { + name := randName("coldstart") + namespace := createRandomNamespace(t) + defer deleteNamespace(t, namespace) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testAccHelmReleaseConfigWithUpgradeInstall(testResourceName, namespace, name, "1.2.3", true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.name", name), + resource.TestCheckResourceAttr("helm_release.test", "metadata.namespace", namespace), + resource.TestCheckResourceAttr("helm_release.test", "metadata.revision", "1"), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + resource.TestCheckResourceAttr("helm_release.test", "description", "Test"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.chart", "test-chart"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.version", "1.2.3"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.app_version", "1.19.5"), + ), + }, + { + Config: testAccHelmReleaseConfigWithUpgradeInstall(testResourceName, namespace, name, "1.2.3", true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.revision", "1"), + ), + }, + }, + }) +} + +func testAccHelmReleaseConfigWithUpgradeInstall(resource, ns, name, version string, upgradeInstall bool) string { + return fmt.Sprintf(` +resource "helm_release" "%s" { + name = %q + namespace = %q + chart = "test-chart" + repository = %q + version = %q + description = "Test" + upgrade_install = %t + + set = [ + { + name = "foo" + value = "qux" + }, + { + name = "qux.bar" + value = "1" + }, + { + name = "master.persistence.enabled" + value = "false" + }, + { + name = "replication.enabled" + value = "false" + } + ] +} +`, resource, name, ns, testRepositoryURL, version, upgradeInstall) +} + +func TestAccResourceRelease_upgradeInstall_warmstart(t *testing.T) { + name := randName("warmstart") + namespace := createRandomNamespace(t) + defer deleteNamespace(t, namespace) + + cmd := exec.Command("helm", "install", name, "test-chart", + "--repo", testRepositoryURL, + "--version", "1.2.3", + "-n", namespace, "--create-namespace") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to preinstall release: %s\n%s", err, out) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testAccHelmReleaseWarmstart(testResourceName, namespace, name, "1.2.3"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.revision", "2"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.version", "1.2.3"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.values", `{"foo":"bar"}`), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + ), + }, + }, + }) +} + +func testAccHelmReleaseWarmstart(resource, ns, name, version string) string { + return fmt.Sprintf(` +resource "helm_release" "%s" { + name = %q + namespace = %q + chart = "test-chart" + repository = %q + version = %q + description = "Test" + upgrade_install = true + + set = [ + { + name = "foo" + value = "bar" + } + ] +} +`, resource, name, ns, testRepositoryURL, version) +} + func TestAccResourceRelease_updateExistingFailed(t *testing.T) { name := randName("test-update-existing-failed") namespace := createRandomNamespace(t) From 5ac8c43be157bd5000a0d482d4e8a93fd3fbf0e9 Mon Sep 17 00:00:00 2001 From: jaylonmcshan19-x Date: Fri, 11 Jul 2025 08:00:24 -0600 Subject: [PATCH 2/6] Add changelog --- .changelog/1675.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/1675.txt diff --git a/.changelog/1675.txt b/.changelog/1675.txt new file mode 100644 index 000000000..0658e8757 --- /dev/null +++ b/.changelog/1675.txt @@ -0,0 +1,3 @@ +```release-note:enhancement: +`helm_release`: adds support for the `upgrade_install` field +``` \ No newline at end of file From 283d65e6809d69f42fb548f575852dda73e77f3e Mon Sep 17 00:00:00 2001 From: jaylonmcshan19-x Date: Fri, 11 Jul 2025 08:55:57 -0600 Subject: [PATCH 3/6] Adding upgrade_install to state upgrader --- helm/resource_helm_release_stateupgrader.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helm/resource_helm_release_stateupgrader.go b/helm/resource_helm_release_stateupgrader.go index 51950075c..6260b042f 100644 --- a/helm/resource_helm_release_stateupgrader.go +++ b/helm/resource_helm_release_stateupgrader.go @@ -102,7 +102,7 @@ func (r *HelmRelease) buildUpgradeStateMap(_ context.Context) map[int64]resource "skip_crds": tftypes.Bool, "status": tftypes.String, "timeout": tftypes.Number, - "upgrade_install": tftypes.String, + "upgrade_install": tftypes.Bool, "values": tftypes.List{ElementType: tftypes.String}, "verify": tftypes.Bool, "version": tftypes.String, @@ -249,6 +249,7 @@ func (r *HelmRelease) buildUpgradeStateMap(_ context.Context) map[int64]resource "set_wo_revision": tftypes.Number, "status": tftypes.String, "timeout": tftypes.Number, + "upgrade_install": tftypes.Bool, "values": tftypes.List{ElementType: tftypes.String}, "verify": tftypes.Bool, "version": tftypes.String, @@ -303,6 +304,7 @@ func (r *HelmRelease) buildUpgradeStateMap(_ context.Context) map[int64]resource "skip_crds": oldState["skip_crds"], "status": oldState["status"], "timeout": oldState["timeout"], + "upgrade_install": oldState["upgrade_install"], "values": oldState["values"], "verify": oldState["verify"], "version": oldState["version"], From df07c07ee5fae6d56e8fbaca460928351cf2bcd2 Mon Sep 17 00:00:00 2001 From: jaylonmcshan19-x Date: Tue, 15 Jul 2025 13:52:26 -0500 Subject: [PATCH 4/6] Add upgradeInstall_warmstart_no_version test case --- helm/resource_helm_release_test.go | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/helm/resource_helm_release_test.go b/helm/resource_helm_release_test.go index 557754aed..55989bd04 100644 --- a/helm/resource_helm_release_test.go +++ b/helm/resource_helm_release_test.go @@ -706,6 +706,58 @@ resource "helm_release" "%s" { `, resource, name, ns, testRepositoryURL, version) } +func TestAccResourceRelease_upgradeInstall_warmstart_no_version(t *testing.T) { + versions := []string{"1.2.3", "2.0.0"} + + for _, version := range versions { + name := randName("warm-noversion") + namespace := createRandomNamespace(t) + defer deleteNamespace(t, namespace) + + cmd := exec.Command("helm", "install", name, "test-chart", + "--repo", testRepositoryURL, + "--version", version, + "-n", namespace, "--create-namespace") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to preinstall release: %s\n%s", err, out) + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: protoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testAccHelmReleaseWarmstartNoVersion(testResourceName, namespace, name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.revision", "2"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.version", version), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + ), + }, + }, + }) + } +} + +func testAccHelmReleaseWarmstartNoVersion(resource, ns, name string) string { + return fmt.Sprintf(` +resource "helm_release" "%s" { + name = %q + namespace = %q + chart = "test-chart" + repository = %q + description = "Test" + upgrade_install = true + + set = [ + { + name = "foo" + value = "bar" + } + ] +} +`, resource, name, ns, testRepositoryURL) +} + func TestAccResourceRelease_updateExistingFailed(t *testing.T) { name := randName("test-update-existing-failed") namespace := createRandomNamespace(t) From d92e135fc80dcf5976ed33ab50e27f074c73144b Mon Sep 17 00:00:00 2001 From: John Houston Date: Tue, 15 Jul 2025 13:15:34 -0600 Subject: [PATCH 5/6] Fix upgrade_install plan modifier logic --- helm/resource_helm_release.go | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/helm/resource_helm_release.go b/helm/resource_helm_release.go index 2594c1d1b..37b536cc9 100644 --- a/helm/resource_helm_release.go +++ b/helm/resource_helm_release.go @@ -2116,7 +2116,7 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq tflog.Debug(ctx, fmt.Sprintf("%s Done", logID)) - if plan.UpgradeInstall.ValueBool() && plan.Version.IsNull() { + if plan.UpgradeInstall.ValueBool() && config.Version.IsNull() { tflog.Debug(ctx, fmt.Sprintf("%s upgrade_install is enabled and version attribute is empty", logID)) installedVersion, err := getInstalledReleaseVersion(ctx, meta, actionConfig, name) @@ -2125,6 +2125,8 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq return } + // panic(fmt.Sprintf("installed version: %q", installedVersion)) + if installedVersion != "" { tflog.Debug(ctx, fmt.Sprintf("%s setting version to installed version %s", logID, installedVersion)) plan.Version = types.StringValue(installedVersion) @@ -2135,23 +2137,23 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq tflog.Debug(ctx, fmt.Sprintf("%s setting version to computed", logID)) plan.Version = types.StringNull() } - } - - if len(chart.Metadata.Version) > 0 { - plan.Version = types.StringValue(chart.Metadata.Version) } else { - plan.Version = types.StringNull() - } - - if !config.Version.IsNull() && !config.Version.Equal(plan.Version) { - if versionsEqual(config.Version.ValueString(), plan.Version.ValueString()) { - plan.Version = config.Version + if len(chart.Metadata.Version) > 0 { + plan.Version = types.StringValue(chart.Metadata.Version) } else { - resp.Diagnostics.AddError( - "Planned version is different from configured version", - fmt.Sprintf(`The version in the configuration is %q but the planned version is %q. + plan.Version = types.StringNull() + } + + if !config.Version.IsNull() && !config.Version.Equal(plan.Version) { + if versionsEqual(config.Version.ValueString(), plan.Version.ValueString()) { + plan.Version = config.Version + } else { + resp.Diagnostics.AddError( + "Planned version is different from configured version", + fmt.Sprintf(`The version in the configuration is %q but the planned version is %q. You should update the version in your configuration to %[2]q, or remove the version attribute from your configuration.`, config.Version.ValueString(), plan.Version.ValueString())) - return + return + } } } From 400945c9f5e1b75398adda0ca4280eee6e8ddc07 Mon Sep 17 00:00:00 2001 From: John Houston Date: Tue, 15 Jul 2025 13:16:19 -0600 Subject: [PATCH 6/6] Remove redundant comment --- helm/resource_helm_release.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/helm/resource_helm_release.go b/helm/resource_helm_release.go index 37b536cc9..44937a647 100644 --- a/helm/resource_helm_release.go +++ b/helm/resource_helm_release.go @@ -2125,8 +2125,6 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq return } - // panic(fmt.Sprintf("installed version: %q", installedVersion)) - if installedVersion != "" { tflog.Debug(ctx, fmt.Sprintf("%s setting version to installed version %s", logID, installedVersion)) plan.Version = types.StringValue(installedVersion)