# tfcapi-lint CLI

`tfcapi-lint` checks a Terraform or OpenTofu module, or the OCI image built from it, against the CAPTF module contract and image contract. It reads the module’s files and the image’s layers. It never runs `init`, `plan` or `apply`, and it needs neither `terraform` nor `tofu`.

This page is the reference: every command, flag, exit code and check. For a walkthrough, registry credentials and CI patterns, see [tfcapi-lint](<https://captf.io/docs/module-author/tfcapi-lint/index.md>).

## Install

Each provider release attaches one static binary per platform and a checksum file: `tfcapi-lint-linux-amd64`, `tfcapi-lint-linux-arm64`, `tfcapi-lint-darwin-amd64`, `tfcapi-lint-darwin-arm64`, `tfcapi-lint-windows-amd64.exe` and `tfcapi-lint-checksums.txt` (SHA-256). The binary has the same version as the provider release. There is no Windows ARM64 build, and `go install` does not work because the repository is a Go workspace.

```sh
curl -fsSLO "https://github.com/captf-io/cluster-api-provider-terraform/releases/download/<version>/tfcapi-lint-<os>-<arch>"
install -m 0755 "tfcapi-lint-<os>-<arch>" /usr/local/bin/tfcapi-lint
tfcapi-lint version
```

- `<version>` is the provider release, for example `v0.1.0`.
- `<os>` and `<arch>` pick a binary from the list above.

The [tfcapi-lint](<https://captf.io/docs/module-author/tfcapi-lint/#install>) guide shows how to verify the checksum.

## Synopsis

```text
tfcapi-lint image  --role=<role> [flags] <image-ref>
tfcapi-lint module --role=<role> [flags] <module-dir>
tfcapi-lint version [--json]
tfcapi-lint --version[=raw]
```

`<role>` is `cluster`, `machine` or `machinepool`. A bare `tfcapi-lint` prints its usage and exits `3`.

## Global flags

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--version` | `version` | `false` | Print version information and exit. `--version=raw` prints the full build information. `--version=vX.Y.Z` sets the reported version. |

## module

```text
tfcapi-lint module --role=<role> [flags] <module-dir>
```

Lints a module directory against the contract. It reads the `.tf`, `.tf.json`, `.tofu` and `.tofu.json` files in `<module-dir>` and in the local modules it calls, and runs the [module checks](<#module-checks>) for the role.

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--role` | `string` |  | The module role: `cluster`, `machine` or `machinepool`. Required. |
| `--contract` | `string` | `v1alpha1` | The contract version to lint against. |
| `--json` | `bool` | `false` | Print a JSON report instead of text. |
| `--strict` | `bool` | `false` | Count warnings as errors for the exit code. |
| `--allow-warning` | `stringSlice` |  | Downgrade this check ID’s warnings to info. Repeatable, or a comma-separated list. Errors cannot be allowed. |

```sh
tfcapi-lint module --role cluster --strict ./modules/cluster
```

## image

```text
tfcapi-lint image --role=<role> [flags] <image-ref>
```

Lints a built source image against the image contract. It pulls the manifest and layers without running the image, runs the [image checks](<#image-checks>), and runs the module checks on the module in `/captf/module`.

`<image-ref>` is a registry reference such as `registry.example.com/acme/machine:v1.0.0`, or `oci:<dir>` for a local OCI image layout. It authenticates with the same credential files as `docker` and `podman`.

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--role` | `string` |  | The module role. Required. |
| `--contract` | `string` | `v1alpha1` | The contract version to lint against. |
| `--json` | `bool` | `false` | Print a JSON report instead of text. |
| `--strict` | `bool` | `false` | Count warnings as errors for the exit code. |
| `--allow-warning` | `stringSlice` |  | Downgrade this check ID’s warnings to info. Repeatable. Errors cannot be allowed. |
| `--platform` | `string` | `linux/amd64` | The platform to check in a multi-platform image. |
| `--all-platforms` | `bool` | `false` | Check every platform the image publishes. |
| `--insecure` | `bool` | `false` | Allow a plain-HTTP registry. |

```sh
tfcapi-lint image --role machine --strict registry.example.com/acme/machine:v1.0.0
```

Lint a local image without a registry by saving it as an OCI layout first:

```sh
podman save --format oci-dir -o ./image <image>
tfcapi-lint image --role machine --strict oci:./image
```

- `<image>` is the local image name, for example `localhost/acme/machine:dev`.

If you set `--platform` and the image is for a different platform, the result is an `image/platform` error. An image with no manifest for the requested platform gets the same error.

### Output

The text format prints one line per finding, then a summary:

```text
error input/required -:0 contract input bootstrap_data is not declared as a variable
0 error(s), 1 warning(s), 0 info (read terraform files)
```

A finding that concerns the whole module or image shows `-` as its file. With `--json`, the report has a `findings` array and a `summary`. Each finding has `id`, `severity` (`error`, `warning` or `info`), `file`, `line` and `message`. `summary` counts `error`, `warning` and `info`. An image report also describes the image it checked.

Findings are ordered by file, line and ID, so output is stable between runs. `--allow-warning` changes a warning to `info` and says so in its message.

## version

```text
tfcapi-lint version [--json]
```

Prints the program name and version, then exits `0`.

| Flag | Type | Default | Description |
| --- | --- | --- | --- |
| `--json` | `bool` | `false` | Print JSON with `version`, `commit`, `date` and `contract`, the list of contract versions this build can lint against. |

```sh
tfcapi-lint version --json
```

## Exit codes

| Code | Name | Meaning |
| --- | --- | --- |
| `0` | `ExitOK` | No errors, and with `--strict`, no warnings. Info findings never fail a run. |
| `1` | `ExitFindings` | At least one error, or a warning under `--strict`. |
| `2` | `ExitUnparsable` | The module could not be read or parsed, or the image could not be pulled or extracted. |
| `3` | `ExitUsage` | A bad command line: a missing `--role`, an unknown role or contract version, a wrong number of arguments, or an invalid image reference. |

In CI, treat any non-zero code as a failed step. Use `--strict` to fail on warnings, which is the recommended default.

## Checks

Every check has an ID of the form `<group>/<name>`, a severity and a fix. Run `--json` to see IDs in a report. A finding’s severity decides the exit code:

| Severity | Effect |
| --- | --- |
| `error` | Fails the run. Cannot be allowed. |
| `warning` | Fails the run only with `--strict`. `--allow-warning <id>` downgrades it to `info`. |
| `info` | Never fails the run. |

The `module` command runs the module checks. The `image` command runs the image checks and the module checks on the image’s module. All module checks apply to every role unless a row says otherwise.

### Module checks

#### Inputs

The contract’s inputs are the variables the controller passes to your module. See the [common contract](<https://captf.io/docs/module-author/contract/v1alpha1/common/#inputs>) and the role pages for the full lists.

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `input/required` | error | A contract input is not declared as a variable. | Declare a `variable` for it. |
| `input/type` | error | A contract input’s type does not accept what the generated root passes. A missing type, or a type the linter cannot read, is a warning. | Declare a type that accepts the contract’s type. |
| `input/default` | warning | An input the controller always sets to a non-null value has a default, which would hide a controller mistake. Nullable inputs may default to `null`. | Remove the `default`. |
| `input/sensitive` | warning | `bootstrap_data` is declared without `sensitive = true`, though it carries the bootstrap payload. | Add `sensitive = true`. |
| `input/reserved` | error | A variable uses the reserved `captf_` prefix but is not a contract input. | Rename the variable. |
| `input/tags-declared` | error | The module does not declare `captf_tags`, the common input every module must accept. | Declare `captf_tags`. |
| `input/tags-unused` | warning | `captf_tags` is declared but never used, directly or through a local module that uses it. | Apply `var.captf_tags` to every resource that can carry tags. Allow the warning if the provider cannot tag anything. |
| `input/user-variable-default` | warning | A variable outside the contract has no default, so an object that does not set it in `spec.variables` or `variablesFrom` fails to apply. | Give it a `default`. |

#### Outputs

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `output/required` | error | A contract output is not declared. | Declare an `output` for it. |
| `output/health` | error | The `health` output is not declared. Every module must report its health. | Declare `health` as the contract describes. |
| `output/reserved` | warning | An output uses the reserved `captf_` prefix. | Rename the output. |
| `output/endpoint-never-set` | warning | Cluster role only. `control_plane_endpoint` is a literal `null`, so a `KubeadmControlPlane` cluster with no user-set endpoint waits forever. | Output the endpoint your infrastructure creates. |
| `output/provider-id-list-shape` | error or warning | Machinepool role only. It is an error when `provider_id_list` is not declared. It is a warning when the expression filters on health or state, or is not sorted with `sort()`. | Declare it, list every non-terminated member, and wrap the list in `sort()`. |
| `pool/autoscaling-ignore-changes` | warning | Machinepool role only. The module uses `var.autoscaling`, but no resource ignores changes to its desired capacity, so each apply resets the cloud autoscaler. | Add the desired-capacity argument to `lifecycle { ignore_changes }`. |

#### Module source

CAPTF owns the backend and the credentials of a run, so a module must not configure them.

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `module/backend` | error | The root or a nested module declares a `terraform { backend }` block. | Remove it. The generated root owns the backend. |
| `module/cloud` | error | The root or a nested module declares a `terraform { cloud }` block. | Remove it, for the same reason. |
| `module/provider-config` | warning | A provider block sets a credential argument, such as `access_key`, `token` or `password`, to a string literal. A reference such as `var.token` is fine. | Take credentials from the identity, not from the module source. |
| `module/source-escape` | error | A local module call resolves outside the root module directory, directly or through a symlink, or does not resolve. A module file that is a symlink out of the root is also reported. Such code is never linted. | Keep every local module inside the root directory. |
| `module/tofu-shadow` | warning | A `.tofu` file shadows a `.tf` file, and OpenTofu loads different declarations than Terraform does. | Make the two files agree, or ship only one. |
| `module/version` | info | The module declares no `required_version`. | Declare the runtime versions you tested with. |

### Image checks

The image checks read the image’s files and config. They apply to every role. The [image contract](<https://captf.io/docs/module-author/image-contract/index.md>) describes the paths and labels they enforce.

#### Content

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `image/module-present` | error | `/captf/module` has no `.tf`, `.tf.json`, `.tofu` or `.tofu.json` file at its top level. | Copy the module into `/captf/module`. |
| `image/module-link` | error | A link under `/captf/module` does not end at a regular file inside it. | Replace the link with the file. |
| `image/module-readable` | warning | With a numeric non-root user, a path under the module or provider mirror is not readable or traversable. The finding lists up to five paths. | Fix the file modes or ownership. |
| `image/runtime-present` | error | `/captf/runtime` is missing, is not a regular file, or is not executable by the image user. | Install the runtime there, executable. |
| `image/runtime-version` | warning | The runtime’s file or link name looks like a different runtime than `io.captf.runtime` says. | Correct the label or the runtime. |
| `image/reserved-paths` | error | Files exist under `/captf/work`, `/captf/bin`, `/captf/config` or `/var/run/captf/credentials`, which the Job mounts over. | Remove them. |
| `image/entrypoint` | info | `ENTRYPOINT` or `CMD` is set. The runner replaces both. | None needed. |
| `image/platform` | error | The image is not for the requested platform, or the index has no manifest for it. | Build for the platform, or pass `--platform`. |

#### Provider mirror

The mirror at `/captf/providers` is optional. Without it, `init` needs registry access when the Job runs.

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `image/providers-absent` | info | The image has no provider mirror. | Add a mirror to run without registry access. |
| `image/providers-layout` | error | An entry is neither a packed (`HOST/NS/TYPE/*.zip` with `*.json`) nor an unpacked (`HOST/NS/TYPE/VERSION/TARGET/`) mirror entry. | Use the mirror layout. |
| `image/providers-complete` | error | A mirror link is broken, or the mirror has no package for the checked platform for a provider the module requires. Built-in providers are exempt. | Mirror every required provider for the platform. |

#### Labels and user

| ID | Severity | What it checks | How to fix |
| --- | --- | --- | --- |
| `image/label-role` | info or error | `io.captf.role` is not set (info), or names a different role than `--role` (error). | Set the label to the role. |
| `image/label-contract` | info or warning | `io.captf.contract` is not set (info), or names a different version than `--contract` (warning). | Set the label to the contract version. |
| `image/label-capacity` | info or error | Machine role. `io.captf.capacity` and `io.captf.node-info` do not parse. Missing labels are info: no scale-from-zero capacity. Other roles get info only. | Make the labels valid JSON of the documented shape. |
| `image/user-root` | warning | The image runs as root, so the Job cannot run under the restricted Pod Security Standard. | Set a numeric non-root `USER`. |
| `image/user-unresolved` | warning | The image user is a name the linter cannot resolve, so permission checks are approximated. | Use a numeric uid. |

> [!TIP]
>
> **Allow a warning only with a reason**
>
> `--allow-warning` hides a warning from `--strict` but not from the report, where it shows as `info`. Pass one flag per ID you have decided to accept, and keep the list in the CI file so a reviewer sees each exception.

> [!NOTE]
>
> **See also**
>
> - [tfcapi-lint](<https://captf.io/docs/module-author/tfcapi-lint/index.md>) for installing, strict mode and CI.
> - [Module Contract](<https://captf.io/docs/module-author/contract/index.md>) and [Image Contract](<https://captf.io/docs/module-author/image-contract/index.md>) for what the checks enforce.
