shoc-backend/scripts/check-terraform-release-plan.py
Adam Moussa fa9bda7554 feat(deploy): move dev application CD through Terraform
GitHub creates the immutable Elastic Beanstalk version; HCP Terraform is the only UpdateEnvironment caller via a guarded version_label run.
2026-09-03 10:03:19 -04:00

284 lines
9.3 KiB
Python

#!/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 <full-sha>-<run-id>-<attempt>"
)
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", "<unknown>")
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", "<unknown>")
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())