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.podmanordocker, 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
- Walk through the quick start to wire a machine template like this one into a full cluster.
- Read the module contract for every rule a production module must follow, and the cluster role and machine pool role for the other two roles a full deployment needs.
- Read Runtime Environment for what a module sees when the runner actually executes it.