From a16080de8988e01f9e5f0d597824cc4f196eab65 Mon Sep 17 00:00:00 2001 From: "Nathan J. Mehl" Date: Mon, 28 Aug 2023 14:23:05 -0400 Subject: [PATCH] Proposal: upgrade mode This PR adds a (potentially) idempotent "upgrade mode" to the provider, mimicing the behavior of `helm upgrade --install` as defined in https://github.com/helm/helm/blob/main/cmd/helm/upgrade.go To wit: an `upgrade` block is added to the resource attributes, consisting of tool boolean values: `enable` and `install`. If `enabled` is true, this causes the provider to create a `*action.Upgrade` client, and attempts to perform an upgrade on the named chart. If `install` is true, it will first create an `*action.History` client to determine if a release already exists; if it does not find one it creates an `*action.Install` client and attempts to install the release from scratch. If a release _is_ found, an upgrade is performed. This allows the resource to potentially co-exist with, e.g., CI/CD systems that could potentially install the release out-of-band from terraform's viewpoint. --- helm/resource_release.go | 132 ++++++++++++++++++------- helm/resource_release_test.go | 143 +++++++++++++++++++++++++++ website/docs/r/release.html.markdown | 30 ++++++ 3 files changed, 270 insertions(+), 35 deletions(-) diff --git a/helm/resource_release.go b/helm/resource_release.go index 72c53aa43..c3c6b272f 100644 --- a/helm/resource_release.go +++ b/helm/resource_release.go @@ -26,6 +26,7 @@ import ( "helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" "helm.sh/helm/v3/pkg/strvals" "sigs.k8s.io/yaml" ) @@ -56,6 +57,10 @@ var defaultAttributes = map[string]interface{}{ "create_namespace": false, "lint": false, "pass_credentials": false, + "upgrade": map[string]bool{ + "enable": false, + "install": false, + }, } func resourceRelease() *schema.Resource { @@ -429,6 +434,15 @@ func resourceRelease() *schema.Resource { }, }, }, + "upgrade": { + Type: schema.TypeMap, + Optional: true, + Default: defaultAttributes["upgrade"], + Description: "Configure 'upgrade' strategy for installing charts. WARNING: this may not be suitable for production use -- see the provider documentation", + Elem: &schema.Schema{ + Type: schema.TypeBool, + }, + }, }, SchemaVersion: 1, StateUpgraders: []schema.StateUpgrader{ @@ -588,50 +602,98 @@ func resourceReleaseCreate(ctx context.Context, d *schema.ResourceData, meta int return diag.FromErr(err) } - client.ClientOnly = false - client.DryRun = false - client.DisableHooks = d.Get("disable_webhooks").(bool) - client.Wait = d.Get("wait").(bool) - client.WaitForJobs = d.Get("wait_for_jobs").(bool) - client.Devel = d.Get("devel").(bool) - client.DependencyUpdate = d.Get("dependency_update").(bool) - client.Timeout = time.Duration(d.Get("timeout").(int)) * time.Second - client.Namespace = d.Get("namespace").(string) - client.ReleaseName = d.Get("name").(string) - client.GenerateName = false - client.NameTemplate = "" - client.OutputDir = "" - client.Atomic = d.Get("atomic").(bool) - client.SkipCRDs = d.Get("skip_crds").(bool) - client.SubNotes = d.Get("render_subchart_notes").(bool) - client.DisableOpenAPIValidation = d.Get("disable_openapi_validation").(bool) - client.Replace = d.Get("replace").(bool) - client.Description = d.Get("description").(string) - client.CreateNamespace = d.Get("create_namespace").(bool) + var rel *release.Release + var installIfNoReleaseToUpgrade bool + var releaseAlreadyExists bool - if cmd := d.Get("postrender.0.binary_path").(string); cmd != "" { - av := d.Get("postrender.0.args") - var args []string - for _, arg := range av.([]interface{}) { - if arg == nil { - continue - } - args = append(args, arg.(string)) - } + releaseName := d.Get("name").(string) + upgradeStrategy := d.Get("upgrade").(map[string]interface{}) + enableUpgradeStrategy, ok := upgradeStrategy["enable"].(bool) - pr, err := postrender.NewExec(cmd, args...) + if ok && enableUpgradeStrategy { + installIfNoReleaseToUpgrade, _ = upgradeStrategy["install"].(bool) + } - if err != nil { + if enableUpgradeStrategy { + // Check to see if there is already a release installed. + histClient := action.NewHistory(actionConfig) + histClient.Max = 1 + if _, err := histClient.Run(releaseName); err == driver.ErrReleaseNotFound { + debug("%s Chart %s is not yet installed", logID, chartName) + } else if err != nil { return diag.FromErr(err) + } else { + releaseAlreadyExists = true + debug("%s Chart %s is installed as release %s", logID, chartName, releaseName) } - - client.PostRenderer = pr } - debug("%s Installing chart", logID) + if enableUpgradeStrategy && releaseAlreadyExists { + debug("%s Upgrading chart", logID) - rel, err := client.Run(c, values) + upgradeClient := action.NewUpgrade(actionConfig) + upgradeClient.ChartPathOptions = *cpo + upgradeClient.DryRun = false + upgradeClient.DisableHooks = d.Get("disable_webhooks").(bool) + upgradeClient.Wait = d.Get("wait").(bool) + upgradeClient.Devel = d.Get("devel").(bool) + upgradeClient.Timeout = time.Duration(d.Get("timeout").(int)) * time.Second + upgradeClient.Namespace = d.Get("namespace").(string) + upgradeClient.Atomic = d.Get("atomic").(bool) + upgradeClient.SkipCRDs = d.Get("skip_crds").(bool) + upgradeClient.SubNotes = d.Get("render_subchart_notes").(bool) + upgradeClient.DisableOpenAPIValidation = d.Get("disable_openapi_validation").(bool) + upgradeClient.Description = d.Get("description").(string) + if cmd := d.Get("postrender.0.binary_path").(string); cmd != "" { + pr, err := postrender.NewExec(cmd) + if err != nil { + return diag.FromErr(err) + } + upgradeClient.PostRenderer = pr + } + + debug("%s Upgrading chart", logID) + rel, err = upgradeClient.Run(releaseName, c, values) + } else if (enableUpgradeStrategy && installIfNoReleaseToUpgrade && !releaseAlreadyExists) || !enableUpgradeStrategy { + instClient := action.NewInstall(actionConfig) + instClient.Replace = d.Get("replace").(bool) + + instClient.ChartPathOptions = *cpo + instClient.ClientOnly = false + instClient.DryRun = false + instClient.DisableHooks = d.Get("disable_webhooks").(bool) + instClient.Wait = d.Get("wait").(bool) + instClient.Devel = d.Get("devel").(bool) + instClient.DependencyUpdate = d.Get("dependency_update").(bool) + instClient.Timeout = time.Duration(d.Get("timeout").(int)) * time.Second + instClient.Namespace = d.Get("namespace").(string) + instClient.ReleaseName = d.Get("name").(string) + instClient.GenerateName = false + instClient.NameTemplate = "" + instClient.OutputDir = "" + instClient.Atomic = d.Get("atomic").(bool) + instClient.SkipCRDs = d.Get("skip_crds").(bool) + instClient.SubNotes = d.Get("render_subchart_notes").(bool) + instClient.DisableOpenAPIValidation = d.Get("disable_openapi_validation").(bool) + instClient.Description = d.Get("description").(string) + instClient.CreateNamespace = d.Get("create_namespace").(bool) + + if cmd := d.Get("postrender.0.binary_path").(string); cmd != "" { + pr, err := postrender.NewExec(cmd) + if err != nil { + return diag.FromErr(err) + } + instClient.PostRenderer = pr + } + + debug("%s Installing chart", logID) + rel, err = instClient.Run(c, values) + } else if enableUpgradeStrategy && !installIfNoReleaseToUpgrade && !releaseAlreadyExists { + return diag.FromErr(fmt.Errorf( + "upgrade strategy enabled, but chart not already installed and install=false chartName=%v releaseName=%v enableUpgradeStrategy=%t installIfNoReleaseToUpgrade=%t releaseAlreadyExists=%t", + chartName, releaseName, enableUpgradeStrategy, installIfNoReleaseToUpgrade, releaseAlreadyExists)) + } if err != nil && rel == nil { return diag.FromErr(err) } diff --git a/helm/resource_release_test.go b/helm/resource_release_test.go index babad0c4b..e1c1076ff 100644 --- a/helm/resource_release_test.go +++ b/helm/resource_release_test.go @@ -105,6 +105,91 @@ func TestAccResourceRelease_emptyVersion(t *testing.T) { }) } +// "upgrade" without a previously installed release with --install (effectively equivalent to TestAccResourceRelease_basic) +func TestAccResourceRelease_upgrade_with_install_coldstart(t *testing.T) { + name := randName("basic") + namespace := createRandomNamespace(t) + // Delete namespace automatically created by helm after checks + defer deleteNamespace(t, namespace) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckHelmReleaseDestroy(namespace), + + Steps: []resource.TestStep{{ + Config: testAccHelmReleaseConfigWithUpgradeStrategy(testResourceName, namespace, name, "1.2.3", true, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.name", name), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.namespace", namespace), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.revision", "1"), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + resource.TestCheckResourceAttr("helm_release.test", "description", "Test"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.chart", "test-chart"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.version", "1.2.3"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.app_version", "1.19.5"), + ), + }, { + Config: testAccHelmReleaseConfigWithUpgradeStrategy(testResourceName, namespace, name, "1.2.3", true, true), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.revision", "1"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.version", "1.2.3"), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + resource.TestCheckResourceAttr("helm_release.test", "description", "Test"), + ), + }}, + }) +} + +// "upgrade" install wherein we pretend that someone else (e.g. a CI/CD system) has done the first install +func TestAccResourceRelease_upgrade_with_install_warmstart(t *testing.T) { + name := randName("basic") + namespace := createRandomNamespace(t) + // Delete namespace automatically created by helm after checks + defer deleteNamespace(t, namespace) + + // preinstall the first revision of our chart directly via the helm CLI + args := []string{"install", "-n", namespace, "--create-namespace", name, "./test-chart-1.2.3.tgz"} + cmd := exec.Command("helm", args...) + stdout, err := cmd.Output() + if err != nil { + t.Fatalf("could not preinstall helm chart: %v -- %s", err, stdout) + } + + // upgrade-install on top of the existing release, creating a new revision + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckHelmReleaseDestroy(namespace), + Steps: []resource.TestStep{{ + Config: testAccHelmReleaseConfigWithUpgradeStrategyWarmstart(namespace, name), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.revision", "2"), + resource.TestCheckResourceAttr("helm_release.test", "metadata.0.version", "1.2.3"), + resource.TestCheckResourceAttr("helm_release.test", "status", release.StatusDeployed.String()), + )}}, + }) +} + +// "upgrade" without a previously installed release without --install (will fail because nothing to upgrade) +func TestAccResourceRelease_upgrade_without_install(t *testing.T) { + name := randName("basic") + namespace := createRandomNamespace(t) + // Delete namespace automatically created by helm after checks + defer deleteNamespace(t, namespace) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckHelmReleaseDestroy(namespace), + Steps: []resource.TestStep{{ + Config: testAccHelmReleaseConfigWithUpgradeStrategy(testResourceName, namespace, name, "1.2.3", true, false), + ExpectError: regexp.MustCompile("upgrade strategy enabled, but chart not already installed and install=false"), + ExpectNonEmptyPlan: true, + }}, + }) +} + func TestAccResourceRelease_import(t *testing.T) { name := randName("import") namespace := createRandomNamespace(t) @@ -919,6 +1004,64 @@ func testAccHelmReleaseConfigEmptyVersion(resource, ns, name string) string { `, resource, name, ns, testRepositoryURL) } +func testAccHelmReleaseConfigWithUpgradeStrategy(resource, ns, name, version string, enabled, install bool) string { + return fmt.Sprintf(` + resource "helm_release" "%s" { + name = %q + namespace = %q + description = "Test" + repository = "%s" + chart = "test-chart" + version = %q + + upgrade = { + enable = %t + install = %t + } + + set { + name = "foo" + value = "qux" + } + + set { + name = "qux.bar" + value = 1 + } + + set { + name = "master.persistence.enabled" + value = false # persistent volumes are giving non-related issues when testing + } + set { + name = "replication.enabled" + value = false + } + } + `, resource, name, ns, testRepositoryURL, version, enabled, install) +} + +func testAccHelmReleaseConfigWithUpgradeStrategyWarmstart(ns, name string) string { + return fmt.Sprintf(` + resource "helm_release" "test" { + name = %q + namespace = %q + description = "Test" + chart = "./test-chart-1.2.3.tgz" + version = "0.1.0" + + upgrade = { + enable = true + install = false + } + set { + name = "foo" + value = "bar" + } + } + `, name, ns) +} + func testAccHelmReleaseConfigValues(resource, ns, name, chart, version string, values []string) string { vals := make([]string, len(values)) for i, v := range values { diff --git a/website/docs/r/release.html.markdown b/website/docs/r/release.html.markdown index b87df30b8..d26631be8 100644 --- a/website/docs/r/release.html.markdown +++ b/website/docs/r/release.html.markdown @@ -212,6 +212,7 @@ The following arguments are supported: * `pass_credentials` - (Optional) Pass credentials to all domains. Defaults to `false`. * `lint` - (Optional) Run the helm chart linter during the plan. Defaults to `false`. * `create_namespace` - (Optional) Create the namespace if it does not yet exist. Defaults to `false`. +* `upgrade` - (Optional) Enable "upgrade mode" -- the structure of this map is documented below. The `set`, `set_list`, and `set_sensitive` blocks support: @@ -241,6 +242,10 @@ The `postrender` block supports two attributes: * `args` - (Optional) a list of arguments to supply to the post-renderer. +The `upgrade` block supports: + +* `enable` - (Required) if set to `true`, use the "upgrade" strategy to install the chart. See [upgrade mode](#upgrade_mode) below for details. +* `install` - (Optional) if set to `true`, install the release even if there is no existing release to upgrade. ## Attributes Reference @@ -261,6 +266,31 @@ The `metadata` block supports: * `app_version` - The version number of the application being deployed. * `values` - The compounded values from `values` and `set*` attributes. +## Upgrade Mode + +When using the Helm CLI directly, it is possible (and fairly common) to use `helm upgrade --install` to +_idempotently_ install a release. For example, `helm upgrade --install mariadb charts/mariadb --verson 7.1.0` +will check to see if there is already a release called `mariadb`: if there is, ensure that it is set to version +7.1.0, and if there is not, install that version from scratch. (See the documentation for the +[helm upgrade](https://helm.sh/docs/helm/helm_upgrade) command for more details.) + +Emulating this behavior in the `helm_release` resource might be desirable if, for example, the initial installation +of a chart is handled out-of-band by a CI/CD system and you want to subsequently add the release to terraform without +having to manually import the release into terraform state each time. But the mechanics of this approach are subtly +different from the defaults and you can easily produce unexpected or undesirable results if you are not careful: +using this approach in production is not necessarily recommended! + +If upgrade mode is enabled by setting `enable` to `true` in the `upgrade` map, the provider will first check to see +if a release with the given name already exists. If that release exists, it will attempt to upgrade the release to +the state defined in the resource, using the same strategy as the [helm upgrade](https://helm.sh/docs/helm/helm_upgrade) +command. In this case, the `generate_name`, `name_template` and `replace` attributes of the resource (if set) are +ignored, as those attributes are not supported by helm's "upgrade" behavior. + +If the release does _not_ exist, the behavior is controlled by the setting of the `install` attribute. If `install` +is `false` or unset, the apply stage will fail: the provider cannot upgrade a non-existent release. If `install` +is set to `true`, the provider will perform a from-scratch installation of the chart. In this case, all resource +attributes are honored. + ## Import A Helm Release resource can be imported using its namespace and name e.g.