diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6aca4cb..5c6abcb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -47,6 +47,19 @@ "license": "MPL-2.0", "strict": false }, + { + "name": "terraform-policy-controls", + "source": "./terraform/policy-controls", + "description": "Terraform policy controls skills including HCP Terraform run task result retrieval and analysis.", + "version": "1.0.0", + "author": { + "name": "HashiCorp" + }, + "keywords": ["terraform", "policy-controls", "run-tasks", "policy-enforcement", "hcp-terraform"], + "category": "integration", + "license": "MPL-2.0", + "strict": false + }, { "name": "packer-builders", "source": "./packer/builders", diff --git a/terraform/policy-controls/.claude-plugin/plugin.json b/terraform/policy-controls/.claude-plugin/plugin.json new file mode 100644 index 0000000..dab084a --- /dev/null +++ b/terraform/policy-controls/.claude-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "terraform-policy-controls", + "version": "1.0.0", + "description": "Terraform policy controls skills for Claude Code, including HCP Terraform run task result retrieval and analysis.", + "author": { + "name": "HashiCorp", + "url": "https://github.com/hashicorp" + }, + "homepage": "https://developer.hashicorp.com/terraform/cloud-docs/run/run-tasks", + "repository": "https://github.com/hashicorp/agent-skills", + "license": "MPL-2.0", + "keywords": ["terraform", "policy-controls", "run-tasks", "policy-enforcement", "hcp-terraform"], + "mcpServers": { + "terraform": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", "TFE_TOKEN", + "-e", "TFE_ADDRESS", + "hashicorp/terraform-mcp-server" + ], + "env": { + "TFE_TOKEN": "${TFE_TOKEN}", + "TFE_ADDRESS": "${TFE_ADDRESS}" + } + } + } +} diff --git a/terraform/policy-controls/skills/terraform-runtask/SKILL.md b/terraform/policy-controls/skills/terraform-runtask/SKILL.md new file mode 100644 index 0000000..f480de3 --- /dev/null +++ b/terraform/policy-controls/skills/terraform-runtask/SKILL.md @@ -0,0 +1,150 @@ +--- +name: terraform-runtask +description: Retrieve and display HCP Terraform Enterprise run task results for a given run. Use this skill whenever the user asks about run task results, run task checks, task stage statuses, or wants to inspect what run tasks reported for a Terraform Cloud/Enterprise run. Triggers on phrases like "check the run tasks", "what did the run tasks say", "show run task results", "get task results for run-xxx", or any reference to run task outcomes on a specific run. +--- + +# HCP Terraform Run Task Reader + +The MCP terraform tools can fetch run details but lack endpoints for task stages, task results, and task result outcomes. This skill bridges that gap with a script that calls the HCP Terraform/TFE REST API directly, returning structured JSON with three layers of detail: **stages → task results → outcomes**. + +## Workflow + +### Step 1: Identify the run + +The user may provide either: + +- A **run ID** like `run-iURWDL3wVxzefsjo` +- A **URL** like `https://app.terraform.io/app/org/workspaces/ws-name/runs/run-abc123` + +Pass either form directly to the script — it handles both. + +### Step 2: Fetch run task data + +Run the script from its skill directory: + +```bash +scripts/get-run-task-results.sh +``` + +The script requires: + +- `$TFE_TOKEN` — API token with read access to the workspace +- `$TFE_HOSTNAME` — (optional) hostname, defaults to `app.terraform.io`; auto-detected from URL input +- `$TFE_SKIP_VERIFY` — (optional) set to `true` to skip TLS verification (self-signed certs on TFE) +- `curl` and `jq` on PATH + +The script uses `include=task_results` sideloading for efficiency (one API call for stages + results), then fetches outcomes and their HTML bodies per task result. It returns a single JSON object. + +Parse the JSON output directly — do not save it to disk. Present the results inline. + +### Step 3: Present structured results + +Parse the JSON and present a markdown summary. The presentation has three tiers that mirror the data hierarchy: + +**Tier 1 — Summary line** with aggregate counts from `summary`. Always include these counts in the user-facing response (not just in the raw JSON), even when all counts are zero: + +``` +**Total tasks**: 1 | Passed: 0 | Failed: 1 | Errored: 0 +``` + +**Tier 2 — Stage sections** grouped by execution phase, each showing its task results table: + +``` +### Post-Plan Tasks (stage status: passed) + +| Task Name | Status | Enforcement | Message | Link | +|-----------|--------|-------------|---------|------| +| Apptio-Cloudability | failed | advisory | Total Cost before: 31.54, after: 31.64, diff: +0.10 | [Results](url) | +``` + +**Tier 3 — Outcome sub-tables** under each task result that has outcomes: + +``` +#### Apptio-Cloudability — Outcomes + +| Outcome | Description | Status | Severity | +|---------|-------------|--------|----------| +| Estimation | Cost Estimation Result | Passed | — | +| Policy | Policy Evaluation Result | Failed | Gated | +| Recommendation | Recommendation Result | Passed | — | +``` + +If an outcome has `body_html` content, render it in a collapsible block: + +``` +
+Policy Evaluation Detail + +[HTML body content — failing resources, tag violations, etc.] + +
+``` + +**Tier 4 — Actionable insights** after presenting the tables, synthesize the most important findings from the outcome bodies. The `body_html` content often contains the richest detail — specific failing resources, tag violations, cost savings recommendations, or compliance issues. Summarize these findings in plain language so the user doesn't have to parse raw HTML. For example: + +> **Key findings:** +> +> - **Policy**: 23 resources failing — 22 missing `cost-center` tag (advisory), 1 EC2 instance using `t3.small` instead of required `t2.small` (gated) +> - **Cost**: Monthly impact +$0.10 USD, driven by a new CloudWatch metric alarm +> - **Recommendation**: Switch EC2 from `t3.small` to `t4g.small` for ~20% cost savings + +This synthesis is what makes the skill output more valuable than just showing raw tables — it highlights what the user needs to act on. + +### Handling edge cases + +There are three distinct "empty" scenarios — distinguish between them clearly. In all cases, still show the Tier 1 summary line with explicit counts — this gives users a quick, scannable answer even when the counts are all zero: + +1. **No task stages at all** (`task_stages` array is empty, `total_tasks` is 0): The workspace has no run tasks configured. Show: `**Total tasks**: 0 | Passed: 0 | Failed: 0 | Errored: 0` followed by "This run has no run tasks configured." + +2. **Task stages exist but contain zero task results** (`task_stages` is non-empty but each stage's `task_results` array is empty, `total_tasks` is 0): The run task infrastructure exists but produced no individual results. Show: `**Total tasks**: 0 | Passed: 0 | Failed: 0 | Errored: 0` followed by "This run has task stages but no task results were produced. The stages are: [list stage names and statuses]." + +3. **Task results exist but have zero outcomes** (`outcomes_count` is 0 for a task result): The task reported a status and message but no detailed breakdown. Show the task result row normally; skip the outcomes sub-table for that task. + +This distinction matters because users asking "are there run tasks?" need to know whether tasks are configured (case 1 vs 2) and whether they produced detail (case 3). + +### Reading the JSON output + +**Task stage fields** (`task_stages[]`): + +- `stage` — `pre_plan`, `post_plan`, `pre_apply`, `post_apply` +- `status` — stage-level status (can pass even when advisory tasks fail) +- `is_overridable`, `permissions` — override capability + +**Task result fields** (`task_stages[].task_results[]`): + +- `task_name` — Name of the run task +- `status` — `pending`, `running`, `passed`, `failed`, `errored`, `unreachable` +- `enforcement_level` — `advisory` (warning only) or `mandatory` (blocks run) +- `message` — Status message from the external service +- `url` — Link to external service results (if present). Include this in the Tier 2 table as a clickable link so users can jump to the full report in the external tool. +- `outcomes_count` — Number of outcome categories + +**Outcome fields** (`task_stages[].task_results[].outcomes[]`): + +- `outcome_id` — Category name. These vary by vendor — don't assume specific names like "Estimation" or "Policy". Present whatever categories the task returns. +- `description` — Human-readable description +- `tags` — Status/severity via `tags[].label == "Status"` → `tags[].value[0].label` and `tags[].label == "Severity"` → `tags[].value[0].label` +- `body_html` — Full HTML detail (may be null). When present, always extract and summarize key findings for the Tier 4 actionable insights. + +The `summary` object has: `total_tasks`, `passed`, `failed`, `errored`, `pending`, `unreachable`. + +Stage ordering (show in execution order): `pre_plan` → `post_plan` → `pre_apply` → `post_apply`. The script already sorts stages in this order. + +### Highlighting problems + +- If a task result has `status: errored` or `unreachable`, highlight it prominently — the external service failed to respond, not just a policy failure. +- If `enforcement_level` is `mandatory` and the task `failed`, note that this blocks the run from proceeding. +- If the stage `is_overridable`, mention that the stage can be manually overridden. + +### Enriching with MCP run context + +After fetching task results, also call `mcp__terraform__get_run_details` with the run ID to get complementary metadata: run status, trigger source, Terraform version, timestamps, and plan/apply state. This context helps the user understand where the run is in its lifecycle and why task results look the way they do. For example, knowing a run was triggered via CLI with auto-apply explains why it proceeded despite advisory failures. + +Include relevant run metadata in your response — especially run status, trigger source, and whether the run has been applied. + +## Error handling + +- **Missing `$TFE_TOKEN`**: Script exits with a clear error. Suggest the user set the token. +- **HTTP 401/403**: Token lacks permissions or is expired. The error message includes the run ID. +- **HTTP 404**: Invalid run ID. The error message includes the run ID for debugging. +- **Script exits non-zero**: Surface the stderr output to the user — do not silently swallow errors or fabricate results. diff --git a/terraform/policy-controls/skills/terraform-runtask/scripts/get-run-task-results.sh b/terraform/policy-controls/skills/terraform-runtask/scripts/get-run-task-results.sh new file mode 100755 index 0000000..bb023b2 --- /dev/null +++ b/terraform/policy-controls/skills/terraform-runtask/scripts/get-run-task-results.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# Fetch run task stages, results, and outcomes from TFC/TFE API. +# +# Usage: +# get-run-task-results.sh +# +# Environment: +# TFE_TOKEN - Required. API token with read access to the workspace. +# TFE_HOSTNAME - Optional. TFE/TFC hostname. Defaults to app.terraform.io +# TFE_SKIP_VERIFY - Optional. Set to "true" to skip TLS certificate verification. +# +# Output: JSON object with run task stages, results, outcomes, and outcome bodies. +# +# Example: +# get-run-task-results.sh run-iURWDL3wVxzefsjo +# get-run-task-results.sh https://app.terraform.io/app/org/workspaces/ws/runs/run-abc123 + +set -euo pipefail + +CURL_CONNECT_TIMEOUT=10 +CURL_MAX_TIME=30 + +# Build curl TLS options from TFE_SKIP_VERIFY +CURL_TLS_OPTS=() +if [[ "${TFE_SKIP_VERIFY:-false}" == "true" ]]; then + CURL_TLS_OPTS+=("--insecure") +fi + +# --- Validate prerequisites --- + +if [[ -z "${TFE_TOKEN:-}" ]]; then + echo "Error: TFE_TOKEN environment variable is not set." >&2 + exit 1 +fi + +if ! command -v curl &>/dev/null; then + echo "Error: curl is required but not found." >&2 + exit 1 +fi + +if ! command -v jq &>/dev/null; then + echo "Error: jq is required but not found." >&2 + exit 1 +fi + +# --- Parse input --- + +INPUT="${1:-}" +if [[ -z "$INPUT" ]]; then + echo "Usage: get-run-task-results.sh " >&2 + exit 1 +fi + +# Extract run ID from URL or use directly +if [[ "$INPUT" =~ ^https?:// ]]; then + RUN_ID=$(echo "$INPUT" | grep -oE 'run-[a-zA-Z0-9]+') + if [[ -z "$RUN_ID" ]]; then + echo "Error: Could not extract run ID from URL: $INPUT" >&2 + exit 1 + fi + # Extract hostname from URL if TFE_HOSTNAME not already set + PARSED_HOST=$(echo "$INPUT" | grep -oE '^https?://[^/]+' | sed 's|^https\?://||') + TFE_HOSTNAME="${TFE_HOSTNAME:-$PARSED_HOST}" +else + # Validate run ID format for direct input + if [[ ! "$INPUT" =~ ^run-[a-zA-Z0-9]+$ ]]; then + echo "Error: Invalid run ID format: $INPUT (expected run-)" >&2 + exit 1 + fi + RUN_ID="$INPUT" + TFE_HOSTNAME="${TFE_HOSTNAME:-app.terraform.io}" +fi + +API_BASE="https://${TFE_HOSTNAME}/api/v2" + +# --- Helper: authenticated GET returning JSON --- + +api_get() { + local endpoint="$1" + local tmpfile + tmpfile=$(mktemp) + trap "rm -f '$tmpfile'" RETURN + + local http_code + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ + "${CURL_TLS_OPTS[@]}" \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_MAX_TIME" \ + -H "Authorization: Bearer ${TFE_TOKEN}" \ + -H "Content-Type: application/vnd.api+json" \ + "${API_BASE}${endpoint}") + + if [[ "$http_code" -eq 401 ]]; then + echo "Error: API returned HTTP 401 for ${endpoint} — TFE_TOKEN may be expired or invalid (run: ${RUN_ID})" >&2 + cat "$tmpfile" >&2 + return 1 + elif [[ "$http_code" -ge 400 ]]; then + echo "Error: API returned HTTP ${http_code} for ${endpoint} (run: ${RUN_ID})" >&2 + cat "$tmpfile" >&2 + return 1 + fi + + cat "$tmpfile" +} + +# Helper: fetch HTML body from outcome (follows redirects safely) +# Uses a two-step approach to avoid leaking the auth header to redirect targets. +api_get_body() { + local endpoint="$1" + local tmpfile + tmpfile=$(mktemp) + trap "rm -f '$tmpfile'" RETURN + + # Step 1: Get the redirect URL without following it + local http_code redirect_url + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ + "${CURL_TLS_OPTS[@]}" \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_MAX_TIME" \ + -H "Authorization: Bearer ${TFE_TOKEN}" \ + -H "Content-Type: application/vnd.api+json" \ + "${API_BASE}${endpoint}") + + if [[ "$http_code" -eq 302 || "$http_code" -eq 301 ]]; then + # Step 2: Follow the redirect WITHOUT the auth header + redirect_url=$(curl -s -o /dev/null -w "%{redirect_url}" \ + "${CURL_TLS_OPTS[@]}" \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_MAX_TIME" \ + -H "Authorization: Bearer ${TFE_TOKEN}" \ + -H "Content-Type: application/vnd.api+json" \ + "${API_BASE}${endpoint}") + + if [[ -n "$redirect_url" ]]; then + curl -s \ + "${CURL_TLS_OPTS[@]}" \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$CURL_MAX_TIME" \ + "$redirect_url" + return $? + fi + fi + + if [[ "$http_code" -ge 400 ]]; then + echo "Warning: Body endpoint returned HTTP ${http_code} for ${endpoint}" >&2 + return 1 + fi + + cat "$tmpfile" +} + +# --- Step 1: Fetch task stages with sideloaded task results --- +# Fetch all pages to handle runs with many task stages. + +all_stages='{"data":[],"included":[]}' +page=1 + +while true; do + page_response=$(api_get "/runs/${RUN_ID}/task-stages?include=task_results&page%5Bnumber%5D=${page}&page%5Bsize%5D=100") + + # Validate response is JSON + if ! echo "$page_response" | jq empty 2>/dev/null; then + echo "Error: Invalid JSON response from task-stages endpoint (run: ${RUN_ID})" >&2 + exit 1 + fi + + # Merge page data + all_stages=$(jq -n \ + --argjson acc "$all_stages" \ + --argjson page "$page_response" \ + '{ + data: ($acc.data + ($page.data // [])), + included: ($acc.included + ($page.included // [])) + }') + + # Check for next page + next_page=$(echo "$page_response" | jq -r '.meta.pagination["next-page"] // empty') + if [[ -z "$next_page" || "$next_page" == "null" ]]; then + break + fi + page=$((page + 1)) +done + +stages_response="$all_stages" +stage_count=$(echo "$stages_response" | jq '.data | length') + +if [[ "$stage_count" -eq 0 ]]; then + jq -n \ + --arg run_id "$RUN_ID" \ + --arg hostname "$TFE_HOSTNAME" \ + '{ + run_id: $run_id, + tfe_hostname: $hostname, + task_stages: [], + summary: { total_tasks: 0, passed: 0, failed: 0, errored: 0, pending: 0, unreachable: 0 } + }' + exit 0 +fi + +# --- Step 2: Extract task result IDs and fetch outcomes for each --- + +task_result_ids=$(echo "$stages_response" | jq -r ' + .included[]? | select(.type == "task-results") | .id // empty +') + +outcomes_json="[]" + +while IFS= read -r result_id; do + [[ -z "$result_id" ]] && continue + + # Skip if this task result has 0 outcomes + outcomes_count=$(echo "$stages_response" | jq -r \ + --arg rid "$result_id" \ + '.included[]? | select(.type == "task-results" and .id == $rid) | .attributes["task-result-outcomes-count"] // 0') + + if [[ "$outcomes_count" -eq 0 ]]; then + outcomes_json=$(echo "$outcomes_json" | jq \ + --arg rid "$result_id" \ + '. + [{ task_result_id: $rid, outcomes: [] }]') + continue + fi + + # Fetch outcomes list for this task result + if ! outcomes_response=$(api_get "/task-results/${result_id}/outcomes" 2>&1); then + echo "Warning: Failed to fetch outcomes for task result ${result_id}" >&2 + outcomes_response='{"data":[]}' + fi + + # For each outcome, fetch the HTML body + outcome_ids=$(echo "$outcomes_response" | jq -r '.data[]?.id // empty') + + while IFS= read -r outcome_id; do + [[ -z "$outcome_id" ]] && continue + + # Write body to temp file to avoid ARG_MAX issues with large HTML + body_tmpfile=$(mktemp) + if api_get_body "/task-result-outcomes/${outcome_id}/body" > "$body_tmpfile" 2>/dev/null; then + # Use --rawfile to safely handle large/special content + outcomes_response=$(echo "$outcomes_response" | jq \ + --arg oid "$outcome_id" \ + --rawfile html "$body_tmpfile" \ + '(.data[] | select(.id == $oid)) += {"body_html": $html}') + else + echo "Warning: Failed to fetch body for outcome ${outcome_id}" >&2 + fi + rm -f "$body_tmpfile" + done <<< "$outcome_ids" + + # Add outcomes keyed by task result ID + outcomes_json=$(echo "$outcomes_json" | jq \ + --arg rid "$result_id" \ + --argjson outcomes "$outcomes_response" \ + '. + [{ task_result_id: $rid, outcomes: $outcomes.data }]') +done <<< "$task_result_ids" + +# --- Step 3: Assemble structured output --- + +output=$(jq -n \ + --arg run_id "$RUN_ID" \ + --arg hostname "$TFE_HOSTNAME" \ + --argjson stages "$stages_response" \ + --argjson outcomes "$outcomes_json" \ + ' + # Index sideloaded task results by ID + ([$stages.included[]? | select(.type == "task-results")] | INDEX(.id)) as $results_by_id | + + # Index outcomes by task result ID + ($outcomes | INDEX(.task_result_id)) as $outcomes_by_result | + + # Stage ordering (unknown stages sort to end) + ["pre_plan", "post_plan", "pre_apply", "post_apply"] as $stage_order | + + { + run_id: $run_id, + tfe_hostname: $hostname, + task_stages: [ + $stages.data[] + | { + id: .id, + stage: .attributes.stage, + status: .attributes.status, + is_overridable: (.attributes.actions["is-overridable"] // false), + permissions: { + can_override_policy: (.attributes.permissions["can-override-policy"] // false), + can_override_tasks: (.attributes.permissions["can-override-tasks"] // false), + can_override: (.attributes.permissions["can-override"] // false) + }, + status_timestamps: .attributes["status-timestamps"], + created_at: .attributes["created-at"], + updated_at: .attributes["updated-at"], + task_results: [ + .relationships["task-results"].data[]? + | .id as $rid + | $results_by_id[$rid] + | select(. != null) + | { + id: .id, + task_name: .attributes["task-name"], + status: .attributes.status, + message: .attributes.message, + url: .attributes.url, + task_url: .attributes["task-url"], + enforcement_level: .attributes["workspace-task-enforcement-level"], + stage: .attributes.stage, + is_speculative: .attributes["is-speculative"], + task_id: .attributes["task-id"], + workspace_task_id: .attributes["workspace-task-id"], + outcomes_count: (.attributes["task-result-outcomes-count"] // 0), + status_timestamps: .attributes["status-timestamps"], + created_at: .attributes["created-at"], + updated_at: .attributes["updated-at"], + outcomes: ( + ($outcomes_by_result[$rid].outcomes // []) + | map({ + id: .id, + outcome_id: .attributes["outcome-id"], + description: .attributes.description, + tags: .attributes.tags, + url: .attributes.url, + body_html: (.body_html // null), + created_at: .attributes["created-at"] + }) + ) + } + ] + } + ] + | sort_by( + .stage as $s | + ($stage_order | to_entries | map(select(.value == $s)) | .[0].key) // 999 + ), + summary: { + total_tasks: ([$stages.included[]? | select(.type == "task-results")] | length), + passed: ([$stages.included[]? | select(.type == "task-results") | select(.attributes.status == "passed")] | length), + failed: ([$stages.included[]? | select(.type == "task-results") | select(.attributes.status == "failed")] | length), + errored: ([$stages.included[]? | select(.type == "task-results") | select(.attributes.status == "errored")] | length), + pending: ([$stages.included[]? | select(.type == "task-results") | select(.attributes.status == "pending")] | length), + unreachable: ([$stages.included[]? | select(.type == "task-results") | select(.attributes.status == "unreachable")] | length) + } + } +') + +echo "$output"