Skip to content

microsoftexpert/tf-mod-helm-release

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

⎈ Helm Release Terraform Module

Installs and manages the lifecycle of a single Helm chart release against a Kubernetes cluster, wrapping hashicorp/helm ~> 3.2's helm_release resource.

Terraform Helm Provider Module Version Module Type Resources Posture


🧩 Overview

  • ⎈ Installs and manages the lifecycle of one Helm chart release in a target Kubernetes namespace.
  • 🔒 Applies our secure-by-default posture unconditionally — atomic rollback, cleanup_on_fail, a mandatory explicit namespace, and hard separation of secret-shaped values into set_sensitive_values.
  • 🧬 Wraps the entirety of helm_release's current argument surface with deeply-typed object schemas — no any, no untyped map.
  • 🚫 Never accepts cluster-authentication material; the calling root module owns the helm/kubernetes provider block.

💡 Why it matters: on AKS, a helm_release apply executes arbitrary chart hooks inside a live, regulated cluster. This module pushes as much correctness as possible into terraform validate — a malformed input should fail at plan time, not surprise an operator mid-apply.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits

flowchart LR
 AKS["Azure AKS module\noutputs: cluster endpoint"]:::neutral
 KV["Key Vault / Managed Identity\nmodule outputs"]:::neutral
 PROV["Caller-owned provider config\n(helm + kubernetes blocks)"]:::neutral
 THIS["tf-mod-helm-release\n(helm_release.this)"]:::keystone
 REL["Running Helm release\nin the target AKS namespace"]:::aks

 AKS -->|"cluster endpoint + credentials"| PROV
 KV -->|"secret values for set_sensitive_values"| THIS
 PROV -->|"configures"| THIS
 THIS -->|"installs / upgrades"| REL

 classDef keystone fill:#0F1689,color:#fff
 classDef aks fill:#277A9F,color:#fff
 classDef neutral fill:#E5E7EB,color:#111
Loading

This provider has no sibling tf-mod-helm-* family tree above it — the diagram above shows the cross-provider seam instead: the Azure AKS module and Key Vault/Managed Identity modules feed the caller's provider configuration and this module's inputs, never this module's variables directly.


🧬 What this builds

flowchart LR
 subgraph INPUTS["Inputs"]
 ID["name, namespace, chart,\nrepository, chart_version"]:::neutral
 VAL["values_yaml, set_values,\nset_list_values, set_sensitive_values"]:::neutral
 LC["atomic, cleanup_on_fail, wait,\ncreate_namespace, timeout,..."]:::neutral
 end

 THIS["helm_release.this"]:::keystone

 subgraph OUTPUTS["Outputs"]
 O1["id, name, namespace"]:::neutral
 O2["status"]:::neutral
 O3["metadata, manifest, resources\n(sensitive)"]:::neutral
 end

 ID -->|"identity + chart source"| THIS
 VAL -->|"values injection"| THIS
 LC -->|"lifecycle / safety knobs"| THIS
 THIS -->|"emits"| O1
 THIS -->|"emits"| O2
 THIS -->|"emits"| O3

 classDef keystone fill:#0F1689,color:#fff
 classDef neutral fill:#E5E7EB,color:#111
Loading

Resource inventory: exactly one resource, helm_release.this — this provider exposes no composite/child-resource shape.


✅ Provider / Versions

Component Requirement
Terraform >= 1.12.0
hashicorp/helm provider ~> 3.2 (verified live against v3.2.0)
Provider block None — the calling root module configures provider "helm" {} / provider "kubernetes" {}; this module never accepts cluster-connection variables

Schema notes that bite:

  • set, set_list, and set_sensitive are list-of-nested-objects in provider v3.x, assigned with = syntax (set = [ { name = "...", value = "..." } ]) — not the v2.x repeatable set {} HCL block. Confirmed per the live provider schema against v3.2.0.
  • timeout defaults to 300 seconds and is a plain top-level optional(number, 300) argument, not a nested timeouts {} block — this provider has no house-wide "universal tail."
  • version is a reserved variable name inside Terraform module blocks; this module exposes it as chart_version instead.
  • repository is not persisted as release metadata by Helm — a terraform import will not populate this module's repository variable, and re-importing will not restore it.

