Logo

Supercharge Helm chart development with DaggerUse Dagger to achieve isolation and reproducibility with Helm

Sági-Kazár Márk
Sági-Kazár Márk@sagikazarmark
cover

With infrastructure becoming increasingly complex, achieving the right level of isolation and reproducibility is crucial for any software development environment. When it’s not the case, issues like “works on my machine” or “push-and-pray” can make life much more challenging.

Helm and Kubernetes are no exception: ensuring you use the correct versions and settings across all environments can be challenging. If you’ve ever had to push multiple CI or pipeline changes, hoping it would turn green, you know exactly what I mean.

In this post, I’ll show you how to use Dagger to achieve isolation and reproducibility with Helm (including running linters, tests, and pushing to a registry) so you can run everything on your machine exactly as it will run in CI.

You can find the final version of the code here.

Preparations

Make sure to install the latest version of Dagger (0.13.3 at the time of writing) and Docker on your machine. Installing your chosen language environment can also improve user experience and IDE integration, but aside from that (and Git), you won’t need any other dependencies on your machine—one of the great advantages of using Dagger.

As a first step, set up a new project and a Dagger module:

mkdir demo-dagger-helm
cd demo-dagger-helm
dagger init --sdk go --source .dagger --name demo

After initializing the module, replace the contents of .dagger/main.go with the following:

.dagger/main.go
package main
 
import (
	"dagger/demo/internal/dagger"
)
 
type Demo struct {
	// +private
	Source *dagger.Directory
}
 
func New(
	// Project source directory.
	//
	// +defaultPath="/"
	source *dagger.Directory,
) *Demo {
	return &Demo{
		Source: source,
	}
}

We will add more code to that file as we go through the tutorial.

Next, generate a new chart using Helm’s create command. You can use my Helm module for Dagger:

dagger -m github.com/sagikazarmark/daggerverse/[email protected] call \
  create --name demo-dagger-helm \
  directory \
  export --wipe --path deploy/charts/demo-dagger-helm

Alternatively, you can try Dagger’s new core command (available since 0.13.0) to run the official Helm container image directly:

dagger core container \
  from --address alpine/helm:3.16.1 \
  with-workdir --path /work \
  with-exec --args 'helm,create,demo-dagger-helm' \
  directory --path /work/demo-dagger-helm \
  export --wipe --path deploy/charts/demo-dagger-helm

To test the Helm chart, we will need a test application. To simplify things, we’ll use Nginx, as it’s the default application used by the generated Helm chart.

Add the following piece of code to .dagger/main.go to build your own Nginx container image:

.dagger/main.go
// Build the application container.
func (m *Demo) Build() *dagger.Container {
	return dag.Container().
		From("nginx:1.16.0")
}
 
// Run the application (for demo purposes).
func (m *Demo) Serve() *dagger.Service {
	return m.Build().WithExposedPort(80).AsService()
}

If you want, you can add a custom index.html to verify your chart uses your custom container. The Build function should look like this in that case:

.dagger/main.go
// Build the application container.
func (m *Demo) Build() *dagger.Container {
	return dag.Container().
		From("nginx:1.16.0").
		WithFile("/usr/share/nginx/html/index.html", m.Source.File("index.html"))
}

You can test the application by running the following command and then visiting

http://localhost:8080 in your browser:

dagger call serve up --ports 8080:80

That concludes the preparation phase. You are now ready to move on to the next chapter. You can always check the example code here if you get stuck.

CI: Testing and Linting Helm charts

The next step is setting up linting and testing. While these could be part of your CI pipeline, what makes Dagger especially powerful is that you can run them on your own machine exactly as they would run in CI, making the feedback loop much quicker.

To do so, you are going to need the Helm module from Daggerverse:

dagger install github.com/sagikazarmark/daggerverse/helm@126b5fbbdad70dbf2a8689600baec2eb78c05ef4

Note: you don’t have to use these exact versions, but these are the ones used in the example repository.

As a first step, create a Helm chart object using the Helm module by adding the following to .dagger/main.go:

.dagger/main.go
// Create a Helm chart object.
func (m *Demo) chart() *dagger.HelmChart {
	chart := m.Source.Directory("deploy/charts/demo-dagger-helm")
 
	return dag.Helm(dagger.HelmOpts{Version: "3.16.1"}).Chart(chart)
}

As you can see from the *dagger.HelmChart return type, the returned object will be more than one of the usual core types of Dagger. It allows you to call higher-level functions on the Helm chart loaded from the source directory.

For example, you can call the Lint function on it, which is equivalent to running helm lint:

.dagger/main.go
// Lint the Helm chart.
func (m *Demo) Lint(ctx context.Context) (string, error) {
	chart := m.chart()
 
	return chart.Lint().Stdout(ctx)
}

You can run the linter by executing the following:

dagger call lint

Helm also comes with a test framework that allows you to install the application and run tests against a running instance. (One such test is in the templates/tests/ directory of your chart.) Installing the chart requires an actual Kubernetes cluster and a way to get your application image inside that cluster.

