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 `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.
  • Loading branch information
n-oden committed Sep 7, 2023
1 parent 7aacef7 commit a16080d
Show file tree
Hide file tree
Showing 3 changed files with 270 additions and 35 deletions.
132 changes: 97 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 @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
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
30 changes: 30 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 map is documented below.

The `set`, `set_list`, and `set_sensitive` blocks support:

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

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

0 comments on commit a16080d

Please sign in to comment.