Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Your First Module

This tutorial writes a small machine-role module from scratch, lints it, packages it as the OCI image CAPTF runs, lints that image, and references it from a TerraformMachineTemplate. It ends with a working, lint-clean module image; it does not provision real infrastructure, since the module in this tutorial creates no cloud resources. For the full set of rules a module must follow, see the module contract and the image contract.

Before you begin

  • tfcapi-lint, installed as in tfcapi-lint.
  • podman or docker, to build the image.
  • A directory to work in. This tutorial calls it machine/.

Terraform or OpenTofu itself is not required on your machine: the reference Containerfile below downloads it inside the build, and tfcapi-lint module parses the module’s files directly.

1. Write the module

A machine-role module implements one Terraform/OpenTofu module that CAPTF calls once per TerraformMachine. It receives the common contract inputs plus the machine role’s inputs, and must return the machine role’s outputs plus the common health output. This tutorial’s module returns fixed values instead of calling a cloud provider, which keeps every input and output visible and needs no credentials or provider plugins to build or lint. Swap the fixed values for calls to your cloud’s Terraform/OpenTofu provider once you understand the shape.

Create four files in machine/.

variables.tf

The contract inputs. captf_contract, captf_cluster, captf_object and captf_tags apply to every role; captf_cluster_outputs applies to the machine and machinepool roles only (the cluster role produces it, and so does not receive it); the rest are specific to the machine role.

# Contract inputs of the machine role, v1alpha1 (https://captf.io/docs/module-author/contract/v1alpha1/common.html
# and machine.html).

variable "captf_contract" {
  type = string
}

variable "captf_cluster" {
  type = object({
    name      = string
    namespace = string
  })
}

variable "captf_object" {
  type = object({
    kind      = string
    name      = string
    namespace = string
  })
}

# The cluster module's exports. The controller always sets it; the default
# follows the contract skeleton (machine.md).
variable "captf_cluster_outputs" {
  type    = any
  default = null
}

variable "captf_tags" {
  type = map(string)
}

variable "machine_name" {
  type = string
}

# Base64 of the bootstrap Secret's value.
variable "bootstrap_data" {
  type      = string
  sensitive = true
}

variable "bootstrap_format" {
  type = string
}

variable "failure_domain" {
  type    = string
  default = null
}

variable "kubernetes_version" {
  type    = string
  default = null
}

variable "control_plane" {
  type = bool
}

main.tf

The module’s own logic. This tutorial stands in a terraform_data resource for a real instance, so the module has something to hold its inputs; a real module replaces this with the resources that create an instance.

# No-op machine module: implements the v1alpha1 machine role with no cloud.

# The stand-in for an instance. user_data decodes bootstrap_data the way a
# module feeding a plain user-data argument would, which proves the
# controller's base64 encoding round-trips (the value stays sensitive).
resource "terraform_data" "instance" {
  input = {
    cluster            = var.captf_cluster
    object             = var.captf_object
    machine_name       = var.machine_name
    tags               = var.captf_tags
    backend_id         = try(var.captf_cluster_outputs.backend_id, null)
    user_data          = base64decode(var.bootstrap_data)
    bootstrap_format   = var.bootstrap_format
    failure_domain     = var.failure_domain
    kubernetes_version = var.kubernetes_version
    control_plane      = var.control_plane
  }
}

outputs.tf

The contract outputs. provider_id, addresses and failure_domain are required; interruptible must be declared even when it is always false; health is the common output every role returns.

# Contract outputs of the machine role, v1alpha1.

# Stable per Machine. With no Node behind it, the e2e suite never expects a
# nodeRef; a real module emits the CCM's or kubelet's format (machine.md).
output "provider_id" {
  value = "noop:///${var.captf_object.namespace}/${var.machine_name}"
}

output "addresses" {
  value = [{ type = "InternalIP", address = "10.0.0.1" }]
}

output "failure_domain" {
  value = var.failure_domain
}

output "interruptible" {
  value = false
}

output "health" {
  value = { state = "running", healthy = true, message = null, reasons = [] }
}

versions.tf

terraform {
  # terraform_data needs Terraform 1.4; the variants' plantimestamp() needs
  # 1.5. Every OpenTofu release (1.6+) has both.
  required_version = ">= 1.5"
}

This module declares no required_providers: terraform_data ships with Terraform/OpenTofu itself, so there is no provider to install or mirror. A module that calls a cloud provider adds a required_providers block here as usual.

2. Lint the module

tfcapi-lint module ./machine --role machine --strict

A clean module prints an empty finding list and an all-zero summary. --strict fails the command on a warning as well as an error, so a lint-clean module here stays lint-clean once you add real inputs, outputs and user variables. See tfcapi-lint for the full command reference and tfcapi-lint CLI for every check ID.