Fortunately, there are modules that can help you with this task:

dagger install github.com/marcosnils/daggerverse/k3s@be7d810261b8719da2cb977132aa51bcab094972
dagger install github.com/sagikazarmark/daggerverse/registry@fb3d654856bd7d53a4129184bf1338e930ac6ca1

The k3s module (lightweight Kubernetes) will run a k3s cluster in Dagger, while the registry will allow you to push your application image to it and Kubernetes to pull it from.

Let’s start putting together our test function. First, we are going to need to build the application and the chart:

.dagger/main.go
// Test the Helm chart.
func (m *Demo) Test(ctx context.Context) error {
	app := m.Build()
	chart := m.chart().Package()
 
	// ...
}

Next, we are going to need a registry service that we can push the application image to:

.dagger/main.go
func (m *Demo) Test(ctx context.Context) error {
	// ...
 
	registry := dag.Registry().Service()
 
	// Push the container image to a local registry.
	_, err := dag.Container().From("quay.io/skopeo/stable").
		WithServiceBinding("registry", registry).
		WithMountedFile("/work/image.tar", app.AsTarball()).
		WithEnvVariable("CACHE_BUSTER", time.Now().String()).
		WithExec([]string{"skopeo", "copy", "--all", "--dest-tls-verify=false", "docker-archive:/work/image.tar", "docker://registry:5000/demo-dagger-helm:latest"}).
		Sync(ctx)
	if err != nil {
		return err
	}
 
	// ...
}

Set up a k3s cluster with the registry acting as a mirror where Kubernetes can pull the image from:

.dagger/main.go
func (m *Demo) Test(ctx context.Context) error {
	// ...
 
	// Configure k3s to use the local registry.
	k8s := dag.K3S("test").With(func(k *dagger.K3S) *dagger.K3S {
		return k.WithContainer(
			k.Container().
			WithEnvVariable("BUST", time.Now().String()).
			WithExec([]string{"sh", "-c", `
cat <<EOF > /etc/rancher/k3s/registries.yaml
mirrors:
  "registry:5000":
	endpoint:
  	- "http://registry:5000"
EOF`}).
			WithServiceBinding("registry", registry),
		)
	})
 
	// Start the Kubernetes cluster.
	_, err = k8s.Server().Start(ctx)
	if err != nil {
		return err
	}
 
	// ...
}

Finally, install the chart on the cluster and run the tests:

.dagger/main.go
func (m *Demo) Test(ctx context.Context) error {
	const values = `
image:
	repository: registry:5000/demo-dagger-helm
	tag: latest
`
 
	_, err = chart.
    	WithKubeconfigFile(k8s.Config()).
    	Install("demo", dagger.HelmPackageInstallOpts{
        	Wait: true,
        	Values: []*dagger.File{
            	dag.Directory().WithNewFile("values.yaml", values).File("values.yaml"),
        	},
    	}).
    	Test(ctx, dagger.HelmReleaseTestOpts{
        	Logs: true,
    	})
	if err != nil {
		return err
	}
 
	return nil
}

You can run the tests by executing the following command:

dagger call test

It may take a few minutes for the first time, but it should successfully launch a k3s cluster, install the chart, and run the default tests. You can always consult the example code here if you get stuck.

Releasing Helm charts

Pushing the chart to an OCI registry is the final step in the development lifecycle. Once again, we will use the Helm module to do this. To make things more realistic, you can also push the application container image to a registry along with the chart. I’ll use ghcr.io in this example, but you can use any registry you prefer.

.dagger/main.go
// Package and release the Helm chart (and the application).
func (m *Demo) Release(ctx context.Context, version string, githubActor string, githubToken *dagger.Secret) error {
	_, err := m.Build().
		WithRegistryAuth("ghcr.io", githubActor, githubToken).
		Publish(ctx, fmt.Sprintf("ghcr.io/%s/demo-dagger-helm:%s", githubActor, version))
	if err != nil {
		return err
	}
 
	err = m.chart().
		Package(dagger.HelmChartPackageOpts{
			Version:	strings.TrimPrefix(version, "v"),
			AppVersion: version,
		}).
		WithRegistryAuth("ghcr.io", githubActor, githubToken).
		Publish(ctx, fmt.Sprintf("oci://ghcr.io/%s/helm-charts", githubActor))
	if err != nil {
		return err
	}
 
	return nil
}

The nice thing about this workflow is you can easily run it from your machine:

dagger call release \
  --version v0.0.1 \
  --githubActor YOU \
  --githubToken cmd:'gh auth token'

While releases are typically handled by a CI system, it's often easier to run the initial release from a local machine. It's also convenient to have the ability to publish releases even if the CI system is down.

Conclusion

Dagger is an excellent tool for developing a Helm chart, supporting the entire development lifecycle—from creating the chart to pushing the final artifact to a registry.

Be sure to check out the example repository for the final code and some bonus components, such as generating documentation with helm-docs and a GitHub Actions workflow.