diff --git a/.changelog/1247.txt b/.changelog/1247.txt new file mode 100644 index 000000000..1df9dfafc --- /dev/null +++ b/.changelog/1247.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/helm_release: add `upgrade` map attribute to enable idempotent release installation, addressing components of [GH-425](https://github.com/hashicorp/terraform-provider-helm/issues/425) +``` diff --git a/helm/resource_release.go b/helm/resource_release.go index 72c53aa43..a626b020c 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" ) @@ -385,6 +386,27 @@ func resourceRelease() *schema.Resource { Description: "The rendered manifest as JSON.", Computed: true, }, + "upgrade": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Configure 'upgrade' strategy for installing charts. WARNING: this may not be suitable for production use -- see the provider documentation,", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enable": { + Type: schema.TypeBool, + Required: true, + 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 using the 'helm upgrade' CLI tool rather than 'helm install'.", + }, + "install": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "When using the 'upgrade' strategy, install the release if it is not already present. This is equivalent to using the 'helm upgrade' CLI tool with the '--install' flag.", + }, + }, + }, + }, "metadata": { Type: schema.TypeList, Computed: true, @@ -588,50 +610,102 @@ 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 + var enableUpgradeStrategy 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) + upgradeBlock := d.Get("upgrade").([]interface{}) + if len(upgradeBlock) > 0 { + upgradeStrategyMap := upgradeBlock[0].(map[string]interface{}) + var ok bool + enableUpgradeStrategy, ok = upgradeStrategyMap["enable"].(bool) + if ok && enableUpgradeStrategy { + installIfNoReleaseToUpgrade, _ = upgradeStrategyMap["install"].(bool) } + } - pr, err := postrender.NewExec(cmd, args...) - - 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); errors.Is(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..ff1580597 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/helm/test-chart-1.2.3.tgz b/helm/test-chart-1.2.3.tgz index 4599ce057..305829f68 100644 Binary files a/helm/test-chart-1.2.3.tgz and b/helm/test-chart-1.2.3.tgz differ diff --git a/website/docs/r/release.html.markdown b/website/docs/r/release.html.markdown index 3603fc254..16b388843 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 block is documented below. The `set`, `set_list`, and `set_sensitive` blocks support: @@ -254,6 +255,11 @@ The `postrender` block supports two attributes: * `binary_path` - (Required) relative or full path to command binary. * `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 In addition to the arguments listed above, the following computed attributes are @@ -273,6 +279,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` block, 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.