Image Contract
The OCI image is the deliverable a module author ships. This page is the
normative image contract for the v1alpha1 module contract: the fixed
paths CAPTF’s runner looks for, the labels it and tfcapi-lint read, the
user the image should run as, and multi-arch publishing. tfcapi-lint image checks an image against this contract. What the module actually
sees when the runner executes it — the generated root, the environment,
the commands run and their order — is on
Runtime Environment.
One image bundles one role module (cluster, machine or machinepool)
and the runtime that runs it (tofu or terraform). There is no
separate module source and no separate runtime image: the image tag is the
module version, the image is what spec.source.image references, and the
image is what gets pinned, moved, rolled out and audited. The controller
runs it as a Job with its own runner binary as the entrypoint; the image
itself never needs a shell.
Fixed paths
The runner discovers a module by fixed path, not by label or registry
metadata, and fails the Job at start (error.kind: image-layout) if a
required path is missing or unusable.
| Path | Required | Contents |
|---|---|---|
/captf/module/ | yes | The role module: at least one .tf, .tf.json, .tofu or .tofu.json file at its top level, plus any local module it references by a relative source. It cannot declare a terraform { backend … } or cloud block: those are only valid in a root module, and the generated root, not this one, is the root. |
/captf/runtime | yes | The tofu or terraform binary: a regular file, or a symlink to one inside the image, executable by the image’s USER. It must support the Terraform 1.x / OpenTofu 1.x CLI surface: version, init, validate, plan, apply, destroy, force-unlock, show, and state push/state list. There is no override for this path. |
/captf/providers/ | no | An optional provider filesystem mirror (below). Without it, init needs registry egress. |
/captf/work/, /captf/bin/, /captf/config/, /var/run/captf/credentials/ | must be empty | The Job mounts an emptyDir, the runner binary, the per-run Secret and the identity’s credential files at these paths respectively. Anything the image ships under them is shadowed (or, for /captf/work, never used, since the image’s own root filesystem is read-only by default). |
Everything else in the image is the author’s business: CA certificates,
git for provider blocks that shell out, or a helper binary a
local-exec provisioner calls.
Provider mirror layout
Build /captf/providers with terraform providers mirror <dir> or tofu providers mirror <dir>, for every platform the image publishes (for
example -platform=linux_amd64 -platform=linux_arm64). Both runtimes
accept either layout it can produce: the packed layout
(HOST/NAMESPACE/TYPE/terraform-provider-TYPE_VERSION_TARGET.zip plus
.json index files) or the unpacked layout
(HOST/NAMESPACE/TYPE/VERSION/TARGET/).
providers mirror creates its target directory only when it writes at
least one provider, so a module that requires none needs the mirror stage
to create /captf/providers itself (see the reference Containerfiles
below) or the later COPY --from=mirror fails. It also fails with
“Module not installed” when the module calls local modules that are not
yet installed, so run terraform get / tofu get first, in the same
stage; that installs modules only, never providers.
Providers are optional: an image without /captf/providers still works,
but needs registry egress at init and is slower and non-hermetic. The
reference images in this repository ship a mirror. How the runner uses the
mirror at run time is on
Runtime Environment.
OCI labels
Labels are metadata only: the runner never reads them for behavior.
tfcapi-lint image checks them for consistency with --role/--contract.
| Label | Value |
|---|---|
io.captf.contract | Contract version, for example v1alpha1. |
io.captf.role | cluster, machine or machinepool. |
io.captf.runtime | tofu or terraform: what /captf/runtime is. |
io.captf.runtime.version | For example 1.12.6. |
org.opencontainers.image.source, .revision, .version | Standard OCI annotations; .version should equal the tag. |
The reference Containerfiles below take these three as IMAGE_SOURCE,
IMAGE_REVISION and IMAGE_VERSION build args (empty by default), so a
build pipeline sets them with --build-arg from the source repository
URL, the commit, and the tag being built.
Capacity labels (machine role only, optional) let
TerraformMachineTemplate support Cluster Autoscaler scale-from-zero: the
module fixes the instance type, so the image is the only place that knows
the node’s size. Set both identically on every platform of a multi-arch
index; an image without them leaves status.capacity/status.nodeInfo
unset.
| Label | Value | Maps to |
|---|---|---|
io.captf.capacity | JSON object, resource name to Kubernetes quantity string, for example {"cpu":"4","memory":"16Gi","nvidia.com/gpu":"1"}. Each key is a valid Kubernetes resource name; each value parses as a quantity. | TerraformMachineTemplate.status.capacity |
io.captf.node-info | JSON object {"architecture":"amd64","operatingSystem":"linux"}; architecture is one of amd64, arm64, s390x, ppc64le; at least one key set. | TerraformMachineTemplate.status.nodeInfo |
A module whose instance type varies needs one image per instance type to use these labels; pool images may carry them, but they are ignored.
User
Any UID works for the runner, but recommend a non-root USER (for example
65532) so the Job can run under a namespace that enforces the Pod
Security restricted profile. Files and directories under /captf/module
and, when present, /captf/providers must be readable, and directories
traversable, by that UID. Credential files are mounted mode 0440, so a
non-root image user reads them through the pod’s fsGroup, not through
ownership. See Security Model
for how the pod’s own security context defaults interact with the image’s
USER.
Multi-arch
Publish a multi-arch manifest (linux/amd64, linux/arm64) or pin the
management cluster’s node architecture to the platform the image ships:
tfcapi-lint image checks the linux/amd64 platform by default, and
--platform/--all-platforms select others. A provider mirror must carry
a package for the target platform it is checked against.
Versioning and pinning
The image tag is the module version: spec.source.image is
registry/repo:tag or registry/repo@sha256:…, and a new module version
is a new tag referenced by a new Terraform*Template. The controller pins
the digest it actually ran after the first successful apply and, for
immutable machines, uses that digest for every later drift and destroy
Job; see Security Model
for the full mechanics and why it matters.
Contract version is not declared in the image: labels are informational.
The controller injects the contract it generates against as
captf_contract, and tfcapi-lint takes --contract on the command
line.
Trust boundary
Referencing an image is equivalent to granting its publisher the runner’s Secret access and the resolved identity’s cloud credentials in that namespace: see Security Model for the full trust boundary and what it means for review and tenancy.
Building an image
Lint the module first, then build, then lint the pushed image:
tfcapi-lint module ./cluster --role cluster --strict
podman build -t "$IMAGE" .
tfcapi-lint image "$IMAGE" --role cluster --strict
The reference images below ship as
examples/Containerfile.terraform and
examples/Containerfile.opentofu, each
taking ARG ROLE and ARG RUNTIME_VERSION.
Reference: Terraform base
hashicorp/terraform is Alpine with git, openssh and CA certificates,
its binary at /bin/terraform, and ENTRYPOINT ["/bin/terraform"]; the
runner replaces that entrypoint, so it has no effect. The image has no
USER (root); the Containerfile below adds one.
# 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}"
Mirroring providers needs registry egress at build time; drop the
mirror stage (and its COPY --from=mirror) for a non-hermetic image.
Reference: OpenTofu base
opentofu:*-minimal is FROM scratch with only the tofu binary — no CA
certificates, no shell, no git — which is why the Containerfile below
copies it into an Alpine stage instead of using it directly as the final
base. The full (non-minimal) OpenTofu image refuses to be used as a
FROM base.
# Reference source image, OpenTofu base (docs/book/src/module-author/image-contract.md "Reference:
# OpenTofu base"). Run from your module's root directory:
#
# podman build -f Containerfile.opentofu --build-arg ROLE=machine \
# --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 machine --strict
#
# The full ghcr.io/opentofu/opentofu image refuses to be a FROM base
# (ONBUILD RUN exit 1); use the -minimal tag via COPY --from as below.
ARG RUNTIME_VERSION=1.12.6
# 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=""
FROM ghcr.io/opentofu/opentofu:${RUNTIME_VERSION}-minimal AS tofu
# 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/library/alpine:3.22 AS mirror
RUN apk add --no-cache ca-certificates
COPY --from=tofu /usr/local/bin/tofu /usr/local/bin/tofu
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 tofu get \
&& mkdir -p /captf/providers \
&& tofu providers mirror -platform=linux_amd64 -platform=linux_arm64 /captf/providers
FROM docker.io/library/alpine:3.22
ARG ROLE=machine
ARG RUNTIME_VERSION
ARG IMAGE_SOURCE
ARG IMAGE_REVISION
ARG IMAGE_VERSION
RUN apk add --no-cache ca-certificates \
&& adduser -D -u 65532 captf
COPY --from=tofu /usr/local/bin/tofu /captf/runtime
COPY --from=mirror /captf/providers /captf/providers
COPY . /captf/module
RUN chown -R 65532:65532 /captf
USER 65532
LABEL io.captf.contract="v1alpha1" \
io.captf.role="${ROLE}" \
io.captf.runtime="tofu" \
io.captf.runtime.version="${RUNTIME_VERSION}" \
org.opencontainers.image.source="${IMAGE_SOURCE}" \
org.opencontainers.image.revision="${IMAGE_REVISION}" \
org.opencontainers.image.version="${IMAGE_VERSION}"
A distroless/static:nonroot final stage also works, and needs no shell,
if the module needs no other tool: tofu is statically linked.
Checklist for tfcapi-lint image
Summarized: /captf/module present and lints clean for the role;
/captf/runtime present and executable; /captf/providers, if present,
follows the mirror layout and covers every required_providers entry for
the platform being checked; labels, if present, agree with
--role/--contract; the capacity labels, if present, are valid JSON of
the shapes above; no files under the reserved paths; config.User is
non-root (running as root is a warning, not an error). See
tfcapi-lint CLI: checks for
every check’s ID, severity and the roles it applies to.