From 40867318057c6ea0400994345f09ba59ce2b3d2d 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 `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. --- .changelog/1247.txt | 3 + helm/resource_release.go | 144 ++++++++++++++++++++------- helm/resource_release_test.go | 143 ++++++++++++++++++++++++++ helm/test-chart-1.2.3.tgz | Bin 3641 -> 3641 bytes website/docs/r/release.html.markdown | 31 ++++++ 5 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 .changelog/1247.txt diff --git a/.changelog/1247.txt b/.changelog/1247.txt new file mode 100644 index 0000000000..1df9dfafc0 --- /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 72c53aa436..a626b020cc 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 babad0c4b1..ff1580597c 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 4599ce057e3bbef0bbec55126d05c99821352a8a..305829f683906a9d5fc9c0606dbf1c0d00ae0040 100644 GIT binary patch delta 3296 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 delta 3296 zcmV<63?K8k9Jw5jO$=#QKo`iS-Pz!_snL;H9)HXawk#c<7kM5ekE~JYZa61W_fE~H zza%kxbhoYF@Ar>KBm3R&_nY7S;c)P1FgiRO^aqD8504)82ZzJa@DcRyn2*+$Doy00 z{&&-A3inUaNJ2lOR8;T)7DJCDN%3>w5B|eH@*-5Bq=~loKFz5Ds(^r^al#Pn)B!CZ zReuP?JTNl~ru15;5~KwI3+35;D#TjLRRe6_YNvH|I^9)cipLwG0_?$m{NuY z;0=b1NQ4EEluQ^Z;}0eX2~i3opj-=BihtDLjT1&RD&O-YS{HCCQmz9Scpi<(3jPLM{W~Gs>?$fFxyXEEo-!0h~?Gg&s>($h8M>03Q}8C5;T-IfBIln#|y8E)+tP z$w%E|=demY=X+p)1L$-z9>ytS7Z^&^DuADVu|6y(VZo*;V{11(g>pedJPAW9l7Hpw zBEgVODJr;{qn@KQj?=*bae$!aB4v@$S16Haj6BG$0?;x=i|B-Np{>8P$|X@`zKlE# zbRGaHVFOs**#4j92!>@4)3#oj1NSZL*%Y|Ypil$hQO1i^pG@%u5Qm5=m?1}LIEGYF zKC_5(-4tW(0k|Mwbd-qbq`{rJcV~fNDyd$c3a;^57hF%(4^pH_0K_}%ZwG)% z!?_{}X0=-fO1@agS^udnsp(Abv$^Y6dgZ(CwOpM19m_yC7k2D|Uhi?nM)0`M)&|AD6D0aC z!b~KVw<#Y0jC%^;OhZhTpvffyfhk^@5R>YB2{T8iqJ*#_LE2< zB!3*Phz{p2MYeL9gt=k}PyX;gt3?RitKEk0NjZEMZ$ExMJ8c<3i*FagJ5hvgH+TEc zS*<#&Rc41zDUa|P^3i4$Rpv$r{b$z9d~0jYsya694?z^KT>*{i^UEMH)ub^ehLpVOPmo&aer&D?joo=U* zrFoXYQ@0023JNBQE#bG6Fgm3e8J}la_q~6h!)@WUpZg10V8L4yqR#NWf9%Qbzjn8*cxrx5n^<&F^@Ot+#EqN>y;gB>#pX0N3 z^A9qYNXqpTI)78$zo||Gmm|9S3~Rrv&AZBg8av5Otju8ALCt1}gJNWY?0@!wwV&mv zHl?X5+FfT`)$kQ0Cd`ED&cAn{^QH604N1hY;EqXkE0YeyaXCj8`)b~^Pl8REWhR#I z2)BZ$Y&+&%hTN~rnoQP{VrSkwTTiNr<3f|etabSczokNBL-utnG2vu}QFpQoYRS+= zwlChMyL>Nc7wt6#?Y45^Hh;#qrj{)|^VBj}OXY5x$4v*{ehRQfu<9wt`6l;N2lKNt zghrTnx@CRxY!fSkNVr|XPieSz5A37%AXKT&>LcynW7L(*|PJ_4Ae;gei zHS)jX!^3_4cQ370|1t0`DUSj;wd4PuBwoH44T@vDowgOaf};nSqknW~P}V`TbYw*+ z_zIlz2)TxlvBI3JfepYQ^t#=y_a`x|uf*LlY!E*5;O@{`?LWeViDiuZZpy*7uK$jk z^&iKFFZcWZ_tL87M3O}H3Y&UUOoDGUs_!)@v(75c8o1C!G_RaBR?i%4=}2~~+`51d0u7 z(_4$5NX%+dP~(p5TTgh^W5aRg2llK~4ef6?Cm-$&b0$(L{KKQ?aeJUbHH<_3#S zY7v?Qy;}TNtNN)N94L$zC{IP45Q)(F&({NgFzWU@;Gb5i`~APC6U<-S_<`zt71C|Q zD31C{$V+30m#^Hh(ZEHj;>e)c1+O)7bE)dE&LUl0ICI)*4``)|zCU!et*f(E>OHI~^60R`r)Omh=#{<1+Mxz2i3Ni?0p=1k6*I2ZLSOvT81q!deFk3HaE#-Dgzp_ZRY!eb* zB9EGD#msWfH_W#dP`5ZXpt&K|AR8Lqf26&K_E7uwXSUY(pF8RMiU0khMNT zz0Ll!vXJj~16%FCf84PDm&4)y{O?}cPIWuYT)F8eGVtB~#VtoNcbzvkB)Y@&fB9b` zM{2cybJKlxL%}(QMm^u|2DaJ%@t{%vJ?xK;5BK(eAFb7_$0BO6<<5?kH1%oI`F@*T zGto|7kUAIQvXlM4j7r1MEY9=np?Lz*{yxcF@T?+7|H_5vg_ zqUi$d4oBra;gNR$9B1wg