🔑 Required Kubernetes RBAC / Cluster Permissions

The identity running terraform plan/terraform apply for this module needs, at minimum:

  • Namespace-scoped: get/list/watch/create/update/patch/delete on every object kind the target chart deploys, via a Role/RoleBinding in the release's namespace.
  • Namespace-scoped, release-history storage: CRUD on Secret objects in the release namespace (the provider's default Helm storage backend).
  • Cluster-scoped (flagged, not granted by default): if the chart installs CRDs or any cluster-scoped object, the identity additionally needs a ClusterRole/ClusterRoleBinding. This module does not grant cluster-scoped access itself and cannot detect the need for it from the caller's inputs — document per-chart.
  • If create_namespace = true, the identity additionally needs create on the cluster-scoped namespaces resource.

Kubernetes & Helm Prerequisites

  • Kubernetes server version: 1.14.0 or higher (confirmed via the provider's overview docs).
  • Chart repository/registry reachability: the CI runner or operator workstation running terraform plan/apply must have outbound network reachability to whatever repository value is supplied (HTTPS index or OCI registry), or none if chart is a local path.
  • CRDs: any CRD a chart's templates assume already exists must be installed ahead of apply.
  • Namespace existence: with the default create_namespace = false, namespace must already exist (provisioned by the platform team) before apply.

📁 Module Structure

tf-mod-helm-release/
├── providers.tf # Terraform + provider version pin, no provider block
├── variables.tf # Full helm_release argument surface, secure-by-default
├── main.tf # helm_release.this — the module's only resource
├── outputs.tf # id first, then name/namespace/status/metadata/manifest/resources
├── README.md
├── SCOPE.md
└── examples/
 └── basic/
 └── main.tf

⚙️ Quick Start

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

module "nginx_ingress" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "nginx-ingress-controller"
  namespace  = "ingress"
  chart      = "nginx-ingress-controller"
  repository = "https://charts.bitnami.com/bitnami"

  set_values = [
    {
      name  = "service.type"
      value = "ClusterIP"
    }
  ]
}

The caller configures the helm/kubernetes provider block and Azure AD/AKS authentication (kubelogin, workload identity, or az aks get-credentials) — this module never sees any of it.


🔌 Cross-Module Contract

Consumes

Input Type Source module
AKS cluster API endpoint + credentials provider configuration (caller-owned) Azure AKS module or az aks get-credentials
Key Vault secret values for set_sensitive_values variable input Azure Key Vault module outputs
Managed Identity client ID (workload identity annotations) variable input, via set_values/values_yaml Azure Managed Identity module outputs
Namespace pre-existence (when create_namespace = false) out-of-band cluster state Platform team's namespace-provisioning process

Emits

Output Description Sensitive
id Release identity string (namespace/name) No
name Release name as applied No
namespace Namespace the release was installed into No
status Current Helm release status No
metadata Full deployed-release metadata (chart, revision, values, notes) Yes
manifest Rendered manifest JSON (populated only if the caller enables experiments.manifest) Yes
resources Rendered manifests JSON keyed by resource (same experiment gating) Yes

📚 Example Library

1 · Minimal HTTPS-repository install
module "redis" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "redis"
  namespace  = "caching"
  chart      = "redis"
  repository = "https://charts.bitnami.com/bitnami"
}

ℹ️ The empty-beyond-required call still gets the full secure-by-default posture: atomic = true, cleanup_on_fail = true, wait = true, create_namespace = false.

2 · Local chart path
module "internal_service" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name      = "internal-service"
  namespace = "platform"
  chart     = "./charts/internal-service"
}

💡 No repository needed when chart is already a local filesystem path on the runner.

