shoc-frontend-new/scripts/hcp-run-guard.py
Adam Moussa 69c24c1c2c
feat(terraform): ship dev content CD through Terraform (SH-300) (#180)
* feat(terraform): ship dev content CD through Terraform (SH-300)

GitHub uploads immutable release prefixes; Terraform owns live publish.
Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set.

* fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
2026-09-11 13:40:14 -04:00

207 lines
6.2 KiB
Python
Executable file

#!/usr/bin/env python3
"""Guard HCP Terraform runs used by GitHub content CD.
Subcommands:
check-and-discard Refuse unsafe workspace settings. Discard a blocking
non-speculative VCS run so GitHub CD can create-run.
reconcile-apply Treat an HCP run whose status is already ``applied`` as
success when the GitHub apply-run step reported failure.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any, Callable
API = "https://app.terraform.io/api/v2"
DEFAULT_WORKSPACE = "shoc-frontend-new-dev"
EXPECTED_TRIGGER_PATTERNS = [
"terraform/live/dev/**",
"terraform/live/modules/**",
]
DISCARDABLE = {
"pending",
"planned",
"cost_estimated",
"policy_checked",
"policy_override",
}
APPLYING = {"applying", "apply_queued"}
HttpGet = Callable[[str], dict[str, Any]]
HttpPost = Callable[[str, dict[str, Any]], int]
class GuardError(Exception):
"""Refused to continue."""
def _headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/vnd.api+json",
}
def default_get(token: str) -> HttpGet:
def get(url: str) -> dict[str, Any]:
request = urllib.request.Request(url, headers=_headers(token))
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
return get
def default_post(token: str) -> HttpPost:
def post(url: str, payload: dict[str, Any]) -> int:
data = json.dumps(payload).encode()
request = urllib.request.Request(
url, data=data, method="POST", headers=_headers(token)
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return int(response.status)
except urllib.error.HTTPError as exc:
if exc.code in (409, 404):
body = exc.read().decode("utf-8", "replace")
print(f"discard returned HTTP {exc.code}: {body}")
return exc.code
raise
return post
def require_token(token: str) -> str:
if not token:
raise GuardError("TF_API_TOKEN is required")
return token
def check_invariants(attrs: dict[str, Any], workspace: str) -> None:
if attrs.get("auto-apply") is True:
raise GuardError(f"{workspace} auto-apply is on; refuse to continue")
if not attrs.get("speculative-enabled"):
raise GuardError("speculative plans are off; refuse to continue")
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
raise GuardError("tag-based VCS triggering is set; refuse to continue")
if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS:
raise GuardError(
"trigger-patterns must be "
f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}"
)
def check_and_discard(
*,
workspace: str,
token: str,
get: HttpGet | None = None,
post: HttpPost | None = None,
) -> int:
token = require_token(token)
get = get or default_get(token)
post = post or default_post(token)
workspace_payload = get(
f"{API}/organizations/seahaven/workspaces/{workspace}"
)["data"]
attrs = workspace_payload["attributes"]
check_invariants(attrs, workspace)
if not attrs.get("locked"):
print("workspace is unlocked")
return 0
current = (
workspace_payload.get("relationships", {})
.get("current-run", {})
.get("data")
)
if not current:
raise GuardError("workspace is locked without a current run")
run_id = current["id"]
run = get(f"{API}/runs/{run_id}")["data"]
run_attrs = run["attributes"]
status = run_attrs.get("status")
plan_only = run_attrs.get("plan-only")
print(f"current run {run_id} status={status} plan-only={plan_only}")
if plan_only:
print("speculative run does not block GitHub CD")
return 0
if status in APPLYING:
raise GuardError(f"{run_id} is {status}; wait, do not discard an apply")
if status not in DISCARDABLE:
raise GuardError(f"{run_id} status {status} is not discardable")
code = post(
f"{API}/runs/{run_id}/actions/discard",
{
"comment": (
"Discarded so GitHub CD can create the content-release applyable run"
)
},
)
print(f"discarded {run_id} http={code}")
return 0
def reconcile_apply(
*,
run_id: str,
apply_outcome: str,
token: str,
get: HttpGet | None = None,
) -> int:
token = require_token(token)
if not run_id:
raise GuardError("run id is required")
if apply_outcome == "success":
print("Apply succeeded.")
return 0
get = get or default_get(token)
status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"]
print(f"HCP run {run_id} status={status}")
if status == "applied":
return 0
raise GuardError(
f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}"
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
check = sub.add_parser("check-and-discard")
check.add_argument("--workspace", default=DEFAULT_WORKSPACE)
check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
reconcile = sub.add_parser("reconcile-apply")
reconcile.add_argument("--run-id", required=True)
reconcile.add_argument(
"--apply-outcome",
default=os.environ.get("APPLY_OUTCOME", ""),
)
reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.command == "check-and-discard":
return check_and_discard(workspace=args.workspace, token=args.token)
return reconcile_apply(
run_id=args.run_id,
apply_outcome=args.apply_outcome,
token=args.token,
)
except GuardError as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())