Skip to main content

Helm Provisioner

Massdriver provisioner for managing resources with Helm.

Structure

This provisioner expects the path to be the base directory of a helm chart. This means it should contain the Chart.yaml and values.yaml files at a minimium.

Tooling

The following tools are included in this provisioner:

  • Checkov: Included to scan helm charts for common policy and compliance violations.

Configuration

The following configuration options are available:

Configuration OptionTypeDefaultDescription
kubernetes_clusterobject.connections.kubernetes_clusterjq path to a massdriver/kubernetes-cluster connection for authentication to Kubernetes
namespacestring"default"Kubernetes namespace to install the chart into. Defaults to the default namespace
release_namestring(package name)Specifies the release name for the helm chart. Defaults to the Massdriver package name if not specified.
checkov.enablebooleantrueEnables Checkov policy evaluation. If false, Checkov will not be run.
checkov.quietbooleantrueOnly display failed checks if true (adds the --quiet flag).
checkov.halt_on_failurebooleanfalseHalt provisioning run and mark deployment as failed on a policy failure (removes the --soft-fail flag).

Inputs

Helm accepts inputs via YAML formatted files, the primary one being values.yaml, though additional files can be specified. To adhere to this standard, this provisioner will convert the params.json, connections.json, envs.json and secrets.json files into YAML format before passing them to Helm.

If modifications to params, connections, envs or secrets are required to fit the predefined values of a helm chart, this provisioner supports JQ templates for restructuring the original JSON files before they are converted to YAML. These JQ template files should exist in the base directory of the helm chart and be named params.jq, connections.jq, envs.jq and secrets.jq. The format of these files should be a JQ template which accepts the params.json, connections.json, envs.json and secrets.json files as inputs and restructures them according to the JQ template. These files aren't required by the provisioner so if any of them is missing the corresponding JSON file will be left unmodified before being converted to YAML, with the exception of envs.json and secrets.json which will be nested under a top level envs key and secrets key, respectively.

To demonstrate, let's say there is a Helm bundle with some configuration values and a dependency on a Postgres database. The values.yaml file would be something like this:

commonLabels: {}

foo:
bar: "baz"
count: 4

postgres:
hostname: ""
port: 5432
user: "root"
password: ""
version: "12.1"

deployment:
envs: {}

To properly set these values in a Massdriver bundle, we likely would want the commonLabels value to come from md_metadata.default_tags, the foo value to come from params, and the postgres block to come from a connection. That means this bundle would require a massdriver/postgres-authentication connection named database. Since this is a Helm chart, it will also need a massdriver/kubernetes-cluster connection to provide authentication to the kubernetes cluster the chart is being installed into. The massdriver.yaml file would look something like:

app:
envs:
LOG_LEVEL: '@text "debug"'

params:
required:
- foo
properties:
foo:
required:
- bar
- count
properties:
bar:
type: string
count:
type: integer

connections:
required:
- kubernetes_cluster
- database
properties:
kubernetes_cluster:
$ref: massdriver/kubernetes-cluster
database:
$ref: massdriver/postgresql-authentication

params.jq

Let's start with the params.json, which will look like:

{
"foo": {
"bar": "bizzle",
"count": 10
},
"md_metadata": {
"default_tags": {
"managed-by": "massdriver",
"md-manifest": "somebundle",
"md-package": "proj-env-somebundle-0000",
"md-project": "proj",
"md-target": "env"
},
"name_prefix": "proj-env-somebundle-0000"
...
}
}

The foo object can be passed directly to helm chart since it already matches the structure in values.yaml. However, we want set commonLabels to md_metadata.default_tags, and we'd also like to remove the rest of md_metadata from the params since it isn't expected by the helm chart and could cause issues in the unlikely event there is a naming collision with an existing value named md_metadata. This means the params.jq file should contain:

. += {"commonLabels": .md_metadata.default_tags} | del(.md_metadata)

This JQ command takes all of the original JSON and adds the field commonLabels which is set to .md_metadata.default_tags. It then deletes the entire .md_metadata block from the params. The resulting params.yaml after this JQ restructuring and conversion to YAML would be:

commonLabels:
managed-by: "massdriver",
md-manifest: "somebundle",
md-package: "proj-env-somebundle-0000",
md-project: "proj",
md-target: "env"
foo:
bar: "bizzle"
count: 10

This fits what the helm chart expects. Now let's focus on connections.

connections.jq

With the database and kubernetes_cluster connection, the connections.json file would be roughly equivalent to:

{
"kubernetes_cluster": {
"data": {
"authentication": {
"cluster": {
"certificate-authority-data": "...",
"server": "https://my.kubernetes.cluster.com"
},
"user": {
"token": "..."
}
}
},
"specs": {
"kubernetes": {
"version": "1.27"
}
}
},
"database": {
"data": {
"authentication": {
"hostname": "the.postgres.database",
"password": "s3cr3tV@lue",
"port": 5432,
"username": "admin"
}
},
"specs": {
"rdbms": {
"version": "14.6"
}
}
}
}

