OpenTofu state and caching¶
tofu-plan and
tofu-apply are the two components that
actually touch AWS. Everything about how they authenticate, hand off a plan,
and cache their way out of network flakiness follows from one starting
constraint: no static credentials, anywhere.
Why OIDC, not access keys¶
GitLab CI mints a short-lived OIDC ID token via the id_tokens: keyword.
The component writes it to a file and exports the three environment
variables the AWS SDK's web-identity credential provider looks for
(AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE, AWS_REGION) — no explicit
aws sts call. OpenTofu's aws provider performs the
AssumeRoleWithWebIdentity exchange itself, refreshes it, and the
credentials are never written to disk beyond the token. This is the
GitLab-side mirror of what GitHub Actions OIDC did for the project before
its GitLab migration, and the reason the trust relationship exists on the
phpboyscout-automation IAM role at all — see
2026-05-16-tofu-plan-apply-v0.2.md.
The one consumer-side requirement this imposes: the stack's aws
provider must not hardcode a profile. A static profile short-circuits the
credential chain before OIDC ever gets a chance — the provider looks for
~/.aws/credentials on a runner that has none, and fails. The safe pattern
is a profile variable that defaults to null in CI:
variable "aws_profile" {
type = string
default = null # null in CI — the OIDC web-identity chain is used instead
}
provider "aws" {
profile = var.aws_profile
}
Two components, not one mode-switched component¶
tofu-plan and tofu-apply are deliberately separate templates rather than
one tofu component with a mode: plan|apply input. They have different
rules: defaults (plan on branches/MRs; apply on the default branch,
manual-gated); tofu-apply needs: the plan job's artifact, a dependency a
single component can't cleanly express on itself; and a consumer's
.gitlab-ci.yml reads more honestly as a plan stage and an apply stage than
as one component wearing two hats.
The plan → apply handoff, two ways¶
tofu-plan writes tfplan.cache (the binary plan) and tfplan.json (a
GitLab terraform report, so the MR widget shows an add/change/destroy
summary) as artifacts. tofu-apply applies the exact plan that was
reviewed — never a fresh re-plan — so tofu itself rejects the apply
outright if state moved in between (fail-safe by design).
There are two ways to get that plan artifact to the apply job, selected by
plan_source:
job(default) — same pipeline.tofu-applydeclaresneeds: [{ job: tofu-plan, artifacts: true }]; plan and apply co-occur, normally on the default branch.ref— cross pipeline. The plan was banked by an earlier, different pipeline (typically themainpush that merged a Release MR); the apply — typically triggered by the release tag — downloads the latest artifact for that job on that ref via the GitLab jobs-artifacts API.
These aren't independent settings: the retrieval mode and the trigger are
the same decision. needs: and rules: can't be conditionally interpolated
inline, so each mode's wiring — where the plan comes from and when the
apply fires — lives together in a hidden job that tofu-apply extends
(.tofu-apply--plan--job / .tofu-apply--plan--ref). Mixing them up is a
hard pipeline-creation error, not a silent bug: a job-mode apply on a tag
pipeline would needs: a tofu-plan job that, per its own rule, never runs
on a tag at all.
ref-mode apply is additionally restricted to tags matching tag_pattern
(strict semver by default) — an unrestricted if: $CI_COMMIT_TAG would let
a prerelease or a stray hand-pushed tag trigger a real tofu apply. See
2026-05-16-tofu-apply-plan-sources-v0.3.md.
A known, accepted edge case: the jobs-artifacts API returns the latest
successful pipeline's artifact, not necessarily the latest pipeline's —
if main's most recent run failed, ref mode happily applies an older
green plan. The component trusts the consumer's release-MR merge gate to
keep that window small; it doesn't query pipeline status itself.
Caching: keeping OpenTofu off the network¶
Two caches, for two different reasons:
- Provider plugin cache (
TF_PLUGIN_CACHE_DIR, ontofu-plan,tofu-apply, andtofu-validate) — providers are immutable once published, so a static cache key keeps every locked version warm indefinitely rather than evicting on eachCargo.lock- style state change. - Resolved-modules cache (
.terraform/modules/, ontofu-validate) —tofu initdownloads every module source a config references, even one gated behindcount = 0, pulling from upstream registries likeregistry.opentofu.org. Caching the resolved modules means a warm run skips the network entirely — which incidentally also masks a transient upstream-registry outage that would otherwise fail validate for a reason that has nothing to do with the code under review.
See also¶
- Reference:
tofu-plan,tofu-apply,tofu-validate - The token-input convention —
state_tokenandplan_tokenfollow it. - How-to: Set up OIDC for tofu-plan / tofu-apply
- Specs:
2026-05-16-tofu-plan-apply-v0.2,2026-05-16-tofu-apply-plan-sources-v0.3,2026-05-24-provider-cache-v0.6,2026-05-30-module-cache-v0.7.2