feat(skill): add Terraform operational skill (#263)

* feat(skill): add Terraform operational skill

Add a single tool skill for Terraform and OpenTofu operations: module
structure, state backends and locking, plan/apply workflow, drift detection,
remote state, upgrade and refactor flows, and evidence-based diagnostics.
Ships the agent-first tfops wrapper (JSON output, direct state-file analysis,
--dry-run/--yes/--force mutation gate), a fixture-tested suite, six eval
cases, dated references, and routing up to platform-engineering. Closes #243.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(skill): clarify missing-binary report in tfops doctor

When the TERRAFORM env override names a binary that cannot be found,
doctor now reports the env value with a (not found) marker instead of
falling back to the generic default name.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-03 17:38:46 -04:00
committed by GitHub
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent c49666e29a
commit 00abbf90a4
18 changed files with 1283 additions and 0 deletions
+9
View File
@@ -1004,6 +1004,15 @@
"strict": false, "strict": false,
"description": "Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service." "description": "Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service."
}, },
{
"name": "terraform",
"source": "./",
"skills": [
"./terraform"
],
"strict": false,
"description": "Operate Terraform and OpenTofu across the whole infrastructure lifecycle: module structure, state backends and locking, plan/apply workflow, drift detection, remote state, upgrade and refactor flows, and evidence-based diagnostics. Use when running or inspecting terraform plans, applies, state files, imports, or state surgery, or when the bundled tfops script should handle the task. Do not use for IaC methodology or cloud design decisions - those route up to platform-engineering."
},
{ {
"name": "three", "name": "three",
"source": "./", "source": "./",
+1
View File
@@ -130,6 +130,7 @@
"./technical-documentation", "./technical-documentation",
"./technology-radar", "./technology-radar",
"./tempest-cli", "./tempest-cli",
"./terraform",
"./three", "./three",
"./tmdb-cli", "./tmdb-cli",
"./traefik", "./traefik",
+4
View File
@@ -461,6 +461,10 @@ Turn scattered technology preferences into explicit, reviewable decisions with o
Hyper-local weather from a WeatherFlow Tempest station. Query current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. A complete reference implementation of the cli-builder patterns in a working, testable project — including the CLI binary and full API field layout reference. Hyper-local weather from a WeatherFlow Tempest station. Query current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. A complete reference implementation of the cli-builder patterns in a working, testable project — including the CLI binary and full API field layout reference.
### [terraform](terraform/SKILL.md)
Operate Terraform and OpenTofu safely across the whole infrastructure lifecycle: module structure, state backends and locking, plan/apply workflow, drift detection, remote state, upgrade and refactor flows, and evidence-based diagnostics. Ships the agent-first `tfops` wrapper with direct state-file analysis, a `--dry-run`/`--yes`/`--force` mutation gate, tests, and dated references.
### [three](three/SKILL.md) ### [three](three/SKILL.md)
Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations. Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations.
+1
View File
@@ -113,6 +113,7 @@
- [technical-documentation](technical-documentation/SKILL.md): Create and review technical documentation, including READMEs, agent-facing instructions, API references, and CLI help. Use when documentation must help someone complete real work. Do not use for marketing copy, brand messaging, or long-form editorial content. - [technical-documentation](technical-documentation/SKILL.md): Create and review technical documentation, including READMEs, agent-facing instructions, API references, and CLI help. Use when documentation must help someone complete real work. Do not use for marketing copy, brand messaging, or long-form editorial content.
- [technology-radar](technology-radar/SKILL.md): Build and maintain technology radars for adoption, trial, assessment, and hold decisions. Use when governing technology choices, build-versus-buy decisions, or engineering portfolio risk. - [technology-radar](technology-radar/SKILL.md): Build and maintain technology radars for adoption, trial, assessment, and hold decisions. Use when governing technology choices, build-versus-buy decisions, or engineering portfolio risk.
- [tempest-cli](tempest-cli/SKILL.md): Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service. - [tempest-cli](tempest-cli/SKILL.md): Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service.
- [terraform](terraform/SKILL.md): Operate Terraform and OpenTofu across the whole infrastructure lifecycle: module structure, state backends and locking, plan/apply workflow, drift detection, remote state, upgrade and refactor flows, and evidence-based diagnostics. Use when running or inspecting terraform plans, applies, state files, imports, or state surgery, or when the bundled tfops script should handle the task. Do not use for IaC methodology or cloud design decisions - those route up to platform-engineering.
- [three](three/SKILL.md): Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations with a small vanilla JavaScript starting point. - [three](three/SKILL.md): Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations with a small vanilla JavaScript starting point.
- [tmdb-cli](tmdb-cli/SKILL.md): Search and discover movies, TV shows, and trending content via The Movie Database (TMDb) API v3. Use when the user asks about movies, TV, film, cinema, genres, certifications, ratings, cast, upcoming releases, or trending media. - [tmdb-cli](tmdb-cli/SKILL.md): Search and discover movies, TV shows, and trending content via The Movie Database (TMDb) API v3. Use when the user asks about movies, TV, film, cinema, genres, certifications, ratings, cast, upcoming releases, or trending media.
- [traefik](traefik/SKILL.md): Deploy, configure, and troubleshoot Traefik v3 reverse proxy — covers all providers, routing, TLS/ACME, middlewares, and production patterns with YAML examples. Load when setting up or debugging a Traefik instance. - [traefik](traefik/SKILL.md): Deploy, configure, and troubleshoot Traefik v3 reverse proxy — covers all providers, routing, TLS/ACME, middlewares, and production patterns with YAML examples. Load when setting up or debugging a Traefik instance.
+44
View File
@@ -0,0 +1,44 @@
# Terraform — Operational Skill for Terraform and OpenTofu
Run, inspect, and repair Terraform and OpenTofu infrastructure safely: module structure, state backends and locking, plan/apply workflow, drift detection, remote state, upgrades and refactors, and diagnostics with evidence.
## Why Install This Skill
Your agent can operate a Terraform codebase end to end: understand the module graph, verify the backend and lock are healthy, review a plan before anything is applied, detect and reconcile drift, work with remote state, and plan version upgrades and module refactors without guessing. It ships a small wrapper script (`tfops`) that analyzes state files directly — so an agent can inventory what a state file describes even when it has no terraform binary on the machine.
The references are distilled from the official Terraform and OpenTofu documentation with dated sources, favoring verification commands and explicit mutation gates over copy-paste optimism. Design decisions (which IaC tool, how to structure modules, provider strategy) intentionally route up to `platform-engineering`; this skill owns the day-to-day operation of the tool itself.
## What You Get
| Directory | Purpose |
|---|---|
| `SKILL.md` | Agent-facing operating loop, mutation gates, and verification boundaries |
| `references/` | Eight dated references: modules, state/backends, plan/apply, drift, remote state, upgrades/refactors, diagnostics, source index |
| `scripts/tfops` | Agent-first wrapper: `--json` output, direct state-file analysis, and a `--dry-run`/`--yes`/`--force` mutation gate |
| `tests/` | Deterministic tests plus a bundled fixture state file |
## Quick Start
```bash
# Inventory a state file without any terraform binary installed
bash scripts/tfops doctor --json
bash scripts/tfops plan --state tests/fixtures/fixture-state.json --json
# With terraform (or OpenTofu) installed, in a config directory
terraform init
bash scripts/tfops plan --json
bash scripts/tfops apply --dry-run --json
bash scripts/tfops apply --yes --json # mutation gate: never runs without --yes
```
The `--help` output documents every flag and works without the terraform binary. Set the `TERRAFORM` environment variable to a specific binary (e.g., `tofu`) when both are installed.
## Triggers
Load this skill for `terraform`, `OpenTofu`, `tofu`, `tfstate`, `terraform plan/apply/import`, state backends and locking, drift between config and infrastructure, remote state, Terraform version upgrades, module refactors (`moved` blocks, `state mv`), or any Terraform plan/apply/state error. Do not load it for IaC methodology or cloud design decisions — that is `platform-engineering`.
## Requirements
- Python 3.8+ for the `tfops` script (`--help` and state-file analysis need no other dependency).
- Terraform CLI 1.5+ or OpenTofu CLI 1.6+ only for delegated commands (`plan`, `apply`, `validate`, `import`) against a real backend.
- Backend access (credentials, network) for remote state operations.
+147
View File
@@ -0,0 +1,147 @@
---
name: terraform
description: >-
Operate Terraform and OpenTofu across the whole infrastructure lifecycle:
module structure, state backends and locking, plan/apply workflow, drift
detection, remote state, upgrade and refactor flows, and evidence-based
diagnostics. Use when running or inspecting terraform plans, applies, state
files, imports, or state surgery, or when the bundled tfops script should
handle the task. Do not use for IaC methodology or cloud design decisions -
those route up to platform-engineering.
license: MIT
compatibility: >-
Terraform CLI 1.5+ or OpenTofu CLI 1.6+ for delegated commands; the bundled
tfops script runs on Python 3.8+ and its --help and state-file analysis need
no terraform binary.
metadata:
source: https://developer.hashicorp.com/terraform/docs
spec: https://opentofu.org/docs/
---
# Terraform / OpenTofu Operations
Use this skill to run, inspect, and repair Terraform and OpenTofu infrastructure safely: understand a module graph, verify state backends and locking, review plans before applies, find and fix drift, work with remote state, plan version upgrades and refactors, and diagnose failures with evidence. This is a **tool skill** for one named tool (Terraform and its drop-in OpenTofu fork share one agent workflow and one trigger). Design decisions and IaC methodology belong to [platform-engineering](../platform-engineering/SKILL.md) and its `references/infrastructure-as-code.md`; this skill owns execution.
## Operating contract
1. **Discover before assuming.** Read the module layout, provider requirements, backend block, workspaces, `terraform.lock.hcl`, and CI invocation before running anything. Never infer state from a config file — the state file is the source of truth for what exists.
2. **Plan first, apply after review.** Every mutation goes through a visible plan (or `tfops` dry-run) and an explicit confirmation. Never run `apply` with unreviewed changes.
3. **Lock and scope state.** Confirm the backend supports locking and that the operator holds the lock before any state mutation. State surgery (`state mv`, `state rm`, `state push`) is a reviewed, scoped operation with a backup.
4. **Verify at the boundary.** A green `apply` is not proof of success: verify the external boundary (DNS, load balancer, API response) that the resource was supposed to satisfy, and check for drift on the next plan.
5. **Keep evidence bounded.** Never dump raw state files, backend credentials, or provider secrets into chat. `tfops` redacts nothing by itself but all outputs should be bounded summaries.
## The tfops script
`scripts/tfops` is an agent-first wrapper around the terraform/tofu CLI. It works without a terraform binary for `--help`, `doctor`, and direct `--state` analysis, so an agent can inventory a state file anywhere.
```bash
scripts/tfops doctor --json # binary, config, backend, state availability
scripts/tfops state --state state.json --json # inspect a local state file directly
scripts/tfops plan --state state.json --json # state-level plan summary (no binary needed)
scripts/tfops plan --json # full plan via terraform plan -json
scripts/tfops apply --dry-run --json # preview only, never mutates
scripts/tfops apply --yes --json # mutation: requires --yes
scripts/tfops apply --yes --force --json # bypass the taint/drift guard after review
scripts/tfops import aws_instance.web i-0abc --dry-run
```
Mutation gate: `apply` and `import` refuse to run without `--yes` (exit 2); `--dry-run` previews without mutating; `--force` skips the taint/drift guard after the plan is reviewed. `TERRAFORM` env var overrides binary selection (`terraform` then `tofu` are auto-detected otherwise). Exit codes: 0 ok, 1 analysis/runtime error, 2 gate refusal, 127 binary missing, 124 timeout.
## Operating loop
1. **Inventory**: module tree, provider requirements, backend config, workspaces, lock file, state serial/lineage.
2. **Analyze**: `terraform validate`, `tfops plan --json` (or a state-file summary when the backend is unreachable).
3. **Review**: read the plan as a diff of resources, not a wall of text — count creates/updates/destroys, check replaces (destroy-before-create), spot-check sensitive changes.
4. **Apply**: scoped, confirmed, with a backend lock held; verify the boundary afterwards.
5. **Drift-check**: re-plan after changes and on a schedule; investigate diffs that should not exist.
## Module structure
- One module per unit of composition: inputs (variables), outputs, and resources with a single responsibility. Call modules from a root module; keep the root thin.
- Pin providers (`required_providers`) and module versions; commit `terraform.lock.hcl`.
- Use `for_each`/`count` for repetition, not code generation; use `templatefile` for config injection, and treat provisioners as a last resort.
- Structure conventions and composition patterns live in `references/01-modules-and-structure.md`.
## State backends and locking
- The backend owns state storage and locking. Default `local` backend stores state on disk; remote backends (S3+DynamoDB, GCS, Azure Storage, Terraform Cloud/OpenTofu Cloud, Consul) keep state off disk and enable collaboration.
- Locking prevents concurrent writers: always confirm the lock is held during applies and state surgery. A stale lock blocks operations until released (`force-unlock` only after verifying no other run is active).
- State holds secrets: encrypt the backend at rest, restrict read access, and mark sensitive values `sensitive = true`.
- Backend choice, migration (`terraform init -migrate-state` / `-reconfigure`), and lock troubleshooting: `references/02-state-and-backends.md`.
## Plan/apply workflow
- `plan` reads config + state + provider data and proposes a diff; `apply` realizes it. Treat `plan` output as the contract the apply will fulfill.
- Review destroys and replaces as the highest-risk changes; use `prevent_destroy` and `create_before_destroy` lifecycle rules where recreation is dangerous.
- Use `-target` only for emergencies, never as a habit; `-auto-approve` only inside a reviewed CI/CD gate.
- Full workflow, JSON plan output (`-json`), and review checklists: `references/03-plan-apply-workflow.md`.
## Drift detection
- Drift is the difference between declared config and actual infrastructure. A clean plan is the drift probe: schedule periodic plans and treat unexpected diffs as incidents.
- Distinguish intended drift (out-of-band manual change, external mutation) from unintended (config/state desync, provider bug).
- Remediation is `plan` + reviewed `apply` (reconcile), or `import` when the resource was never managed; never delete-and-recreate as a default reflex.
- `tfops` flags tainted resources in state analysis — those force replacement and should never be applied blind. Methods and cadence: `references/04-drift-detection.md`.
## Remote state
- Remote backends make state shared, durable, and lockable; local state is for experiments only.
- Consume another stack's outputs with `data "terraform_remote_state"` — reference by workspace/environment, never hand-copy outputs.
- The state file is not the delivery artifact: remote state must be protected (encryption, ACLs, audit) and recoverable (versioning, backups, restore drills). Practices: `references/05-remote-state-and-collaboration.md`.
## Upgrade and refactor flows
- Upgrades: read the upgrade guides for the version span, validate with `terraform validate`/`tofu validate`, run a plan, apply in a non-production environment first, and use `terraform state replace-provider` / `state mv` for provider-version or address changes.
- Refactors: rename or restructure resources with `moved` blocks (plan-safe, no state surgery), or reviewed `state mv` when `moved` does not fit; never delete state to force recreation.
- Version/support observations and step-by-step flows: `references/06-upgrades-and-refactors.md`.
## Diagnostics
Diagnose in evidence order: binary/version → config validation → backend + lock status → state serial/lineage → plan diff → apply error → boundary check.
- Lock errors: find the holder (backend-specific) before any `force-unlock`.
- State serial/lineage mismatches: a stale or foreign state; use `state pull`/`state push` only with a backup and reviewed scope.
- `tfops doctor` gathers the first layer of evidence; failure patterns and their probes live in `references/07-diagnostics.md`.
## Reference routing
| Load when | Reference |
|---|---|
| Module design, composition, or structure conventions | `references/01-modules-and-structure.md` |
| Backend choice, migration, or locking problems | `references/02-state-and-backends.md` |
| Planning, applying, or reviewing a change | `references/03-plan-apply-workflow.md` |
| Unexpected config-vs-reality differences | `references/04-drift-detection.md` |
| Shared or cross-stack state | `references/05-remote-state-and-collaboration.md` |
| Version bumps, provider migrations, or module refactors | `references/06-upgrades-and-refactors.md` |
| A failed apply, lock, or state error | `references/07-diagnostics.md` |
| Sources, version observations, and refresh procedure | `references/00-source-index.md` |
## Included artifacts
- `scripts/tfops`: agent-first wrapper (state analysis, plan/apply, gated mutations, JSON output).
- `tests/test_tfops.py` + `tests/fixtures/fixture-state.json`: deterministic tests against a bundled state fixture.
- `references/`: eight dated, source-indexed references covering the operational topics above.
## Verification boundary
| Claim | Minimum evidence |
|---|---|
| Config is valid | `terraform validate` (or `tofu validate`) exit 0 |
| State is readable | `tfops state --state FILE --json` parses and inventories it |
| Plan is safe | Reviewed plan diff with counts of create/update/destroy/replace and no tainted resources applied blind |
| Apply succeeded | Apply exit 0 **plus** the external boundary the resource serves responds correctly |
| No drift | A clean re-plan immediately after apply and on the declared cadence |
## Hard boundaries
- Never expose state files, backend credentials, provider secrets, or `sensitive` output values.
- Never run `apply`, `import`, `state push`, or `force-unlock` without the mutation gate (`--yes` after a reviewed plan, or an explicit human directive).
- Never delete state or a resource just to "fix" drift — reconcile or import.
- Never run a provider-specific procedure without checking the module's `required_providers` and version pins.
## When not to use
- **IaC methodology, tool selection, or cloud design decisions** — route up to [platform-engineering](../platform-engineering/SKILL.md).
- **Cloud provider depth** (AWS/GCP/Azure service-by-service operations) — provider references and platform patterns live under `platform-engineering`; this skill owns the Terraform/OpenTofu tool itself.
- **Ansible, Pulumi, CloudFormation, or CDK** — different tools with their own operational contracts; only Terraform/OpenTofu live here.
- **Designing a new module from scratch** (composition, interfaces, versioning policy) — start from `platform-engineering` methodology, then execute with this skill.
+78
View File
@@ -0,0 +1,78 @@
{
"schema_version": 1,
"skill_name": "terraform",
"evals": [
{
"id": "state-backend-migration",
"prompt": "We keep Terraform state in a local terraform.tfstate file and now two engineers apply to the same directory at the same time. We want to move to a shared remote backend with locking. What is the migration flow and what should we verify before and after?",
"expected_output": "A backend migration plan: choose a remote backend that supports locking (for example S3 with a DynamoDB lock table, GCS, Azure Storage, or a Terraform/OpenTofu cloud), add the backend block, run terraform init with the migration flag so the existing state is copied to the new backend, and verify the lock works by starting a concurrent operation. The response explains that state is a secrets-bearing artifact so the backend must be encrypted at rest and access-restricted, that state push/pull are dangerous manual operations only used with a backup, and that locking prevents concurrent writers while a stale lock may require force-unlock only after confirming no other run is active. Verification is a clean plan from the new backend and a successful lock/unlock cycle.",
"assertions": [
"The migration flow includes choosing a locking-capable backend and terraform init with the migrate flag",
"State is treated as secrets-bearing and the backend is encrypted and access-restricted",
"Locking behavior is verified with a concurrent operation",
"Manual state push/pull are flagged as dangerous and backup-first",
"A clean plan from the new backend is the verification"
]
},
{
"id": "plan-review-before-apply",
"prompt": "Before running apply on a production workspace, the plan shows 3 to create, 2 to change in place, and 1 to replace (destroy then create). The replaced resource is a database instance. How should I review this plan and what should make me stop?",
"expected_output": "A plan-review procedure that treats the replace as the highest-risk item: the response counts creates/updates/destroys/replaces, identifies the database replacement as destroy-before-create and checks the lifecycle rules and backup/restore path before allowing it, and stops if any resource has prevent_destroy, if sensitive values change unexpectedly, if the diff references resources outside the workspace scope, or if the state shows tainted resources being applied blind. The response prescribes running the plan with JSON output for machine review, confirming the state serial and backend lock, and applying only after the plan has been explicitly reviewed, then verifying the external boundary (database reachable, data intact) after apply.",
"assertions": [
"The plan is reviewed as a diff with counts of create/update/destroy/replace",
"The database replacement is flagged as the highest-risk change requiring backup and lifecycle checks",
"Stop conditions include prevent_destroy, unexpected sensitive changes, scope leakage, and blind tainted applies",
"JSON plan output is used for machine review",
"Post-apply verification checks the external boundary and data integrity"
]
},
{
"id": "drift-investigation",
"prompt": "Our terraform plan in CI shows a diff for a security group that nobody remembers changing. The environment was supposed to be untouched this quarter. How do I investigate whether this is real drift and decide what to do?",
"expected_output": "A drift investigation that first proves the diff is real before touching anything: the response compares the current plan against the previous known-good plan and the state serial to rule out a stale state, checks git history and change records for the workspace, and then classifies the cause — an out-of-band manual change to the live security group, a config edit that was never applied, or a state/config desync. The response maps each cause to its remediation: reconcile with a reviewed plan and apply when the desired state is the config, import the resource if it was never managed, and never delete-and-recreate out of reflex. The response prescribes a drift cadence of periodic plans so unexpected diffs are caught before they become incidents, and verification is a clean re-plan after remediation.",
"assertions": [
"The investigation proves the diff is real before changing anything, using state serial and previous plans",
"Git history and change records are checked to rule out a known change",
"Drift causes are classified: out-of-band change, unapplied config, or state desync",
"Remediation maps each cause to reconcile, import, or reviewed apply, never delete-and-recreate reflex",
"A periodic plan cadence is prescribed so unexpected diffs surface early"
]
},
{
"id": "module-structure-review",
"prompt": "A teammate wants to add a new Terraform module for a shared load balancer used by three services. What should the review look for in the module's structure and interfaces before it can be consumed?",
"expected_output": "A module review that checks composition and interface discipline: the module declares required_providers and pins versions, exposes a small set of inputs with defaults and validation, outputs only what consumers need, and uses for_each or count for repetition rather than duplicating resource blocks. The reviewer checks that the module does not hardcode environment-specific values, that it references data sources or remote state only where appropriate, that the lock file is committed, and that the module is versioned by tag for consumption. The response notes that module design methodology belongs to platform-engineering while this review checks the operational execution: valid config, correct interface surface, and a safe consumption path for the three services.",
"assertions": [
"required_providers and version pins are verified",
"Inputs are validated with defaults and outputs expose only the minimal surface",
"for_each or count is preferred over duplicated resource blocks",
"Environment-specific values are not hardcoded and the lock file is committed",
"Modules are consumed by version tag, with design methodology routed to platform-engineering"
]
},
{
"id": "upgrade-and-refactor-flow",
"prompt": "We are on Terraform 1.3 and want to upgrade to the current release, and in the same change rename several resources to follow a new naming convention. What is the safe sequence and where are the traps?",
"expected_output": "A sequenced upgrade-then-refactor plan: first read the upgrade guides for every minor version in the span to collect deprecations and behavior changes, validate and plan in a non-production environment, then apply the upgrade before any refactor so provider and language changes are isolated. For the rename, the response prescribes moved blocks so the plan shows pure renames with no destroy/create, falling back to reviewed state mv only when moved does not fit, and never deleting state to force recreation. The response flags the traps: skipping intermediate upgrade guides, refactoring before upgrading (compounding two change classes), and state surgery without a backup and a held lock. Verification is a clean plan showing renames only, applied in staging first, with drift-free re-plan after.",
"assertions": [
"Upgrade guides are read for the whole version span before anything runs",
"The upgrade is validated and applied in a non-production environment first",
"Renames use moved blocks so the plan shows no destroy/create",
"Refactoring before upgrading is flagged as a compounding-risk trap",
"State surgery requires a backup and held lock, with a clean re-plan as verification"
]
},
{
"id": "diagnose-lock-error",
"prompt": "terraform plan just failed with an error that the state is locked by another operation. There is no CI job running that I know of. What are the next steps, in order, and what must I never do?",
"expected_output": "A lock-error diagnosis in evidence order: first identify the backend and find the lock holder through backend-specific inspection (for example the DynamoDB lock item or the cloud workspace run), check whether a real operation is genuinely in progress, and only after confirming nothing is running unlock the stale lock with the force-unlock command using the lock ID from the error. The response states the hard boundary: never force-unlock while an apply may be running, never delete the lock row blindly, and never bypass locking by switching to the local backend just to run a command. Verification is a successful plan after the lock is cleared and confirming the lock re-engages for the next operation.",
"assertions": [
"The diagnosis finds the lock holder through backend-specific inspection before any action",
"Force-unlock is only used after confirming no operation is genuinely running, with the lock ID from the error",
"Never force-unlock a live apply or delete the lock row blindly",
"Switching to the local backend to bypass locking is forbidden",
"A successful plan with the lock re-engaging is the verification"
]
}
]
}
+54
View File
@@ -0,0 +1,54 @@
# Source index and maintenance contract
Research and verification date: 2026-08-03
## Primary sources
### Terraform (HashiCorp)
- https://developer.hashicorp.com/terraform/docs
- https://developer.hashicorp.com/terraform/cli/commands
- https://developer.hashicorp.com/terraform/language/modules
- https://developer.hashicorp.com/terraform/language/state/backends
- https://developer.hashicorp.com/terraform/language/state/locking
- https://developer.hashicorp.com/terraform/language/state/remote-state-data
- https://developer.hashicorp.com/terraform/cli/commands/plan
- https://developer.hashicorp.com/terraform/internals/json-format
- https://developer.hashicorp.com/terraform/language/modules/develop/refactoring
- https://developer.hashicorp.com/terraform/upgrade-guides
- https://developer.hashicorp.com/terraform/cli/commands/state/mv
- https://developer.hashicorp.com/terraform/cli/commands/import
- https://github.com/hashicorp/terraform/releases
### OpenTofu (Linux Foundation)
- https://opentofu.org/docs/
- https://opentofu.org/docs/language/state/backends/
- https://opentofu.org/docs/language/state/locking/
- https://opentofu.org/docs/language/modules/develop/refactoring/
- https://opentofu.org/docs/cli/commands/
- https://opentofu.org/blog/opentofu-1-12-0/
- https://github.com/opentofu/opentofu/releases
### Ecosystem and operational context
- https://endoflife.date/terraform
- https://endoflife.date/opentofu
- https://www.terraform.io/language/state (state-purpose and remote-state guidance)
## Verified release observations (2026-08-03)
| Product | Observation | Verification |
|---|---|---|
| Terraform | 1.15.x is the current stable line (1.15.8 as of July 2026); 1.16 is in alpha/beta; 1.13 reached EOL 2026-04-29 | Official release pages and eol tracking |
| OpenTofu | 1.12.x is the current line (1.12.0 released 2026-05-14; 1.12.5 as of July 2026); 1.9 reached EOL 2026-05-14 | OpenTofu blog and release pages |
| State format | Terraform state JSON format version 4 (serial and lineage fields) | State file format documentation |
These are dated observations, not promises. Refresh them before asserting a current version or support status.
## Refresh procedure
1. Re-check the official Terraform and OpenTofu release pages and the eol tracking pages above.
2. Compare version lines, deprecations, backend defaults, and upgrade guides against the references in this skill.
3. Record discrepancies before editing guidance; never update a version number without its source URL, retrieval date, and support interpretation.
4. Re-run `terraform/scripts/tfops` tests and the repository validators.
@@ -0,0 +1,31 @@
# Modules and structure
Operational guidance for working with Terraform/OpenTofu module trees. Design methodology (when to split, interface contracts, registry conventions) lives in `platform-engineering`; this file is the execution-side structure playbook.
## Reading a module tree
- Locate the root module (the working directory with the backend/state) and the called modules (`module "name" { source = ... }`).
- Check `required_providers` in each module's `versions.tf` or `terraform.tf` for provider source and version constraints; the root pins what the tree may use.
- `terraform.lock.hcl` records exact provider versions: commit it, and use `terraform providers lock` to add platforms deterministically.
- `terraform graph` / `terraform providers` / `terraform version` give the resolved picture; never guess the provider set from imports alone.
## Composition conventions
- One module per unit of composition: inputs, outputs, resources with a single responsibility. A root module stays thin and wires modules together.
- Use `for_each` (maps) or `count` (indexed lists) for repetition; conditional creation via `count = var.enabled ? 1 : 0`.
- Inject configuration with `templatefile`; read external data via data sources, not `file` at plan time where freshness matters.
- Provisioners (`local-exec`/`remote-exec`) are a last resort: prefer provider-native mechanisms and exit statuses over scripted side effects.
## Interface discipline when operating someone else's module
- Read the variable defaults and validations before changing a call site; a `validation` block tells you the contract the module enforces.
- Prefer module outputs over reaching into the module's internals; referencing an internal resource of another module breaks encapsulation.
- When a module is pinned to a tag or registry version, record which version is in use before any upgrade (see `06-upgrades-and-refactors.md`).
## Sources
> **Last Updated:** 2026-08-03
- Terraform module overview and structure: https://developer.hashicorp.com/terraform/language/modules (accessed 2026-08-03)
- Module development / composition patterns: https://developer.hashicorp.com/terraform/language/modules/develop (accessed 2026-08-03)
- OpenTofu modules documentation: https://opentofu.org/docs/language/modules/ (accessed 2026-08-03)
- Provider lock file mechanics: https://developer.hashicorp.com/terraform/language/dependency-lock (accessed 2026-08-03)
@@ -0,0 +1,39 @@
# State backends and locking
The backend owns state storage and locking. State is the source of truth for what exists, holds sensitive values, and is the thing concurrent operators can corrupt — treat it accordingly.
## Backend selection
- `local` (default): state on disk in the working directory. Fine for experiments; unsafe for any shared environment.
- Object-storage backends with a locking sidecar: S3 + DynamoDB lock table, GCS, Azure Storage. Standard for team use; the lock sidecar is what makes them safe to share.
- Managed state backends: Terraform Cloud / OpenTofu Cloud (or a compatible cloud) — built-in locking, run history, policy hooks.
- Consul: locking natively via Consul sessions; niche but lock-capable.
Backend behavior is implementation-dependent: some backends lock, some do not. Check the backend documentation before trusting locking (the `00-source-index.md` lists backend docs).
## Locking semantics
- `plan` takes a lock only when it needs to (normally plan can run lock-free except with `-refresh-only` and similar); `apply` and `state push` acquire and hold the lock for the whole run.
- A stale lock (crash, killed process) blocks the next run. Resolution is backend-specific: inspect the lock holder, confirm nothing is genuinely running, then `force-unlock <LOCK_ID>` — never delete the lock row blindly and never bypass locking by switching backends.
- Locking does not protect against `state push` misuse: `push` is "extremely dangerous" per upstream docs and should be avoided; it refuses to overwrite a different lineage or a higher serial unless `-force` is used, which itself requires a backup and reviewed scope.
## Migrating between backends
1. Backup the current state first (`terraform state pull > state-backup.json`).
2. Add the new backend block; run `terraform init` — it offers `-migrate-state` (copy) or `-reconfigure` (re-point, no copy). Choose deliberately.
3. Verify: `tfops state --state <pulled> --json` on the migrated state, a clean plan, and a lock/unlock cycle from two concurrent processes.
## State as a secrets-bearing artifact
- Encrypt the backend at rest; restrict read/write to operators that need it.
- Mark sensitive values `sensitive = true` so they are redacted in plan output and logs.
- Enable versioning/soft-delete on the backend so an accidental overwrite is recoverable, and restore-drill it.
## Sources
> **Last Updated:** 2026-08-03
- State storage and locking (Terraform): https://developer.hashicorp.com/terraform/language/state/backends (accessed 2026-08-03)
- State locking (Terraform): https://developer.hashicorp.com/terraform/language/state/locking (accessed 2026-08-03)
- State backends (OpenTofu): https://opentofu.org/docs/language/state/backends/ (accessed 2026-08-03)
- State locking (OpenTofu): https://opentofu.org/docs/language/state/locking/ (accessed 2026-08-03)
- Manual state pull/push warnings: https://developer.hashicorp.com/terraform/language/state/backends#manual-state-pull-push (accessed 2026-08-03)
@@ -0,0 +1,32 @@
# Plan/apply workflow
`plan` proposes; `apply` realizes. The plan output is the contract the apply fulfills — review it as a diff, not a wall of text.
## The loop
1. **Validate** before planning: `terraform validate` / `tofu validate` catches syntax and semantic errors early.
2. **Plan**: `terraform plan -json` (JSON, machine-reviewable) or `terraform plan -out plan.tfplan` (binary, reproducible apply input). `tfops plan --json` wraps the native plan or analyzes a state file directly when the backend is unreachable.
3. **Review**: classify every change — create, update in place, destroy, replace (destroy + create). Replaces are the highest risk; databases and stateful services deserve lifecycle checks (`prevent_destroy`, `create_before_destroy`, backup/restore proof) before approval.
4. **Apply**: `terraform apply plan.tfplan` (reviewed input) or `-auto-approve` only inside a reviewed CI/CD gate. `tfops apply` requires `--yes` and refuses when the analyzed state has tainted resources unless `--force` is given.
5. **Verify**: apply exit 0 is evidence about Terraform only — check the external boundary the resource serves (DNS, endpoint, API) and re-plan to confirm no residual drift.
## Plan-reading rules
- Count creates/updates/destroys/replaces before reading details; a plan with unexpected destroys is a stop condition.
- `~` in-place update, `+` create, `-` destroy, `-/+` replace. A replace is a delete-then-create pair in the diff.
- Sensitive changes show as redacted values — if the diff implies a secret rotation you did not intend, stop and find the cause.
- `-target` narrows a run: acceptable for emergencies, never the default workflow (it leaves the rest of the state unverified).
- Workspaces: `terraform workspace list/select` before planning so the plan is against the right state; plan output should state the workspace.
## JSON plan output
`terraform plan -json` emits NDJSON events (one JSON object per line: version, config, diagnostics, planned changes, resource changes). Useful fields: `resource_changes[*].change.actions` (the action list) and `planned_values`. `tfops` wraps the stream in a single JSON envelope for stable agent consumption.
## Sources
> **Last Updated:** 2026-08-03
- Terraform plan command: https://developer.hashicorp.com/terraform/cli/commands/plan (accessed 2026-08-03)
- Terraform apply command: https://developer.hashicorp.com/terraform/cli/commands/apply (accessed 2026-08-03)
- JSON output format: https://developer.hashicorp.com/terraform/internals/json-format (accessed 2026-08-03)
- OpenTofu CLI commands: https://opentofu.org/docs/cli/commands/ (accessed 2026-08-03)
- Lifecycle rules (`create_before_destroy`, `prevent_destroy`): https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle (accessed 2026-08-03)
@@ -0,0 +1,37 @@
# Drift detection
Drift is the difference between declared configuration and actual infrastructure. The plan is the drift probe: a plan that shows changes against a supposedly stable environment is drift, whether intended or not.
## Proving the diff is real
Before remediating, prove the diff is real:
1. Check the state serial and lineage (`tfops state --state FILE --json` shows both): a stale serial means the local state is behind the backend, not that the world changed.
2. Compare against the previous known-good plan for the same workspace.
3. Check git history and change records: was the config touched? Was a plan applied that CI never recorded?
4. Only then classify the cause:
- **Out-of-band manual change**: someone changed the live resource (console, another tool). Config is the desired state — reconcile with a reviewed apply.
- **Unapplied config change**: config was edited but never applied. Apply it deliberately after review.
- **State/config desync**: resource was created outside Terraform and never imported, or state was edited. Import the resource (`terraform import` / `tfops import`) instead of delete-and-recreate.
## Cadence and automation
- Schedule periodic plans (CI cron or a drift-detection run) and treat unexpected diffs as incidents with owners.
- Cloud-hosted runs (Terraform Cloud/OpenTofu Cloud) can run drift detection on a schedule and notify; self-managed teams build the cron equivalent with `plan -refresh-only` or plain plans.
- After any remediation, re-plan to confirm the diff is gone; a clean plan is the drift-free proof.
## Remediation rules
- Reconcile with plan + reviewed apply. Never delete-and-recreate as a default reflex — a resource's data may be irreplaceable.
- Import-before-manage: adopt pre-existing resources with `import` rather than deleting and rebuilding.
- Tainted resources (`tfops` lists them under `tainted`) force replacement on the next apply: review why they were tainted before applying, and never apply them blind.
- Distinguish intended drift (deliberate out-of-band action with a record) from incidents; both still end with a clean plan or a documented, reviewed exception.
## Sources
> **Last Updated:** 2026-08-03
- Terraform state purpose and refresh: https://developer.hashicorp.com/terraform/language/state (accessed 2026-08-03)
- Import command: https://developer.hashicorp.com/terraform/cli/commands/import (accessed 2026-08-03)
- Drift detection in Terraform Cloud: https://developer.hashicorp.com/terraform/cloud-docs/workspaces/drift-detection (accessed 2026-08-03)
- OpenTofu state documentation: https://opentofu.org/docs/language/state/ (accessed 2026-08-03)
- IaC review and drift-baseline checklist (methodology): `platform-engineering/templates/iac-review-record.md` (accessed 2026-08-03)
@@ -0,0 +1,30 @@
# Remote state and collaboration
Remote backends make state shared, durable, lockable, and recoverable. The rules below keep multi-operator and cross-stack workflows safe.
## Operating with a remote backend
- `terraform init` once per backend config; it stores the backend configuration locally. `-reconfigure` re-points to a new backend without migrating data; `-migrate-state` copies existing state.
- All commands that touch state acquire the backend lock; verify locking works before trusting a multi-operator workflow (see `02-state-and-backends.md`).
- The working directory keeps only config and lock files; state lives in the backend. If you find a `terraform.tfstate` file next to a remote backend config, that is a migration mistake or a leftover — investigate, do not delete.
## Cross-stack consumption
- Read another stack's outputs with `data "terraform_remote_state"` in the consuming workspace, selecting by workspace name.
- Never hand-copy output values into config; the data source keeps the reference live and the dependency explicit.
- Document the producer/consumer relationship: a consumer makes the producer's state a dependency of its own applies.
## Protection and recovery
- Encrypt at rest; scope IAM/ACLs so only operators who must read state can; audit access.
- Enable backend versioning/soft-delete and restore-drill it: recovery of a corrupted or overwritten state file is a drill, not a hope.
- `terraform state pull` / `terraform state push` are the manual escape hatches: pull for backup and inspection, push only for reviewed fixups with lineage/serial protection understood (see `02-state-and-backends.md`).
## Sources
> **Last Updated:** 2026-08-03
- Remote state data source (Terraform): https://developer.hashicorp.com/terraform/language/state/remote-state-data (accessed 2026-08-03)
- Backends overview: https://developer.hashicorp.com/terraform/language/settings/backends/configuration (accessed 2026-08-03)
- State pull/push commands: https://developer.hashicorp.com/terraform/cli/commands/state/pull (accessed 2026-08-03)
- OpenTofu remote state: https://opentofu.org/docs/language/state/remote-state-data/ (accessed 2026-08-03)
- State management patterns (methodology): `platform-engineering/references/infrastructure-as-code.md` (accessed 2026-08-03)
@@ -0,0 +1,33 @@
# Upgrades and refactors
Upgrades change the tool/provider contract; refactors change the module structure. Do them one at a time, in a controlled order, with a plan review between every step.
## Version upgrades
1. **Read the upgrade guides for the whole span** — each minor version from current to target (Terraform upgrade guides; OpenTofu publishes its own). Collect deprecations, default changes, and behavior changes.
2. **Validate** in place first: `terraform validate` / `tofu validate` and a plan show whether the current config is compatible with the current binary before you change anything.
3. **Stage**: upgrade the tool in a non-production environment (or a clone workspace), run plan, apply, and verify before touching production.
4. **Provider migrations** within an upgrade: `terraform state replace-provider` handles provider source/version moves (e.g., moving to a namespaced provider); run it with the state backed up and a lock held.
5. **Rollback**: the tool binary can be downgraded within supported bounds, and the state file is version-agnostic at the format level — keep the previous binary available until the new version has applied cleanly.
## Refactors (renames and restructures)
- Prefer `moved` blocks: they make the plan show pure renames (no destroy/create), keep the state change explicit, and are reviewable in the diff. This is the default for renaming resources or modules.
- Fall back to reviewed `terraform state mv` only when `moved` does not fit (e.g., migrating between backends/workspaces); each `state mv` is a state mutation and needs a backup and a held lock.
- Never delete state entries to force recreation of a resource that exists; that loses the resource's data and identity.
- Sequence: upgrade first, then refactor — two change classes compounding is the classic trap (a failed refactor is then blamed on the upgrade and vice versa).
## Verification
- After each step: `terraform validate`, a clean plan (renames show as renames, not replaces), apply in staging, and a drift-free re-plan.
- `tfops plan --state FILE --json` gives a quick state-level sanity check (serial, lineage, resource inventory) before and after refactors.
## Sources
> **Last Updated:** 2026-08-03
- Terraform upgrade guides: https://developer.hashicorp.com/terraform/upgrade-guides (accessed 2026-08-03)
- Refactoring module resources (`moved` blocks): https://developer.hashicorp.com/terraform/language/modules/develop/refactoring (accessed 2026-08-03)
- `terraform state mv`: https://developer.hashicorp.com/terraform/cli/commands/state/mv (accessed 2026-08-03)
- `terraform state replace-provider`: https://developer.hashicorp.com/terraform/cli/commands/state/replace-provider (accessed 2026-08-03)
- OpenTofu refactoring documentation: https://opentofu.org/docs/language/modules/develop/refactoring/ (accessed 2026-08-03)
- Version observations (current lines): see `00-source-index.md` (accessed 2026-08-03)
+57
View File
@@ -0,0 +1,57 @@
# Diagnostics
Diagnose in evidence order: binary/version → config validation → backend + lock status → state serial/lineage → plan diff → apply error → boundary check. Each layer narrows the next; skipping layers produces guesswork.
## First-response diagnostic (tfops)
```bash
scripts/tfops doctor --json # binary, version, config files, backend hint, optional state summary
scripts/tfops plan --state state.json --json # state-level inventory without a binary
scripts/tfops plan --json # real plan against the backend (needs binary + backend access)
```
`doctor` tells you whether the failure is environmental (no binary, no config) or state-related before you interpret any deeper error.
## Failure patterns
### Lock errors (`Error acquiring the state lock`)
- Find the holder through backend-specific inspection (the DynamoDB lock item, the cloud workspace run, the Consul session).
- Confirm no operation is genuinely running; only then `force-unlock <LOCK_ID>` with the ID from the error.
- Never delete the lock row blindly; never bypass locking by switching to the local backend to run a command.
### Serial/lineage mismatches (`Error: state file in path does not match the given serial` or lineage errors)
- The local/backup state and the live state diverged. Use `state pull` to see the live state, compare serials, and restore from a known-good backup after review — never overwrite a newer serial.
### `terraform init` backend errors
- Verify backend configuration, credentials/identity, and network path. `-reconfigure` re-reads the backend block; a changed backend without `-migrate-state`/`-reconfigure` is the usual trigger.
### Plan/apply errors mid-run
- Read the resource-level error, not just the summary; providers give actionable messages (API errors, quota, IAM).
- A partial apply leaves state consistent but the resource may not exist: re-plan to see what remains, then apply again.
- Timeout or "context deadline exceeded": check the provider's operation timeout and the backend/network; bounded retries beat immediate blind re-apply.
### Validate errors
- `terraform validate` diagnostics point at file/line; fix config errors before planning. Tainted resources (`tfops` reports them) are not a validate error — they are a plan/apply concern.
## Verification boundary
| Diagnosis | Minimum evidence |
|---|---|
| Binary healthy | `terraform version` / `tofu version` exit 0 |
| Config valid | `validate` exit 0 with no diagnostics |
| Backend reachable | `init` exit 0; lock acquired and released |
| State intact | `tfops state --state FILE --json` parses; serial/lineage match backend |
| Root cause fixed | The originally failing operation succeeds, then a clean re-plan |
## Sources
> **Last Updated:** 2026-08-03
- Terraform troubleshooting guide: https://developer.hashicorp.com/terraform/tutorials/configuration-language/troubleshooting-workflow (accessed 2026-08-03)
- State locking and force-unlock: https://developer.hashicorp.com/terraform/language/state/locking (accessed 2026-08-03)
- Common error messages: https://developer.hashicorp.com/terraform/internals/error-messages (accessed 2026-08-03)
- OpenTofu CLI reference (diagnostics): https://opentofu.org/docs/cli/ (accessed 2026-08-03)
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""tfops - agent-first operational wrapper for Terraform and OpenTofu.
This wrapper delegates lifecycle semantics to the terraform (or tofu) binary
when one is available, and adds bounded JSON output, explicit mutation
confirmation, direct state-file analysis, and deterministic exit behavior.
--help and --state analysis work with no terraform binary installed, so an
agent can inventory and inspect state files anywhere.
Commands
--------
doctor Report binary, config, backend, and state availability.
validate Run `terraform validate` (JSON) when a binary exists.
plan Direct state-file analysis with --state, or `terraform plan -json`.
state Inspect a local state file directly (no binary required).
apply Apply infrastructure changes (mutation: requires --yes).
import Import an existing resource into state (mutation: requires --yes).
Exit codes: 0 ok, 1 analysis/runtime error, 2 usage or gate refusal,
127 required binary not found, 124 delegate timeout.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from typing import Any, Optional
STATE_FORMAT_VERSIONS = {"1.0", "1.1", "1.2", "1.3"}
MUTATING_COMMANDS = {"apply", "import"}
COMMON_DEFAULTS = {
"json": False,
"dry_run": False,
"yes": False,
"force": False,
"state": None,
"timeout": 60,
}
def emit(payload: Any, as_json: bool) -> None:
"""Write a payload to stdout; JSON when --json, readable text otherwise."""
if as_json:
print(json.dumps(payload, indent=2, sort_keys=True))
return
if isinstance(payload, str):
print(payload)
return
lines = []
for key, value in payload.items():
if isinstance(value, (list, dict)) and value:
lines.append(f"{key}: {json.dumps(value, sort_keys=True)}")
else:
lines.append(f"{key}: {value}")
print("\n".join(lines))
def find_binary() -> Optional[str]:
"""Locate a terraform-compatible binary: TERRAFORM env wins, then tofu, then terraform."""
override = os.environ.get("TERRAFORM")
if override:
return override if os.path.exists(override) else None
for candidate in ("tofu", "terraform"):
located = shutil.which(candidate)
if located:
return located
return None
def run_delegate(binary: str, parts: list[str], args: argparse.Namespace) -> int:
"""Run a terraform/tofu command and wrap its output in the JSON envelope."""
command = [binary] + parts
try:
proc = subprocess.run(command, capture_output=True, text=True, timeout=args.timeout)
except subprocess.TimeoutExpired:
emit(
{
"ok": False,
"error": f"{binary} timed out",
"timeout_seconds": args.timeout,
"command": parts,
},
args.json,
)
return 124
payload: dict[str, Any] = {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"command": parts,
}
if proc.stdout:
payload["stdout"] = proc.stdout
if proc.stderr:
payload["stderr"] = proc.stderr
emit(payload, args.json)
return proc.returncode
def resource_address(resource: dict[str, Any]) -> str:
parts = [part for part in (resource.get("module"), resource.get("type"), resource.get("name")) if part]
return ".".join(parts)
def analyze_state(path: str) -> dict[str, Any]:
"""Parse a Terraform state file and return a bounded inventory summary."""
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("state file root must be a JSON object")
fmt = data.get("format_version")
if fmt is not None and str(fmt) not in STATE_FORMAT_VERSIONS:
raise ValueError(f"unsupported state format_version {fmt!r}")
if "resources" not in data:
raise ValueError("missing 'resources' key; not a Terraform state document")
resources = data.get("resources", [])
if not isinstance(resources, list):
raise ValueError("state 'resources' must be a list")
managed = [r for r in resources if r.get("mode") == "managed"]
datas = [r for r in resources if r.get("mode") == "data"]
modules = sorted({(r.get("module") or "root") for r in resources})
providers = sorted({r.get("provider", "") for r in resources if r.get("provider")})
tainted = [
resource_address(r)
for r in resources
for inst in r.get("instances", [])
if isinstance(inst, dict) and inst.get("status") == "tainted"
]
return {
"format_version": fmt,
"terraform_version": data.get("terraform_version"),
"serial": data.get("serial"),
"lineage": data.get("lineage"),
"resource_count": len(resources),
"managed_resources": len(managed),
"data_resources": len(datas),
"modules": modules,
"providers": providers,
"tainted": tainted,
"outputs": sorted(data.get("outputs", {}).keys()),
}
def inspect_state(path: str) -> dict[str, Any]:
"""Detailed per-resource state listing for the `state` subcommand."""
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
resources = []
for resource in data.get("resources", []):
instances = resource.get("instances", [])
addresses = [resource_address(resource) + (f"[{i}]" if len(instances) > 1 else "") for i in range(len(instances))]
statuses = sorted({inst.get("status", "ok") for inst in instances if isinstance(inst, dict)})
resources.append(
{
"address": resource_address(resource),
"mode": resource.get("mode", "managed"),
"type": resource.get("type"),
"name": resource.get("name"),
"provider": resource.get("provider"),
"instance_count": len(instances),
"instance_addresses": addresses,
"statuses": statuses,
}
)
return {"resources": resources, "resource_count": len(resources)}
def load_state(args: argparse.Namespace) -> tuple[Optional[dict[str, Any]], Optional[str]]:
"""Load and analyze the state file named by --state; return (payload, error)."""
path = args.state
if not path:
return None, "plan --state requires a state file path (--state FILE)"
try:
return analyze_state(path), None
except (OSError, json.JSONDecodeError) as error:
return None, f"state file {path} is not readable JSON: {error}"
except ValueError as error:
return None, f"state file {path} is not a Terraform state: {error}"
def mutation_refusal(command: str, as_json: bool) -> None:
emit(
{
"ok": False,
"error": f"{command} is a mutation and requires --yes",
"hint": "Run with --dry-run first to preview, then repeat with --yes after reviewing scope.",
},
as_json,
)
def cmd_doctor(args: argparse.Namespace) -> int:
binary = find_binary()
missing = f"{os.environ.get('TERRAFORM') or 'terraform'} (not found)"
payload: dict[str, Any] = {
"ok": True,
"binary_found": binary is not None,
"binary": binary or missing,
}
if binary:
version_proc = subprocess.run([binary, "version"], capture_output=True, text=True, timeout=args.timeout)
payload["version_output"] = (version_proc.stdout or version_proc.stderr).strip()
config_files = sorted(
name
for name in os.listdir(os.getcwd())
if name.endswith((".tf", ".tf.json")) and os.path.isfile(name)
)
payload["config_files"] = config_files
payload["backend_hint"] = (
"inspect a backend block with terraform backend config or the reference "
"references/02-state-and-backends.md"
if config_files
else "no .tf config files in the working directory"
)
if args.state:
state, error = load_state(args)
if error:
payload["state_error"] = error
else:
payload["state"] = state
emit(payload, args.json)
return 0
def cmd_validate(args: argparse.Namespace) -> int:
binary = find_binary()
if not binary:
emit(
{
"ok": False,
"error": "terraform/tofu not found",
"hint": "Install terraform or OpenTofu, or set TERRAFORM to a compatible binary.",
},
args.json,
)
return 127
return run_delegate(binary, ["validate", "-json", "-input=false"], args)
def cmd_plan(args: argparse.Namespace) -> int:
if args.state:
state, error = load_state(args)
if error:
emit({"ok": False, "error": error, "command": "plan"}, args.json)
return 1
payload: dict[str, Any] = {"ok": True, "command": "plan", "state_file": args.state, "plan": state}
payload["summary"] = (
f"{state['resource_count']} resources ({state['managed_resources']} managed, "
f"{state['data_resources']} data) across {len(state['modules'])} module(s); "
f"{len(state['tainted'])} tainted resource(s) require planned replacement; "
"a full plan requires configuration plus backend state access"
)
emit(payload, args.json)
return 0
binary = find_binary()
if not binary:
emit(
{
"ok": False,
"error": "terraform/tofu not found; provide --state FILE to analyze a state file directly",
"hint": "Install terraform or OpenTofu, or set TERRAFORM to a compatible binary.",
},
args.json,
)
return 127
return run_delegate(binary, ["plan", "-json", "-input=false", "-no-color"], args)
def cmd_state(args: argparse.Namespace) -> int:
if not args.state:
emit({"ok": False, "error": "state requires --state FILE", "hint": "Pass --state to the local state file to inspect."}, args.json)
return 2
try:
detail = inspect_state(args.state)
except (OSError, json.JSONDecodeError, ValueError) as error:
emit({"ok": False, "error": f"state file {args.state} is not readable: {error}", "command": "state"}, args.json)
return 1
payload = {"ok": True, "command": "state", "state_file": args.state, **detail}
emit(payload, args.json)
return 0
def cmd_apply(args: argparse.Namespace) -> int:
if args.dry_run:
if args.state:
state, error = load_state(args)
if error:
emit({"ok": False, "error": error, "command": "apply"}, args.json)
return 1
payload = {
"ok": True,
"command": "apply",
"dry_run": True,
"state_file": args.state,
"would_apply": state["managed_resources"],
"would_replace": state["tainted"],
"note": "dry-run preview only; no changes applied",
}
emit(payload, args.json)
return 0
binary = find_binary()
if not binary:
emit(
{"ok": False, "error": "terraform/tofu not found; --dry-run without --state needs a binary"},
args.json,
)
return 127
return run_delegate(binary, ["plan", "-json", "-input=false", "-no-color"], args)
if not args.yes:
mutation_refusal("apply", args.json)
return 2
if args.state:
state, error = load_state(args)
if error:
emit({"ok": False, "error": error, "command": "apply"}, args.json)
return 1
if state["tainted"] and not args.force:
emit(
{
"ok": False,
"error": "state contains tainted resources; refusing to apply without a reviewed plan",
"tainted": state["tainted"],
"hint": "Run a plan first, or pass --force to proceed despite the taint guard.",
},
args.json,
)
return 2
binary = find_binary()
if not binary:
emit({"ok": False, "error": "terraform/tofu not found", "hint": "Install terraform or OpenTofu, or set TERRAFORM."}, args.json)
return 127
return run_delegate(binary, ["apply", "-auto-approve", "-json", "-input=false", "-no-color"], args)
def cmd_import(args: argparse.Namespace) -> int:
if args.dry_run:
emit(
{
"ok": True,
"command": "import",
"dry_run": True,
"address": args.address,
"id": args.id,
"note": "dry-run preview only; nothing imported",
},
args.json,
)
return 0
if not args.yes:
mutation_refusal("import", args.json)
return 2
binary = find_binary()
if not binary:
emit({"ok": False, "error": "terraform/tofu not found", "hint": "Install terraform or OpenTofu, or set TERRAFORM."}, args.json)
return 127
return run_delegate(binary, ["import", "-json", "-input=false", args.address, args.id], args)
def add_common(parser: argparse.ArgumentParser) -> None:
"""Attach global flags with SUPPRESS defaults so values survive subcommand parsing."""
parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="Emit a structured JSON result on stdout")
parser.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS, help="Preview only; never mutate state")
parser.add_argument("--yes", action="store_true", default=argparse.SUPPRESS, help="Confirm a mutating operation (apply, import)")
parser.add_argument("--force", action="store_true", default=argparse.SUPPRESS, help="Bypass the taint/drift guard on mutations")
parser.add_argument("--state", default=argparse.SUPPRESS, help="Local state file to analyze directly (no binary required)")
parser.add_argument("--timeout", type=int, default=argparse.SUPPRESS, help="Delegate command timeout in seconds")
def build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
add_common(common)
parser = argparse.ArgumentParser(
prog="tfops",
description=(
"Agent-first operational wrapper for Terraform and OpenTofu: state analysis, "
"plan/apply workflow, diagnostics, and gated mutations with JSON output."
),
)
add_common(parser)
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
doctor = sub.add_parser("doctor", parents=[common], help="Report binary, config, backend, and state availability")
doctor.set_defaults(handler=cmd_doctor)
validate = sub.add_parser("validate", parents=[common], help="Run terraform validate (JSON)")
validate.set_defaults(handler=cmd_validate)
plan = sub.add_parser("plan", parents=[common], help="Preview a plan: state analysis (--state) or terraform plan -json")
plan.set_defaults(handler=cmd_plan)
apply = sub.add_parser("apply", parents=[common], help="Apply infrastructure changes (mutation: requires --yes)")
apply.set_defaults(handler=cmd_apply)
state = sub.add_parser("state", parents=[common], help="Inspect a local state file directly (no binary required)")
state.set_defaults(handler=cmd_state)
imp = sub.add_parser("import", parents=[common], help="Import an existing resource into state (mutation: requires --yes)")
imp.add_argument("address", help="Resource address to import into, e.g. aws_instance.web")
imp.add_argument("id", help="Provider-side ID of the existing resource")
imp.set_defaults(handler=cmd_import)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
for dest, default in COMMON_DEFAULTS.items():
if not hasattr(args, dest):
setattr(args, dest, default)
return args.handler(args)
if __name__ == "__main__":
sys.exit(main())
+84
View File
@@ -0,0 +1,84 @@
{
"version": 4,
"terraform_version": "1.9.5",
"serial": 7,
"lineage": "5f6a2c8e-9d4b-4b1f-8d3e-1a2b3c4d5e6f",
"outputs": {
"instance_id": {
"value": "i-0abc123def456",
"type": "string"
}
},
"resources": [
{
"module": "module.vpc",
"mode": "managed",
"type": "aws_vpc",
"name": "main",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "vpc-12345678",
"cidr_block": "10.0.0.0/16"
},
"sensitive_attributes": []
}
]
},
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "i-0abc123def456",
"ami": "ami-0abcdef1234567890",
"instance_type": "t3.micro"
},
"sensitive_attributes": []
}
]
},
{
"mode": "managed",
"type": "aws_instance",
"name": "db",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "i-0def456abc789123",
"ami": "ami-0abcdef1234567890",
"instance_type": "m5.large"
},
"sensitive_attributes": [],
"status": "tainted"
}
]
},
{
"mode": "data",
"type": "aws_availability_zones",
"name": "available",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"names": [
"us-east-1a",
"us-east-1b"
]
},
"sensitive_attributes": []
}
]
}
]
}
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Deterministic tests for the terraform/scripts/tfops wrapper.
Runs the script as a subprocess so the tests exercise the real CLI surface
(--help, --json, mutation gate, state-file analysis). No terraform binary is
required; the TERRAFORM environment variable can point at a fake binary for
delegate-path coverage.
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "tfops"
FIXTURE = ROOT / "tests" / "fixtures" / "fixture-state.json"
def run_script(*args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
env=env,
timeout=30,
)
class HelpTests(unittest.TestCase):
def test_help_exits_zero_without_binary(self):
proc = run_script("--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("--json", proc.stdout)
for flag in ("--dry-run", "--yes", "--force"):
self.assertIn(flag, proc.stdout)
def test_subcommand_help_exits_zero(self):
for command in ("doctor", "validate", "plan", "apply", "state", "import"):
proc = run_script(command, "--help")
self.assertEqual(proc.returncode, 0, command)
self.assertIn("--json", proc.stdout)
class StateAnalysisTests(unittest.TestCase):
def test_plan_state_json_is_parseable(self):
proc = run_script("plan", "--state", str(FIXTURE), "--json")
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertTrue(payload["ok"])
self.assertEqual(payload["plan"]["resource_count"], 4)
self.assertEqual(payload["plan"]["managed_resources"], 3)
self.assertEqual(payload["plan"]["data_resources"], 1)
self.assertIn("module.vpc", payload["plan"]["modules"])
self.assertEqual(payload["plan"]["tainted"], ["aws_instance.db"])
def test_state_json_lists_resources(self):
proc = run_script("state", "--state", str(FIXTURE), "--json")
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertEqual(payload["resource_count"], 4)
addresses = {r["address"] for r in payload["resources"]}
self.assertIn("module.vpc.aws_vpc.main", addresses)
self.assertIn("aws_instance.web", addresses)
def test_plan_rejects_non_state_file_as_json(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
handle.write("{\"not\": \"state\"}")
bad_path = handle.name
try:
proc = run_script("plan", "--state", bad_path, "--json")
finally:
os.unlink(bad_path)
self.assertEqual(proc.returncode, 1)
json.loads(proc.stdout) # error path still emits parseable JSON
class MutationGateTests(unittest.TestCase):
def test_apply_requires_yes(self):
proc = run_script("apply", "--state", str(FIXTURE), "--json")
self.assertEqual(proc.returncode, 2)
payload = json.loads(proc.stdout)
self.assertFalse(payload["ok"])
self.assertIn("--yes", payload["error"])
def test_apply_dry_run_previews_without_mutating(self):
proc = run_script("apply", "--state", str(FIXTURE), "--dry-run", "--json")
self.assertEqual(proc.returncode, 0)
payload = json.loads(proc.stdout)
self.assertTrue(payload["ok"])
self.assertTrue(payload["dry_run"])
def test_apply_taint_guard_refuses_without_force(self):
proc = run_script("apply", "--state", str(FIXTURE), "--yes", "--json")
self.assertEqual(proc.returncode, 2)
payload = json.loads(proc.stdout)
self.assertIn("tainted", payload["error"])
self.assertEqual(payload["tainted"], ["aws_instance.db"])
def test_apply_taint_guard_skipped_with_force(self):
fake = FakeTerraformBinary()
try:
proc = run_script(
"apply", "--state", str(FIXTURE), "--yes", "--force", "--json",
env_extra={"TERRAFORM": fake.path},
)
finally:
fake.cleanup()
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertTrue(payload["ok"])
self.assertIn("apply", payload["command"])
def test_import_requires_yes(self):
proc = run_script("import", "aws_instance.web", "i-0abc123def456", "--json")
self.assertEqual(proc.returncode, 2)
payload = json.loads(proc.stdout)
self.assertIn("--yes", payload["error"])
def test_import_dry_run_previews(self):
proc = run_script("import", "aws_instance.web", "i-0abc123def456", "--dry-run", "--json")
self.assertEqual(proc.returncode, 0)
payload = json.loads(proc.stdout)
self.assertTrue(payload["dry_run"])
class DelegatePathTests(unittest.TestCase):
def test_plan_without_state_requires_binary(self):
proc = run_script("plan", "--json", env_extra={"TERRAFORM": "/nonexistent/tf"})
self.assertEqual(proc.returncode, 127)
payload = json.loads(proc.stdout)
self.assertFalse(payload["ok"])
def test_doctor_reports_missing_binary(self):
proc = run_script("doctor", "--json", env_extra={"TERRAFORM": "/nonexistent/tf"})
self.assertEqual(proc.returncode, 0)
payload = json.loads(proc.stdout)
self.assertTrue(payload["ok"])
self.assertFalse(payload["binary_found"])
def test_validate_delegates_to_fake_binary(self):
fake = FakeTerraformBinary()
try:
proc = run_script("validate", "--json", env_extra={"TERRAFORM": fake.path})
finally:
fake.cleanup()
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertTrue(payload["ok"])
self.assertIn("validate", " ".join(payload["command"]))
class FakeTerraformBinary:
"""A fake terraform binary that answers version/validate/apply/plan calls."""
def __init__(self) -> None:
self._dir = tempfile.mkdtemp(prefix="tfops-fake-")
self.path = os.path.join(self._dir, "terraform")
with open(self.path, "w", encoding="utf-8") as handle:
handle.write(
"#!/usr/bin/env bash\n"
"set -e\n"
'printf "Terraform v1.15.8 (fake)\\n"\n'
'if [ "$1" = "validate" ]; then exit 0; fi\n'
'if [ "$1" = "apply" ]; then exit 0; fi\n'
'if [ "$1" = "plan" ]; then printf "no changes\\n"; exit 0; fi\n'
'if [ "$1" = "import" ]; then exit 0; fi\n'
)
os.chmod(self.path, 0o755)
def cleanup(self) -> None:
for entry in os.listdir(self._dir):
os.unlink(os.path.join(self._dir, entry))
os.rmdir(self._dir)
if __name__ == "__main__":
unittest.main()