A pipeline that tests Terraform needs a cloud, and a cloud needs an account, a
secret and a bill. feint removes all three: the
emulator enters a services: block like any test database, and the real
Terraform provider applies against it. No secret to create, no resource
to clean up when a job fails halfway.
What you will get
By the end of this page, a pull request will run a full apply, second plan and
destroy, on an ordinary runner, without a single cloud secret existing
in the repository and without a billed resource being created.
- Choose between the service container, the dedicated action and the binary.
- Write the job for GitHub Actions and for GitLab CI.
- Understand why the second plan is the only real assertion.
- Verify the image's signature before running it.
Three forms, and which one to take
The choice comes down to one question: does your job already run in containers?
| Form | When to choose it | What it gives you |
|---|---|---|
| Service container | the job already uses containers | nothing to install, an image pinned by digest |
setup-feint action | the runner executes commands directly | installs the binary, verifies its checksum, waits for the answer |
| Binary on a host | you want real machines behind the API | the only mode that opens the Incus runtime |
The container is a control plane and nothing else. It runs with --vm off and
emulates only the three APIs. An image promising to start containers from inside a
container would be a half-truth, and the project prefers to state it: real
machines need the binary on a host, as detailed in Run real
machines.
The whole pipeline, in both forges
The job does the same thing on both sides: apply, re-plan, destroy. Only the way of waiting for the emulator changes.
The runner holds the steps until the image's own healthcheck answers, so the first step can talk to it immediately.
name: Terraform against a local cloud
on: pull_request: workflow_dispatch:
permissions: {}
jobs: terraform: name: apply, re-plan, destroy runs-on: ubuntu-24.04 timeout-minutes: 10
services: feint: image: ghcr.io/stephrobert/feint:v0.13.0@sha256:d46639ac7c7a0fa2b6b69d0c5d74bdca0757e3124675563209e8ada58e6a6040 ports: - 4599:4599
env: SCW_ACCESS_KEY: SCWXXXXXXXXXXXXXXXXX SCW_SECRET_KEY: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_PROJECT_ID: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_ORGANIZATION_ID: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_ZONE: fr-par-1 SCW_DEFAULT_REGION: fr-par SCW_API_URL: http://127.0.0.1:4599 SCW_INSECURE: "true"
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: terraform_version: 1.15.4
- run: terraform init - run: terraform apply -auto-approve - run: terraform plan -detailed-exitcode - run: terraform destroy -auto-approveGitLab does not wait for the service's healthcheck, so the job waits itself. A fixed sleep would pass on a fast runner and fail on a slow one: the loop tests a condition, not a duration.
variables: FEINT_IMAGE: ghcr.io/stephrobert/feint:v0.13.0@sha256:d46639ac7c7a0fa2b6b69d0c5d74bdca0757e3124675563209e8ada58e6a6040 FEINT_ENDPOINT: http://feint:4599 SCW_ACCESS_KEY: SCWXXXXXXXXXXXXXXXXX SCW_SECRET_KEY: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_PROJECT_ID: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_ORGANIZATION_ID: 11111111-1111-1111-1111-111111111111 SCW_DEFAULT_ZONE: fr-par-1 SCW_DEFAULT_REGION: fr-par SCW_API_URL: http://feint:4599 SCW_INSECURE: "true"
terraform: image: hashicorp/terraform:1.15@sha256:fd5debae63188975d6febc6aa5bd1a982a588f55e4a4ddb7de28be923f250456 services: - name: $FEINT_IMAGE alias: feint before_script: - | for _ in $(seq 1 60); do wget -q -O /dev/null "$FEINT_ENDPOINT/_feint/health" && break sleep 1 done wget -q -O - "$FEINT_ENDPOINT/_feint/health" || { echo "the emulator never answered"; exit 1; } script: - terraform init - terraform apply -auto-approve - terraform plan -detailed-exitcode - terraform destroy -auto-approveWhen the runner does not execute containers, the dedicated action installs the published binary, verifies its checksum before running it, then waits for the emulator to answer. It also exports the client environment of the provider you name.
- uses: stephrobert/setup-feint@b7eba1d4fcaccf65cf9124bf97a0d995996709b9 # v1.0.0 with: version: 0.13.0 provider: scalewayThis is the form to prefer on a host that has Incus, since it is the only one of the three that opens the way to real machines behind the API, with a runtime installed on the runner's own machine.
The assertion that makes this a test
The line that counts is terraform plan -detailed-exitcode, not the apply. A
successful apply proves the API answered 200 or 201; it does not prove it
stored what it said it stored.
The -detailed-exitcode flag turns the plan into an assertion:
| Code | Meaning |
|---|---|
0 | no change, the infrastructure converged |
1 | error |
2 | changes proposed, therefore drift |
An emulator answering 200 while storing something else would be invisible to
the apply and obvious here: the second plan would propose to recreate what
already exists. That is the assertion the project's own conformance suite runs
against every provider, and measured on the v0.13.0 container, this second plan
does exit 0.
The credentials that are not secrets
The values in the env block deserve an explanation, otherwise the first code
review will read them as a leak.
They are well formed and meaningless. The Scaleway SDK validates the format
of these variables before sending anything: the access key looks like
SCWXXXXXXXXXXXXXXXXX and the secret is a UUID. They are fake in the only way
that still lets a real client start.
The emulator verifies no authentication. It parses the shape of a credential and validates nothing, which is exactly the property that removes the need for an account. The trade-off is clear: an IAM permission test makes no sense here, as detailed in What feint proves.
Verify the image before running it
This site teaches software supply chain security, and an emulator that runs in everybody's pipeline is precisely an attack surface. The image is signed and attested by the release workflow, under the same identity as the binaries.
cosign verify ghcr.io/stephrobert/feint:v0.13.0 \ --certificate-identity-regexp '^https://github\.com/stephrobert/feint/\.github/workflows/release\.yml@refs/tags/v' \ --certificate-oidc-issuer https://token.actions.githubusercontent.comThe output confirms three distinct attestations on the same digest: a software bill of materials in CycloneDX format, a SLSA provenance and the signature itself. The verified identity is the decisive part of the command: it requires the signature to come from this repository's release workflow, on a tag, and not from any account that happened to push an image.
One tag per release, and no latest. A mutable reference pulls whatever is
newest, which is an image nobody can name afterwards when a test fails. Pinning the
digest in the pipeline, as in the examples above, closes the question
completely.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The job fails immediately on a refused connection | GitLab does not wait for the service | Keep the waiting loop in before_script |
terraform plan exits 2 | A real divergence between the apply and the stored state | Read the plan: that is the defect this test looks for |
| The SDK refuses to start | A malformed credential variable | Respect the formats: SCWXXXXXXXXXXXXXXXXX and a UUID |
An operation answers 404 for no clear reason | The pack declines that operation | Look for the X-Feint-Not-Emulated header |
| The pipeline is green but tests nothing | No assertion after the apply | Add terraform plan -detailed-exitcode |
| Calls leave for the real cloud | An Object Storage resource in the configuration | Run feint doctor in the directory before the apply |
Key takeaways
- The emulator enters a
services:block like any other test dependency. - The container is a control plane: no machines, and that is announced rather than promised.
- The second plan is the assertion, the apply is only a prerequisite.
- The credentials are well formed and meaningless, which still lets a real client start.
- The image is verified by signature and pinned by digest, never by
latest. - GitLab does not wait for the service: the loop tests a condition, not a duration.