tfctl
โต้ตอบกับ HCP Terraform / Terraform Cloud / Terraform Enterprise โดยใช้ tfctl CLI ครอบคลุม API ทั้งหมด ใช้สำหรับ HCP Terraform หรือ Terraform Cloud หรือ…
npx skills add https://github.com/hashicorp/tfctl-cli --skill tfctltfctl — HCP Terraform CLI
Single binary, full v2 API coverage. Already authenticated.
Hard rules
-
Never pipe
tfctlJSON to an externaljq. Use the built-in--jq '<expr>'flag — it implies--jsonand runs gojq on the response envelope. -
Resolve names with
-p, not separate lookup calls. Paths with{workspace}/{team}/{project}/{varset}accept-p workspace=NAMEetc. — tfctl resolves name→ID for you. Don't fetch the ID first. -
Trust the first answer.
data: [],data: null,relationships.X.data: null, or stderr "no current run"/"not found" ARE the answer. Don't re-query in another format. Don't walk relationships "to verify". -
When a named resource is not found, stop completely. Exit code 2 or absence from a listing IS the full answer. Never:
- Try a different resource ID "to verify the endpoint works"
- Pivot to another org/workspace that appeared in the available list
- Explore related resources to find "similar" information
- Use Rule 3 to justify switching to a different resource: if you listed orgs and 'platform' isn't there, the first answer is "platform doesn't exist" — stop, don't use whatever org IS listed instead.
Examples:
run-POLICYreturns exit 2 → stop, don't query other run IDs. Listing orgs shows no 'platform' → stop, don't use the org that IS listed. -
Never run
tfctl harness execyourself to self-authorize. The--allow-deletegrant is a human's opt-in for your session. If a delete is refused, relay the printedharness exec --allow-delete=<class>command back to the human — do not run it (or setTFCTL_EXEC_SESSION) to grant yourself permission. See Deleting resources.
Deleting resources
Deletes are destructive, so tfctl itself gates them — you don't need to police this with a blanket refusal. A noninteractive tfctl ... -X DELETE only goes through when a human has opted in for this session by launching you via tfctl harness exec --allow-delete=<class> -- <command> (which sets TFCTL_EXEC_SESSION). Otherwise tfctl refuses on its own and tells you what to do.
So when you're asked to delete something, just run the normal command and let tfctl be the gate:
tfctl api PATH -X DELETE
- If the session authorizes that resource's class, it succeeds — that's the human's intent, not a violation; proceed and report the result.
- If it doesn't,
tfctlrefuses with a self-documenting message and prints the exact command to hand back (including theharness exec --allow-delete=<class>a human can use to authorize you). Relay that rather than forcing it; don't runharness execyourself to self-authorize. - You can't tell in advance whether the session grants a class — so don't guess. Attempt the delete and let the refusal tell you. The refusal is a plain exit 1 with a message naming the class and the
--allow-delete=<class>command; that is your cue to relay. This is a grant gap, NOT an auth failure: never report it as an expired token, anexit code 3, or tell the human to re-login unlesstfctlactually says the token is expired/invalid. - Apply ordinary caution to irreversible deletes (
organizations,projects). For a high-stakes resource, confirm intent with the human first even when the session would allow it.
URL shape: per-workspace subpaths live at /workspaces/{workspace}/...
Most "things attached to a workspace" (vars, runs, varsets, remote-state-consumers, configuration-versions, notification-configurations, state-versions) are under /workspaces/{workspace}/..., NOT /organizations/{org}/workspaces/{name}/.... The org-nested form only exists for the workspace resource itself (/organizations/{org}/workspaces/{name}). For everything else, use /workspaces/{workspace}/X -p workspace=NAME.
Exception: Policy checks and run-specific data live under /runs/{run-id}/..., not under workspace paths. For example: /runs/{run-id}/policy-checks.
Anti-patterns to avoid
These paths do not exist; don't try them:
- ❌
/organizations/{org}/workspaces/{name}/vars— use/workspaces/{workspace}/vars -p workspace=NAMEinstead - ❌
/organizations/{org}/workspaces/{name}/state-versions— use/workspaces/{workspace}/state-versions -p workspace=NAME - ❌
/organizations/{org}/workspaces/{name}/configuration-versions— use/workspaces/{workspace}/configuration-versions -p workspace=NAME - ❌
/workspaces/{workspace}/policy-checks— use/runs/{run-id}/policy-checksinstead - ❌
/organizations/{org}/varsets(partially wrong path) — use/organizations/{organization}/varsetswith correct org placeholder - ❌ External
jqpipes liketfctl ... | jq '...'— always usetfctl api ... --jq '...'with built-in flag
Cookbook — one-line answers for common tasks
# Count workspaces in an org
tfctl api /organizations/{organization}/workspaces --page-size 1 --jq '.meta.pagination.["total-count"]'
# Find workspace by partial name (server-side search) — also returns current run state in one call
tfctl api /organizations/{organization}/workspaces -f 'search[name]=TERM' --jq '.data[] | {id, name: .attributes.name, current_run: .relationships.["current-run"].data}'
# Filter workspaces by attribute
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.["terraform-version"] | startswith("1.8")) | .attributes.name'
# List variables on a workspace — path is /workspaces/{workspace}/vars (NOT /organizations/{org}/workspaces/{name}/vars; that endpoint does not exist)
tfctl api /workspaces/{workspace}/vars -p workspace=NAME --jq '.data[] | {key: .attributes.key, category: .attributes.category, sensitive: .attributes.sensitive}'
# Get current run status
tfctl run status NAME_OR_ID # If it prints "no current run" or exits non-zero with that message, that IS the answer.
# Get current run ID for a workspace
tfctl api /organizations/{organization}/workspaces/NAME --jq '.data.relationships.["current-run"].data.id'
# List runs in a workspace with status filtering
tfctl api /workspaces/{workspace}/runs -p workspace=NAME --jq '.data[] | select(.attributes.status == "planned") | {id, status: .attributes.status}'
# Find workspace by VCS repo identifier (single call; no results = not connected to that repo)
tfctl api /organizations/{organization}/workspaces --all \
--jq '.data[] | select(.attributes.["vcs-repo"] != null and .attributes.["vcs-repo"].identifier == "org/repo") | {id: .id, name: .attributes.name}'
# List workspaces accessible to a team (two-step: resolve team name → query team-workspaces)
# Note: /organizations/{org}/teams/{id}/workspaces does NOT exist — use /team-workspaces instead
tfctl api /organizations/{organization}/teams -f 'filter[names]=TEAM_NAME' --jq '.data[0].id'
tfctl api /team-workspaces -f 'filter[team][id]=TEAM_ID' --jq '.data[] | {workspace_id: .relationships.workspace.data.id, access: .attributes.access}'
# Get organization details and settings
tfctl api /organizations/{organization} --jq '.data | {id, name: .attributes.name, created_at: .attributes."created-at", terraform_version_default: .attributes."terraform-version"}'
# Get the current state version for a workspace
# (operationId: getCurrentStateVersion — single resource, not a list)
tfctl api /workspaces/{workspace}/current-state-version -p workspace=NAME --jq '.data | {serial: .attributes.serial, created_at: .attributes.["created-at"], status: .attributes.status}'
# List all variable sets in an org and their variable counts
tfctl api /organizations/{organization}/varsets --all --jq '.data[] | {name: .attributes.name, id: .id, var_count: (.relationships.vars.data | length)}'
# Get configuration version details
tfctl api /workspaces/{workspace}/configuration-versions -p workspace=NAME --jq '.data[] | {id, source: .attributes.source, created_at: .attributes.created-at}'
# List notification configurations
tfctl api /workspaces/{workspace}/notification-configurations -p workspace=NAME --jq '.data[] | {id, type: .attributes.destination-type, trigger: .attributes.triggers}'
# Filter workspaces by terraform version (1.7+)
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.["terraform-version"] | ltrimstr("v") | split(".") | [.[0], .[1]] | join(".") | tonumber >= 1.7) | {name: .attributes.name, tf_version: .attributes.["terraform-version"]}'
# Filter workspaces excluding certain names
tfctl api /organizations/{organization}/workspaces --all --jq '.data[] | select(.attributes.name | test("^temp-|^old-") | not) | .attributes.name'
# Count resources by status (e.g., runs by status)
tfctl api /workspaces/{workspace}/runs -p workspace=NAME --all --jq '[.data[] | .attributes.status] | group_by(.) | map({status: .[0], count: length})'
# Get log URL for a completed run
# Note: run.log-read-url is null on completed runs — the URL lives on plan/apply.
tfctl api /runs/RUN_ID --jq '.data.relationships | {plan: .plan.data.id, apply: .apply.data.id}'
tfctl api /plans/PLAN_ID --jq '.data.attributes.["log-read-url"]'
# Start a run
tfctl run start NAME_OR_ID
# Add a remote state consumer to a workspace
# (operationId: addWorkspaceRemoteStateConsumers — POST returns 204)
tfctl api /workspaces/{workspace}/relationships/remote-state-consumers -p workspace=NAME \
-i '{"data":[{"type":"workspaces","id":"ws-CONSUMER_ID"}]}'
# Apply a variable set to a workspace
# (operationId: updateWorkspaceRelationship for varsets)
tfctl api /workspaces/{workspace}/relationships/varsets -p workspace=NAME \
-X POST -i '{"data":[{"type":"varsets","id":"varset-VARSET_ID"}]}'
# Get policy check results for a run
tfctl api /runs/{run-id}/policy-checks --jq '.data[] | {id: .id, status: .attributes.status, enforced: .attributes.enforcement-level}'
# Discover an API operation when you don't know it
tfctl api schema search "KEYWORD" --json # returns operationIds
tfctl api schema get OPERATION_ID # full OpenAPI schema (large response — only call when needed)
Secret Redaction
By default, tfctl will redact the output of sensitive values from api command output, which includes artifact download URLs, log URLs, tokens, private SSH keys, and some unknown things such as variable values that match a token heuristic. This includes --json and --jq output. When extracting a secret that you need, use the --no-redact global flag to disable redaction for a single request.
Output flags
| Need | Flag |
|---|---|
| Filter / extract | --jq '<expr>' |
| Full JSON | --json |
| Render for human | --markdown |
| Audit a mutation | --dry-run |
| Don't redact secrets | --no-redact |
--jq implies --json. Don't pass both. Always pass one explicitly — don't rely on auto-detect.
JSON:API conventions
Responses are JSON:API envelopes: {data: {id, type, attributes, relationships}} (or data: [...] for lists). To follow a link, read data.relationships.<name>.data which gives {id, type} or null. A null is final — there is no related resource.
Pagination: default page 1. Add --all for all pages (cap 2000), or --page-size N / --page-number N for explicit control. For counts, use --page-size 1 --jq '.meta.pagination.["total-count"]'.
Mutations & batch updates
# Create or update a single variable
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VARKEY -a value=VALUE -a category=env
# Batch updates: use separate calls (HCP TFC doesn't support bulk POST for vars)
# Instead of trying /workspaces/{ws}/vars with multiple items, do:
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VAR1 -a value=VAL1 -a category=env
tfctl api /workspaces/{workspace}/vars -p workspace=NAME -a key=VAR2 -a value=VAL2 -a category=env
# (Multiple calls cost more, but it's the supported pattern)
# PATCH with a raw body
tfctl api /workspaces/{workspace}/X -X PATCH -p workspace=NAME -i '{"data":{"type":"X","attributes":{…}}}'
Add --dry-run to preview without sending.
Exit codes (quick map)
| Code | Meaning | Action |
|---|---|---|
| 0 | Success | Done |
| 1 | Informational message or usage error | Read stderr; if it says "no current run" or similar, that IS the answer — stop |
| 2 | Not found (workspace/run doesn't exist) OR invalid auth | Verify the ID/name is correct, then check token if still failing |
| 3 | Auth token expired or invalid | Re-authenticate |
| 4 | Network error | Retry after brief delay |
| 5 | Rate limited (429) or server error (5xx) | Retry with backoff |
| 6 | Resource has an error state | The error is already diagnosed in output (e.g., plan failed); read it |
Important: When an API returns data: [] (empty list) or data: null, that IS the answer. Don't retry with different flags or endpoints.
Common troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
exit 2 when listing workspaces | Organization doesn't exist or auth token has no access | Verify org name and re-authenticate |
exit 1 with "no current run" | Workspace simply has no active run (informational) | This is the answer — stop, don't verify |
exit 3 when making any API call | Auth token expired | Re-authenticate with tfctl login |
exit 5 (429 rate limit) | Too many requests | Wait and retry; tfctl will backoff automatically |
exit 6 with "plan is errored" | Terraform plan had syntax errors (not CLI error) | Read the plan output for details |
Empty list (data: []) when filtering | No resources match criteria | Verify criteria is correct; empty list is valid answer |
Smart defaults
{organization}resolves from active profile if set.{workspace}resolves from a localcloud {}block in CWD.- Already-formed IDs (
ws-…,team-…,prj-…,varset-…) are passed through as-is.