Skip to content

Commit

Permalink
Proposal: upgrade mode
Browse files Browse the repository at this point in the history
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 `enable` 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.
  • Loading branch information
n-oden committed Jan 17, 2024
1 parent 8da8621 commit 4086731
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 35 deletions.
3 changes: 3 additions & 0 deletions .changelog/1247.txt
Original file line number Diff line number Diff line change
@@ -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)
```
144 changes: 109 additions & 35 deletions helm/resource_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
143 changes: 143 additions & 0 deletions helm/resource_release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Binary file modified helm/test-chart-1.2.3.tgz
Binary file not shown.
31 changes: 31 additions & 0 deletions website/docs/r/release.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down

0 comments on commit 4086731

Please sign in to comment.