Managing Kubernetes resources in Terraform: Helm provider

container ship

In the previous blog post we've covered the Kubernetes provider for Terraform. In this blog post, we look at the Helm provider.

With the Terraform provider the most time-consuming tasks were the conversion from YAML to HCL, and the need to manually split CRDs. With the Helm provider we don't need to do these tasks.

The basics

The Helm provider only has one resource called helm_release. As an example we can configure and use it to install Grafana like this:

provider "helm" {
  kubernetes {
    config_path = "~/.kube/config"
  }
}

resource "helm_release" "grafana" {
  name             = "grafana"
  repository       = "https://grafana.github.io/helm-charts"
  chart            = "grafana"
  version          = "7.0.6"
}

Applying it will create the Terraform resource:

$ terraform apply
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

And deploy the application in Kubernetes:

$ kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
grafana-5b67f46b65-pq25z   1/1     Running   0          76s

Installing Istio

In the previous post we installed Istio using the Kubernetes provider. To show the differences between both providers, we'll reinstall Istio, this time using the Helm provider.

Last time we generated the Istio YAML manifests using istioctl, which included the CRDs and Istiod deployment. To deploy the same resources with Helm, we need to install two charts:

In Terraform they can be installed as follows:

resource "helm_release" "istio_base" {
  name             = "istio-base"
  repository       = "https://istio-release.storage.googleapis.com/charts"
  chart            = "base"
  namespace        = "istio-system"
  create_namespace = true
  version          = "1.20.0"
}

resource "helm_release" "istiod" {
  name             = "istiod"
  repository       = "https://istio-release.storage.googleapis.com/charts"
  chart            = "istiod"
  namespace        = "istio-system"
  create_namespace = true
  version          = "1.20.0"

  # to install the CRDs first
  depends_on = [helm_release.istio_base]
}

We apply it:

$ terraform apply

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

And with that the installation is done:

$ kubectl get pods -n istio-system
NAME                      READY   STATUS    RESTARTS   AGE
istiod-7d4885fc54-qgk54   1/1     Running   0          37s

Compared to using the Kubernetes provider, this was much easier and faster. We didn't have to convert YAML to HCL, and then split the CRDs manually into a different file.

Chart values

There are 3 options to set custom chart values:

We'll look at each of them and see how they can be used and what the pros and cons of each method are.

HCL set blocks

As an example here's how to set custom values using set with the same Istio chart from above:

resource "helm_release" "istiod" {
  # ...

  set {
    name  = "pilot.resources.requests.cpu"
    value = "100m"
  }

  set {
    name  = "pilot.resources.requests.memory"
    value = "100Mi"
  }

  set {
    name  = "pilot.resources.limits.memory"
    value = "100Mi"
  }
}

When setting values that have {}, [], ., and , characters in them we have to double-escape them:

set {
  name  = "pilot.podAnnotations.prometheus\\.io/scrape"
  value = "\"true\""
}

The downside of this method is that it's verbose. Each value takes up 4 lines. HCL doesn't allow to merge it into a single line:

Error: Invalid single-argument block definition

  on main.tf line 28, in resource "helm_release" "istiod":
  28:   set { name  = "pilot.resources.requests.memory", value = "100Mi" }

Single-line block syntax can include only one argument definition. To define multiple
arguments, use the multi-line block syntax with one argument definition per line.

This means that setting 3 custom values for a Helm chart requires adding 12 lines to the Terraform file. This can quickly add up for larger charts and make it difficult to get a good overview of the changes.

HCL set_sensitive blocks

For sensitive values (secrets) which should not be displayed as clear text in the plan output, we have to use the HCL set_sensitive block.

There is no equivalent with when using the values attribute. There is an Issue and PR but they are abandoned.

Using the Grafana Helm chart we can set the admin password like this:

set_sensitive {
  name  = "grafana.adminPassword"
  value = local.password
}

However, there is an issue with names that contain backslashes, which leads to sensitive values being shown in clear-text. There is an Issue about this, but it has been marked as stale and closed by a bot. A PR with a proposed fix is ignored by the developers.

In the following example we use the Grafana Helm chart and set the client secret for the GitHub OAuth2 authentication:

resource "helm_release" "grafana" {
  name             = "grafana"
  repository       = "https://grafana.github.io/helm-charts"
  chart            = "grafana"
  version          = "7.0.6"
  namespace        = "grafana"
  create_namespace = true

  set {
    name = "grafana\\.ini.server.root_url"
    value = "htps://example.org"
  }

  set_sensitive {
    name = "grafana\\.ini.server.auth\\.github.client_secret"
    value = "very-secret"
  }
}

On the first apply the sensitive value will not be shown, but if we change any attribute in the resource (below I've changed the root_url) it will be displayed in clear text:

# helm_release.grafana will be updated in-place
~ resource "helm_release" "grafana" {
      id                         = "grafana"
    ~ metadata                   = [
        - {
            - app_version = "10.1.5"
            - chart       = "grafana"
            - name        = "grafana"
            - namespace   = "grafana"
            - revision    = 3
            - values      = jsonencode(
                  {
                    - "grafana.ini" = {
                        - server = {
                            - "auth.github" = {
                                - client_secret = "very-secret"
                              }
                            - root_url      = "htps://example.org"
                          }
                      }
                  }
              )
            - version     = "7.0.6"
          },
      ] -> (known after apply)
      name                       = "grafana"
      # (27 unchanged attributes hidden)

    - set {
        - name  = "grafana\\.ini.server.root_url" -> null
        - value = "htps://example.org" -> null
      }
    + set {
        + name  = "grafana\\.ini.server.root_url"
        + value = "htps://www.grafana.com"
      }

      # (1 unchanged block hidden)
  }

You can see the very-secret value being leaked as clear text in the output.

YAML in values attribute

As an alternative to using HCL set blocks, we can specify values in YAML or JSON by setting the values argument. In the examples below I'll focus on YAML.

Most commonly an HCL heredoc string is used. As an example we use the Istio Helm chart:

values = [<<EOT
pilot:
  resources:
    requests:
      cpu: "100m"
      memory: "100Mi"
    limits:
      memory: "100Mi"
EOT
]

This is more compact and readable than the HCL set blocks. It also allows for easy copying and pasting of examples from Helm chart documentation or the default values files, which are written in YAML.

However, a drawback of this approach is the absence of linting, syntax highlighting, or schema validation in our editor, as the attribute is a HCL text field. This can lead to common errors like indentation errors.

One solution is to read the values from a separate YAML file:

values = [file("${path.module}/values.yaml")]

This allows us to open the YAML file in our editor and get language support. Another benefit is better debugging when locally rendering the Helm chart since we can use the same values as Terraform.

The downside of this method is that it doesn't allow for substitutions (using variables in the YAML file). This is important when we want to use the output of a Terraform resource as an input to our Helm chart resource.

To work around this we can render the YAML file as a template and pass in the values in a variable.

In the following example we create a DNS record with Cloudflare and then use the hostname output in the Grafana Helm chart YAML values:

resource "cloudflare_record" "cluster" {
  zone_id = "000"
  name    = "cluster"
  value   = "192.0.2.1"
  type    = "A"
}

resource "helm_release" "grafana" {
  name             = "grafana"
  repository       = "https://grafana.github.io/helm-charts"
  chart            = "grafana"
  version          = "7.0.0"

  values = [templatefile("values.yaml", {
    root_url = "https://${cloudflare_record.cluster.hostname}"
  })]
}

The values.yaml file:

grafana.ini:
  server:
    root_url: ${root_url}

The drawback is that we now have to manage two files and coordinate changes between the HCL configuration file and the associated YAML file, which can increase the likelihood of errors.

HCL objects instead of text

Another way to set values is to use HCL objects and encode them using jsonencode or yamlencode. It's less verbose than set blocks, but slightly more verbose than YAML. The benefit is that it lets us keep everything in one file, allows for substitutions and have editor language support.

The following example sets the resource requests/limits for the Istio Helm chart:

values = [
  jsonencode({
    pilot = {
      resources = {
        requests = {
            cpu = "100m"
            memory = "100Mi"
          }
          limits = {
            memory = "100Mi"
          }
      }
    }
  })
]

The drawbacks are the same as for the other HCL blocks. The biggest issue is that we have to convert YAML code from the documentation or examples into HCL objects. This additional step can slow down the development speed and also lead to errors when doing the conversion.

Diff output

The most notable difference between the above methods for setting values, is the diff output when running a terraform plan.

In general the HCL set blocks will give a clear view of what has changed. The plan output when changing the memory limit value looks like this:

- set {
    - name  = "pilot.resources.limits.memory" -> null
    - value = "100Mi" -> null
  }
+ set {
    + name  = "pilot.resources.limits.memory"
    + value = "150Mi"
  }

However, the values attribute is treated as plain text and changing any value will always mark the whole attribute as changed. When changing the memory limit value the output looks like this:

~ values = [
  - <<-EOT
      pilot:
        resources:
          requests:
            cpu: "100m"
            memory: "100Mi"
          limits:
            memory: "100Mi"
  EOT,
  + <<-EOT
      pilot:
        resources:
          requests:
            cpu: "100m"
            memory: "100Mi"
          limits:
            memory: "150Mi"
  EOT,
]

When reviewing code, this makes it difficult to see what the actual change was. Projects that use a pull request automation tool like Atlantis, which posts the terraform plan output as a comment, will be less useful. There is an open Issue discussing this, but it seems to be ignored.

These are all the ways to set custom values (that I could find). Below I'll go into two issues that I've found when using the provider.

Viewing rendered Kubernetes resources

It's not possible to see the manifests of the generated Kubernetes resources. The provider has an experimental flag to show them, but in my testing it didn't work for any chart and broke the deployments.

First step is to enable it in the provider:

provider "helm" {
  kubernetes {
    config_path = "~/.kube/config"
  }

  experiments {
    manifest = true
  }
}

Trying to deploy the Grafana Helm chart resulted in the following error:

$ terraform plan

│ Error: Provider produced inconsistent final plan
│ When expanding the plan for helm_release.grafana to include new values learned so far
│ during apply, provider "registry.terraform.io/hashicorp/helm" produced an invalid new
│ value for .manifest: was
│ cty.StringVal("

### ... Truncated output for readability ... ###

│ This is a bug in the provider, which should be reported in the provider's own issue
│ tracker.

The issue happened with all Helm charts I tested.

Seeing the generated Kubernetes resources is important, especially for security. Without this feature we can only hope/trust that the Helm chart authors won't include any Kubernetes resources that could be a security risk (such as an RBAC Role with too many permissions).

Fixing issues when client aborts

When a client has to abort an apply (for example due to a lost network connection) the resource will be in pending-upgrade state and not allow to re-run an apply.

For example, if we installed Argo CD using the Helm provider like this:

resource "helm_release" "argocd" {
  name             = "argocd"
  repository       = "https://argoproj.github.io/argo-helm"
  chart            = "argo-cd"
  version          = "5.51.0"
  namespace        = "default"
}

Then update the version to 5.51.1 and press Ctrl+C during the apply process:

helm_release.argocd: Still modifying... [id=argocd, 10s elapsed]
^C
Two interrupts received. Exiting immediately. Note that data loss may have occurred.

│ Error: operation canceled

Next time we run apply we get the following error:

│ Error: another operation (install/upgrade/rollback) is in progress
│   with helm_release.argocd,
│   on main.tf line 29, in resource "helm_release" "argocd":
│   29: resource "helm_release" "argocd" {

We can fix this by deleting the latest secret created for this release by Helm:

$ kubectl get secret
NAME                           TYPE                 DATA   AGE
sh.helm.release.v1.argocd.v1   helm.sh/release.v1   1      45m
sh.helm.release.v1.argocd.v2   helm.sh/release.v1   1      40m
sh.helm.release.v1.argocd.v3   helm.sh/release.v1   1      32m

$ kubectl delete secret sh.helm.release.v1.argocd.v3

After that the apply process will work again. A similar error is Error: cannot re-use a name that is still in use which can be fixed in the same way.

Conclusion

In this blog post we've seen how to use the Terraform Helm provider. We covered the basics on how to use it and showed how to set custom Helm chart values in different ways.

In general the provider makes the deployment of Kubernetes resources much easier than the Kubernetes provider shown in the last blog post. We don't have to manually convert YAML to HCL, and don't have to handle CRDs in a special way.

The provider has disadvantages and bugs. It's not possible to see the generated Kubernetes manifests (the manifest experiment flag did not work). This makes it hard to troubleshoot issues and understand configurations. Another issue is that the set_sensitive block is leaking secrets in the terraform plan output.

The project development is not very active. All of the issues above have open Issues and PRs but have been ignored for years.

In conclusion I can't recommend using the provider for anything beyond temporary testing environments. Alternative solutions such as Argo CD and Flux are currently the better choices.