3. Package it as an image

CAPTF runs a module as one OCI image that bundles the module’s files and the Terraform or OpenTofu binary; there is no separate module source and no separate runtime image. Save the reference Terraform-based Containerfile into machine/:

# Reference source image, Terraform base (docs/book/src/module-author/image-contract.md "Reference:
# Terraform base"). Run from your module's root directory:
#
#   podman build -f Containerfile.terraform --build-arg ROLE=cluster \
#     --build-arg IMAGE_SOURCE=https://github.com/<org>/<repo> \
#     --build-arg IMAGE_REVISION="$(git rev-parse HEAD)" \
#     --build-arg IMAGE_VERSION=<tag> -t <registry>/<repo>:<tag> .
#
# The image tag is the module version. Lint first:
#   tfcapi-lint module . --role cluster --strict

ARG RUNTIME_VERSION=1.16.4
# The org.opencontainers.image.* labels below (image-contract.md "OCI
# labels"): leave these unset for a local/test build, or pass them from
# your CI pipeline (source repo URL, commit SHA, the image tag).
ARG IMAGE_SOURCE=""
ARG IMAGE_REVISION=""
ARG IMAGE_VERSION=""

# Optional but recommended: hermetic provider mirror for the platforms you
# publish. Needs registry egress at build time; drop this stage (and the
# COPY --from=mirror below) for a non-hermetic image.
FROM docker.io/hashicorp/terraform:${RUNTIME_VERSION} AS mirror
WORKDIR /src
COPY . /src
# get: `providers mirror` refuses a module whose nested local modules are not
# installed; get installs them (no providers), in this stage only.
# mkdir: `providers mirror` does not create the target when the module
# requires no providers, and the COPY --from=mirror below needs it.
RUN terraform get \
 && mkdir -p /captf/providers \
 && terraform providers mirror -platform=linux_amd64 -platform=linux_arm64 /captf/providers

FROM docker.io/hashicorp/terraform:${RUNTIME_VERSION}
ARG ROLE=cluster
ARG RUNTIME_VERSION
ARG IMAGE_SOURCE
ARG IMAGE_REVISION
ARG IMAGE_VERSION
COPY --from=mirror /captf/providers /captf/providers
COPY . /captf/module
RUN ln -s /bin/terraform /captf/runtime \
 && adduser -D -u 65532 captf \
 && chown -R 65532:65532 /captf
USER 65532
LABEL io.captf.contract="v1alpha1" \
      io.captf.role="${ROLE}" \
      io.captf.runtime="terraform" \
      io.captf.runtime.version="${RUNTIME_VERSION}" \
      org.opencontainers.image.source="${IMAGE_SOURCE}" \
      org.opencontainers.image.revision="${IMAGE_REVISION}" \
      org.opencontainers.image.version="${IMAGE_VERSION}"

An OpenTofu-based equivalent is also available (Containerfile.opentofu); either runtime satisfies the contract. Build from inside machine/:

export IMAGE=registry.example.com/acme/machine:v1.0.0
podman build -f Containerfile.terraform --build-arg ROLE=machine -t "$IMAGE" .

IMAGE is the registry, repository and tag you push to; ROLE=machine tags the image with the role it implements. See the image contract for the fixed paths, OCI labels and execution environment every image must satisfy.

4. Lint the image

tfcapi-lint image reads a pushed registry reference or a local OCI layout. Without a registry to push to yet, save the image podman just built to a local OCI directory and lint that:

podman save --format oci-dir -o /tmp/first-module-image "$IMAGE"
tfcapi-lint image --role machine "oci:/tmp/first-module-image"

Once you push $IMAGE to a registry, lint the pushed reference the same way: tfcapi-lint image --role machine "$IMAGE".

5. Reference it from a TerraformMachineTemplate

A TerraformMachineTemplate is what a MachineDeployment, a KubeadmControlPlane or a MachineSet points at to create TerraformMachines; its spec.template.spec.source.image names the image you just built:

apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: TerraformMachineTemplate
metadata:
  name: first-module
  namespace: default
spec:
  template:
    spec:
      source:
        image: registry.example.com/acme/machine:v1.0.0

Apply it:

kubectl apply -f first-module-template.yaml

kubectl get terraformmachinetemplate first-module -n default confirms the object exists; there is nothing to provision yet, since nothing references this template as a MachineDeployment, MachineSet or control plane’s infrastructureRef does. A TerraformMachine created from it needs an identity to run under: either its own spec.identityRef, or one inherited from its TerraformCluster’s spec.defaults.identityRef, as set up in the quick start.

Next steps