3 · Direct.tgz chart URL
module "redis_pinned_tgz" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name  = "redis"
  chart = "https://charts.bitnami.com/bitnami/redis-10.7.16.tgz"

  namespace = "caching"
}
4 · OCI registry chart source
provider "helm" {
  kubernetes = {
    config_path = "~/.kube/config"
  }

  registries = [
    {
      url      = "oci://private.registry.casey.example"
      username = "REPLACE_ME"
      password = "REPLACE_ME"
    }
  ]
}

module "internal_chart_oci" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name          = "internal-chart"
  namespace     = "platform"
  repository    = "oci://private.registry.casey.example/charts"
  chart         = "internal-chart"
  chart_version = "1.4.2"
}

🔒 OCI registry credentials are a provider-block concern (registries on provider "helm" {}), never a variable on this module.

5 · Raw values_yaml heredoc
module "grafana" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "grafana"
  namespace  = "observability"
  chart      = "grafana"
  repository = "https://grafana.github.io/helm-charts"

  values_yaml = [
    <<-YAML
 persistence:
 enabled: true
 size: 10Gi
 service:
 type: ClusterIP
 YAML
  ]
}

⚠️ Values placed in values_yaml appear in plan output, state, and CI logs in plaintext — never place secret material here.

6 · set_values for scalar overrides
module "nginx_ingress_set" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "nginx-ingress-controller"
  namespace  = "ingress"
  chart      = "nginx-ingress-controller"
  repository = "https://charts.bitnami.com/bitnami"

  set_values = [
    { name = "cluster.enabled", value = "true" },
    { name = "metrics.enabled", value = "true" },
    { name = "service.annotations.prometheus\\.io/port", value = "9127", type = "string" },
  ]
}
7 · set_list_values for list-typed overrides
module "vault_agent" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "vault-agent"
  namespace  = "platform"
  chart      = "vault"
  repository = "https://helm.releases.hashicorp.com"

  set_list_values = [
    { name = "server.extraArgs", value = ["-log-level=info", "-log-format=json"] },
  ]
}
8 · set_sensitive_values for secret-shaped material
module "app_with_db_secret" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name      = "internal-app"
  namespace = "platform"
  chart     = "./charts/internal-app"

  set_sensitive_values = [
    { name = "database.password", value = "<from-key-vault>" },
  ]
}

🔒 Attempting set_values = [{ name = "database.password", value = "..." }] instead fails terraform validate — the secret-shaped-name check rejects it and points the caller at set_sensitive_values.

9 · Chart signature verification
module "verified_chart" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "verified-chart"
  namespace  = "platform"
  chart      = "verified-chart"
  repository = "https://charts.example.com"

  verify  = true
  keyring = "/etc/casey/helm/pubring.gpg"
}

⚠️ Setting verify = true without keyring fails validation at plan time — this module never allows an unverifiable verification attempt to silently pass.

10 · Namespace creation as a documented exception
module "ephemeral_preview" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name             = "preview-app"
  namespace        = "pr-1234-preview"
  chart            = "./charts/preview-app"
  create_namespace = true # Exception: ephemeral PR-preview namespace, not platform-provisioned.
}
11 · CRD-skipping for an operator-managed CRD chart
module "cert_manager" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "cert-manager"
  namespace  = "cert-manager"
  chart      = "cert-manager"
  repository = "https://charts.jetstack.io"

  skip_crds = true # CRDs are managed by a separate, cluster-scoped apply step.
}

⚠️ Cluster-scoped CRD management outside this module requires its own RBAC — see this module's SCOPE.md "Required Kubernetes RBAC" section.

12 · upgrade_install adoption of a pre-existing release
module "adopted_release" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name            = "legacy-app"
  namespace       = "legacy"
  chart           = "legacy-app"
  repository      = "https://charts.example.com"
  chart_version   = "2.3.1"
  upgrade_install = true
}

⚠️ The first plan after adopting a pre-existing release this way will show as changed even when the deployed values are identical — there is no prior Terraform state to diff against. Expected, not a bug. See 🧠 Architecture Notes below.

