mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
* 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)
207 lines
6.2 KiB
Python
Executable file
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())
|