While this connections.json file contains all the necessary data for the postgres configuration, it isn't formatted properly and there is significantly more data than needed by the chart. The entire kubernetes_cluster block isn't used by the Helm chart at all (it is only needed to provide the provisioner with authentication information to the Kubernetes cluster). Let's create a connections.jq file to remove the kubernetes_cluster connection, and restructure the database connection so that it fits the helm chart's expected postgres block.

{
"postgres": {
"hostname": .database.data.authentication.hostname,
"port": .database.data.authentication.port,
"user": .database.data.authentication.username,
"password": .database.data.authentication.password,
"version": .database.specs.version
}
}

This will restructure the data so that the connections.yaml file passed to helm will be:

postgres:
hostname: "the.postgres.database"
port: 5432
user: "admin"
password: "s3cr3tV@lue"
version: "14.6"

This converts the data in connections.json to match the expected fields in values.yaml.

envs.jq

The last file to address is the environment variables. The envs.json file will look like:

{
"LOG_LEVEL": "debug"
}

There are two problems here. First, by default this provisioner will place the envs under a top level envs block, while the helm chart is expecting them under deployment.envs. Second, most Helm charts expect the environment variables to be an array of objects with name and values keys, as opposed to a map. So, let's convert our envs.json into an array of objects, and move it under a deployment.envs path.

{
deployment: {
envs: [to_entries[] | {name: .key, value: .value}]
}
}

This will restructure the data so that the envs.yaml file passed to helm will be:

deployment:
envs:
- name: "LOG_LEVEL"
value: "debug"

This converts the data in envs.json to match the expected field in values.yaml.

Artifacts

After every provision, this provider will scan the template directory for files matching the pattern artifact_<name>.jq. If a file matching this pattern is present, it will be used as a JQ template to render and publish a Massdriver artifact. The inputs to the JQ template will be a JSON object with the params, connections, envs, secrets and helm manifests as top level fields. Note that the params, connections, envs and secrets will contain the original content of params.json, connections.json, envs.json and secrets.json without any modifications that may have been applied through params.jq, connections.jq, envs.jq and secrets.jq. The outputs field will contain the result of helm get manifest for the chart after it is installed. Since the output of helm get manifest is list of yaml files, the outputs block will be a JSON array with each element being a JSON object of an individual kubernetes resource manifest.

{
"params": {
...
},
"connections": {
...
},
"envs": {
...
},
"secrets": {
...
},
"outputs": [
...
]
}

To demonstrate, let's say there is a Helm bundle with a single param (namespace), a single connection (kubernetes_cluster), and a single artifact (api_endpoint). The massdriver.yaml would be similar to:

params:
required:
- namespace
properties:
namespace:
type: string

connections:
required:
- kubernetes_cluster
properties:
kubernetes_cluster:
$ref: massdriver/kubernetes-cluster

artifacts:
required:
- api_endpoint
properties:
api_endpoint:
$ref: massdriver/api

Since the artifact is named api_endpoint a file named artifact_api_endpoint.jq would need to be in the template directory and the provisioner would use this file as a JQ template, passing the params, connections and outputs to it. For this example, let's say the helm chart will produce two manifests: a deployment, and a service. The output of helm get manifest would be something like:

---
apiVersion: v1
kind: Service
metadata:
name: helm-prov-example-0000
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
protocol: TCP
name: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: helm-prov-example-0000
spec:
template:
spec:
containers:
- name: nginx
image: "nginx:latest"
imagePullPolicy: Always

In this case, the input to the artifact_api_endpoint.jq template file would be:

{
"params": {
"namespace": "foo"
},
"connections": {
"kubernetes_cluster": {
"data": {
"authentication": {
"cluster": {
"certificate-authority-data": "...",
"server": "https://my.kubernetes.cluster.com"
},
"user": {
"token": "..."
}
}
},
"specs": {
"kubernetes": {
"version": "1.27"
}
}
}
},
"envs": {
"LOG_LEVEL": "debug"
},
"secrets": {},
"outputs": [
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "helm-prov-example-0000"
},
"spec": {
"type": "ClusterIP",
"ports": [{
"port": 80,
"targetPort": 80,
"protocol": "TCP",
"name": "http"
}]
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "helm-prov-example-0000"
},
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "nginx",
"image": "nginx:latest",
"imagePullPolicy": "Always"
}
]
}
}
}
}
]
}

We need to build an API artifact from these inputs. We'll use Kubernetes built in DNS pattern for services to build the API endpoint from the service name, namespace and port. Thus, the artifact_api_endpoint.jq file would be:

{
"data": {
"api": {
"hostname": "\(.outputs[] | select(.kind == "Service" and .apiVersion == "v1") | .metadata.name).\(.params.namespace).svc.cluster.local",
"port": (.outputs[] | select(.kind == "Service" and .apiVersion == "v1") | .spec.ports[] | select(.name == "http") | .port),
"protocol": "http"
}
},
"specs": {
"api": {
"version": "1.0.0"
}
}
}

In this template, we are using the select function in JQ to find the proper manifest and extract the relevant values to build a properly formatted artifact.