13 · Multi-environment for_each composition
locals {
  environments = {
    dev  = { namespace = "app-dev", chart_version = "1.2.0" }
    test = { namespace = "app-test", chart_version = "1.2.0" }
    prod = { namespace = "app-prod", chart_version = "1.1.5" }
  }
}

module "app_per_env" {
  source   = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"
  for_each = local.environments

  name          = "internal-app"
  namespace     = each.value.namespace
  chart         = "internal-app"
  repository    = "https://charts.example.com"
  chart_version = each.value.chart_version
}

💡 for_each over a keyed map, never count — adding or removing an environment never re-indexes the others.

14 · Workload-identity-scoped Key Vault value injection
data "azurerm_key_vault_secret" "app_db_password" {
  name         = "app-db-password"
  key_vault_id = var.key_vault_id
}

module "app_with_workload_identity" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name      = "internal-app"
  namespace = "platform"
  chart     = "./charts/internal-app"

  set_values = [
    { name = "serviceAccount.annotations.azure\\.workload\\.identity/client-id", value = var.managed_identity_client_id },
  ]

  set_sensitive_values = [
    { name = "database.password", value = data.azurerm_key_vault_secret.app_db_password.value },
  ]
}

🔒 The Managed Identity client ID is not itself secret and is set via set_values; the Key Vault-sourced database password must go through set_sensitive_values.

15 · 🏗️ End-to-end composition — AKS module → provider → release
module "aks" {
  source = "git::https://github.com/microsoftexpert/tf-mod-azure-aks-cluster.git?ref=v1.0.0"
  #... AKS cluster inputs...
}

provider "helm" {
  kubernetes = {
    host                   = module.aks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.aks.cluster_ca_certificate)
    exec = {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "kubelogin"
      args        = ["get-token", "--login", "workloadidentity", "--server-id", module.aks.oidc_issuer_url]
    }
  }
}

module "platform_ingress" {
  source = "git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0"

  name       = "nginx-ingress-controller"
  namespace  = "ingress"
  chart      = "nginx-ingress-controller"
  repository = "https://charts.bitnami.com/bitnami"

  set_sensitive_values = [
    { name = "controller.extraEnvVars[0].value", value = "<from-key-vault>" },
  ]
}

