#!/usr/bin/env python3 """Reject HCP Terraform plans that are not a version-only Elastic Beanstalk update. This script may read a local plan JSON file or download plan JSON from the documented HashiCorp endpoint: GET https://app.terraform.io/api/v2/plans/:id/json-output The download follows exactly one redirect, and only to archivist.terraform.io. It does not create, apply, discard, or poll runs. """ from __future__ import annotations import argparse import json import os import re import ssl import sys import urllib.error import urllib.request from pathlib import Path from typing import Any, Callable from urllib.parse import urlparse RELEASE_ADDRESS = "module.environment.aws_elastic_beanstalk_environment.this" API_HOST = "app.terraform.io" ARCHIVE_HOST = "archivist.terraform.io" PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$") VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$") IGNORED_ACTIONS = {"no-op", "read"} UNSAFE_ACTIONS = {"create", "delete"} UrlOpen = Callable[..., Any] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() source = parser.add_mutually_exclusive_group(required=True) source.add_argument( "plan_json", type=Path, nargs="?", help="Local Terraform plan JSON. Mutually exclusive with --plan-id.", ) source.add_argument( "--plan-id", help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.", ) parser.add_argument( "--expected-version-label", required=True, help="Immutable application version the plan must apply.", ) parser.add_argument( "--evidence-out", type=Path, help="Write machine-readable proof after every assertion passes.", ) return parser.parse_args() def download_plan_json( plan_id: str, token: str, *, urlopen: UrlOpen | None = None, ) -> dict[str, Any]: if not PLAN_ID_RE.fullmatch(plan_id): raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id") if not token: raise ValueError("TF_API_TOKEN is required to download plan JSON") opener = urlopen or urllib.request.urlopen api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output" request = urllib.request.Request( api_url, method="GET", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/vnd.api+json", "Accept": "application/json", }, ) first = _open_pinned(opener, request, allowed_host=API_HOST) try: if first.status == 204: raise ValueError( "plan JSON is not ready; refusing to poll the plans endpoint" ) if first.status not in {301, 302, 303, 307, 308}: raise ValueError( f"expected a redirect from {API_HOST}, got HTTP {first.status}" ) location = first.headers.get("Location") if not location: raise ValueError(f"{API_HOST} redirect is missing a Location header") archive = urlparse(location) if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST: raise ValueError( "refusing redirect that is not https://" f"{ARCHIVE_HOST}/" ) archive_request = urllib.request.Request(location, method="GET") second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST) try: if second.status in {301, 302, 303, 307, 308}: raise ValueError( f"refusing a second redirect from {ARCHIVE_HOST}" ) if second.status != 200: raise ValueError( f"plan JSON download from {ARCHIVE_HOST} returned " f"HTTP {second.status}" ) payload = second.read() finally: second.close() finally: first.close() plan = json.loads(payload.decode("utf-8")) if not isinstance(plan, dict): raise ValueError("plan JSON must be an object") return plan def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str): parsed = urlparse(request.full_url) if parsed.scheme != "https" or parsed.hostname != allowed_host: raise ValueError( f"refusing to contact {parsed.scheme}://{parsed.hostname} " f"(pinned host is {allowed_host})" ) context = ssl.create_default_context() try: return urlopen(request, context=context, timeout=30) except TypeError: return urlopen(request, timeout=30) def changed_attributes(change: dict[str, Any]) -> set[str]: before = change.get("before") or {} after = change.get("after") or {} unknown = change.get("after_unknown") or {} keys = set(before) | set(after) changed: set[str] = set() for key in keys: unknown_value = unknown.get(key) if unknown_value is True or ( isinstance(unknown_value, (dict, list)) and unknown_value ): continue if before.get(key) != after.get(key): changed.add(key) return changed def validate_plan(plan: dict[str, Any], expected_label: str) -> list[str]: violations: list[str] = [] if not VERSION_LABEL_RE.fullmatch(expected_label): violations.append( "expected version label must be --" ) return violations updates: list[dict[str, Any]] = [] for resource in plan.get("resource_changes", []): if resource.get("mode", "managed") != "managed": continue address = resource.get("address", "") change = resource.get("change") or {} actions = list(change.get("actions") or []) action_set = set(actions) if action_set <= IGNORED_ACTIONS: continue if change.get("importing"): violations.append(f"{address}: import actions are not allowed") unsafe = sorted(action_set & UNSAFE_ACTIONS) if unsafe: violations.append(f"{address}: unsafe actions {unsafe}") if "replace" in action_set or actions in ( ["delete", "create"], ["create", "delete"], ): violations.append(f"{address}: replacement is not allowed") if "update" in action_set: updates.append(resource) if action_set != {"update"}: violations.append( f"{address}: update must be the only action, got {actions}" ) if address != RELEASE_ADDRESS and action_set - IGNORED_ACTIONS: violations.append( f"{address}: managed address is outside the version-only release" ) if len(updates) != 1: violations.append( f"expected exactly one managed update, found {len(updates)}" ) return violations resource = updates[0] address = resource.get("address", "") if address != RELEASE_ADDRESS: violations.append( f"{address}: expected update address {RELEASE_ADDRESS}" ) return violations change = resource.get("change") or {} changed = changed_attributes(change) if changed != {"version_label"}: violations.append( f"{address}: expected only version_label to change, found " f"{sorted(changed) if changed else 'no attribute changes'}" ) after = change.get("after") or {} actual = after.get("version_label") if actual != expected_label: violations.append( f"{address}: after version_label {actual!r} does not match " f"{expected_label!r}" ) unknown = change.get("after_unknown") or {} if unknown.get("version_label") is True: violations.append(f"{address}: version_label after value is unknown") return violations def main() -> int: args = parse_args() if args.plan_id: try: plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", "")) except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc: print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr) return 1 else: if args.plan_json is None: print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr) return 1 plan = json.loads(args.plan_json.read_text(encoding="utf-8")) violations = validate_plan(plan, args.expected_version_label) if violations: print("FAIL: Terraform plan is not a version-only release", file=sys.stderr) for violation in violations: print(f" - {violation}", file=sys.stderr) return 1 if args.evidence_out: evidence = { "address": RELEASE_ADDRESS, "expected_version_label": args.expected_version_label, "managed_updates": 1, "changed_attributes": ["version_label"], "creates": 0, "deletes": 0, "replacements": 0, } args.evidence_out.write_text( json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) print( "PASS: version-only plan updates " f"{RELEASE_ADDRESS} version_label to {args.expected_version_label}" ) return 0 if __name__ == "__main__": raise SystemExit(main())