🏗️ This is the mandatory end-to-end shape: an Azure AKS module's outputs feed the caller's provider "helm" {} block (via kubelogin's workload-identity exec plugin), and this module's inputs are wired independently — this module itself never sees the AKS module's outputs directly.


📥 Inputs

Grouped summary:

Group Variables
Identity name, namespace, description
Chart source chart, repository, repository_ca_file, repository_cert_file, repository_key_file, repository_username, repository_password, chart_version, devel, verify, keyring
Values injection values_yaml, set_values, set_list_values, set_sensitive_values
Lifecycle / safety atomic, cleanup_on_fail, wait, wait_for_jobs, create_namespace, disable_openapi_validation, disable_webhooks, disable_crd_hooks, skip_crds, dependency_update, upgrade_install, take_ownership, timeout, max_history, reset_values, reuse_values, force_update, recreate_pods, render_subchart_notes, replace, pass_credentials
Full object schemas
variable "set_values" {
  type = list(object({
    name  = string
    value = optional(string)
    type  = optional(string)
  }))
  default = []
}

variable "set_list_values" {
  type = list(object({
    name  = string
    value = list(string)
  }))
  default = []
}

variable "set_sensitive_values" {
  type = list(object({
    name  = string
    value = string
    type  = optional(string)
  }))
  default   = []
  sensitive = true
}

See variables.tf for the complete set, including every scalar's description heredoc and validation {} block.


🧾 Outputs

Output Description Sensitive
id Release identity string (namespace/name) No
name Release name as applied No
namespace Namespace the release was installed into No
status Current Helm release status No
metadata Full deployed-release metadata Yes
manifest Rendered manifest JSON (experiment-gated) Yes
resources Rendered manifests JSON keyed by resource (experiment-gated) Yes

🧠 Architecture Notes

  • v2 → v3 syntax migration. set, set_list, and set_sensitive are list-of-nested-objects in provider v3.x, assigned via =, not the v2.x repeatable set {} HCL block. A v2-era example copied into a caller's code will fail terraform validate against this module's ~> 3.2 pin.
  • upgrade_install first-plan-shows-changed. Enabling upgrade_install = true against a release that already exists in the cluster but isn't tracked in Terraform state produces a "changed" plan on the very first run, even with identical values — there is no prior state to diff against. Expected, not a bug.
  • Chart hooks execute outside Terraform's visibility. pre-install/post-upgrade/etc. hooks run arbitrary logic inside the cluster during apply; terraform plan cannot preview hook behavior. Reinforces the house "no apply in this authoring pipeline" rule.
  • repository is not persisted as release metadata. A terraform import of an existing release will not populate repository, and re-importing does not restore it.

🧱 Design Principles

Default value Upstream provider default What it prevents
atomic true false A failed install/upgrade left half-applied
cleanup_on_fail true false Orphaned resources from a failed upgrade
wait true true A release reporting success before workloads are healthy
namespace required, no default defaults to "default" Accidental installs into the cluster's default namespace
create_namespace false false Silent namespace creation masking a missing platform-provisioned namespace
disable_openapi_validation false false Malformed manifests reaching the API server unchecked
verify + keyring pairing keyring required when verify = true no pairing enforced Unsigned/unverifiable charts silently deploying
Secret-shaped set_values/set_list_values names rejected by validation {} not enforced Secrets landing in plan output, state, and CI logs in plaintext
set_sensitive_values entire variable sensitive = true set_sensitive masked at the argument level Secret values echoing in plan/apply output
dependency_update false false Uncontrolled network calls to chart-dependency repositories
timeout 300 (provider default) 300 Long-running operations blocking CI indefinitely if raised without review

🚀 Runbook

cd tf-mod-helm-release
terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module source with an immutable tag, never a branch:

git::https://github.com/microsoftexpert/tf-mod-helm-release.git?ref=v1.0.0

terraform plan/terraform apply are run by a human, from CI, after code review — never by this authoring process.


🧪 Testing

terraform init -backend=false / validate / fmt -check prove schema and syntax correctness — type mismatches, missing required arguments, malformed HCL, and this module's validation {} blocks (secret-shaped name rejection, verify/keyring pairing, type enum checks). They do not prove that a chart will actually render or install successfully against a live cluster — that requires a real terraform plan/apply against a live AKS cluster, run by a human outside this pipeline.


💬 Example Output

$ terraform output

id = "ingress/nginx-ingress-controller"
name = "nginx-ingress-controller"
namespace = "ingress"
status = "deployed"
metadata = <sensitive>
manifest = <sensitive>
resources = <sensitive>

🔍 Troubleshooting

Symptom Cause Fix
First plan after upgrade_install = true shows changed even though nothing was actually changed in the cluster No prior Terraform state exists to diff an adopted release against Expected on the first plan only; confirm with helm get values that the deployed values match, then proceed
terraform validate fails on a set { name =... value =... } block v2.x-era HCL block syntax copied from an old example, incompatible with this module's ~> 3.2 pin Rewrite as set_values = [{ name = "...", value = "..." }] (list-of-objects assignment)
terraform validate fails with "set_values contains a secret-shaped name" A values path like database.password was passed through set_values Move the entry to set_sensitive_values
terraform validate fails with "keyring must be set whenever verify = true" verify = true was set without keyring Supply a keyring path, or leave verify = false
A terraform import'd release shows repository = null after import Helm does not persist repository as release metadata Manually set repository in the module call after import; this is a known provider limitation, not a bug in this module
manifest/resources outputs are always null The caller's provider configuration did not enable experiments = { manifest = true } This is a provider-block, caller-owned setting — enable it there if the rendered manifest output is needed

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."

Packages

 
 
 

Contributors

Languages