mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
chore(cd): retire leftover Terraform app CD path
This commit is contained in:
parent
68ad7362df
commit
f42576e2db
17 changed files with 6 additions and 2193 deletions
1238
.github/workflows/deploy.yml
vendored
1238
.github/workflows/deploy.yml
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -73,8 +73,6 @@ remains in the matrix. HCP plan/apply roles stay in org-baseline; this
|
|||
repository never manages `hcptf-*` roles.
|
||||
|
||||
The former G12 version-only HCP apply guard is not part of the repository gate.
|
||||
`scripts/check-terraform-release-plan.py` remains only while
|
||||
`.github/workflows/deploy.yml` is still present.
|
||||
|
||||
G13 fails when the same diff contains both `terraform/` and deployable
|
||||
application files. Workflow, documentation, and gate-script changes may share
|
||||
|
|
|
|||
|
|
@ -1,328 +0,0 @@
|
|||
#!/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"}
|
||||
# Wholly unknown computed attributes may be ignored. Nested unknowns on any
|
||||
# other attribute are treated as changes so the version-only guard fails closed.
|
||||
COMPUTED_UNKNOWN_ATTRIBUTES = frozenset({"instances", "load_balancers"})
|
||||
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||
|
||||
UrlOpen = Callable[..., Any]
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Return the redirect response instead of following it."""
|
||||
|
||||
def http_error_301(self, req, fp, code, msg, headers):
|
||||
return self._capture(req, fp, code, headers)
|
||||
|
||||
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
||||
|
||||
@staticmethod
|
||||
def _capture(req, fp, code, headers):
|
||||
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
||||
response.msg = "Redirect"
|
||||
return response
|
||||
|
||||
|
||||
def _urlopen_without_redirects(
|
||||
*handlers: urllib.request.BaseHandler,
|
||||
) -> UrlOpen:
|
||||
context = ssl.create_default_context()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
_NoRedirectHandler,
|
||||
*handlers,
|
||||
)
|
||||
return opener.open
|
||||
|
||||
|
||||
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,
|
||||
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
||||
) -> 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 _urlopen_without_redirects(*handlers)
|
||||
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 REDIRECT_STATUSES:
|
||||
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 REDIRECT_STATUSES:
|
||||
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 _is_nested_unknown(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
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) | set(unknown)
|
||||
changed: set[str] = set()
|
||||
for key in keys:
|
||||
unknown_value = unknown.get(key)
|
||||
if unknown_value is True:
|
||||
if key in COMPUTED_UNKNOWN_ATTRIBUTES:
|
||||
continue
|
||||
changed.add(key)
|
||||
continue
|
||||
if _is_nested_unknown(unknown_value):
|
||||
changed.add(key)
|
||||
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())
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for check-terraform-release-plan.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from urllib.request import Request
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py")
|
||||
FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans"
|
||||
EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
PLAN_ID = "plan-8F5JFydVYAmtTjET"
|
||||
|
||||
|
||||
def run_case(
|
||||
fixture_name: str,
|
||||
*,
|
||||
expected_label: str = EXPECTED_LABEL,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(FIXTURES / fixture_name),
|
||||
"--expected-version-label",
|
||||
expected_label,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
status: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: bytes = b"",
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self._body = body
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def load_check_module():
|
||||
spec = importlib.util.spec_from_file_location("check_terraform_release_plan", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_download_pinning() -> list[str]:
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_urlopen(request: Request, **_kwargs):
|
||||
url = request.full_url
|
||||
calls.append(url)
|
||||
host = request.host if hasattr(request, "host") else ""
|
||||
if url.startswith("https://app.terraform.io/api/v2/plans/"):
|
||||
if request.get_header("Authorization") != "Bearer test-token":
|
||||
raise AssertionError("API request is missing the bearer token")
|
||||
if "/runs" in url or "/apply" in url or "/discard" in url:
|
||||
raise AssertionError(f"download contacted a run-control path: {url}")
|
||||
return FakeResponse(
|
||||
url=url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
if url == archive_url:
|
||||
if request.get_header("Authorization"):
|
||||
raise AssertionError("archivist request must not send TF_API_TOKEN")
|
||||
return FakeResponse(url=url, status=200, body=fixture)
|
||||
raise AssertionError(f"unexpected URL {url} host={host}")
|
||||
|
||||
plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen)
|
||||
failures: list[str] = []
|
||||
if plan["resource_changes"][1]["address"] != (
|
||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
||||
):
|
||||
failures.append("download did not return the version-only fixture")
|
||||
if calls != [
|
||||
f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output",
|
||||
archive_url,
|
||||
]:
|
||||
failures.append(f"download URLs were {calls}")
|
||||
|
||||
try:
|
||||
module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen)
|
||||
failures.append("invalid plan id was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def redirect_elsewhere(request: Request, **_kwargs):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://evil.example/plan.json"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere)
|
||||
failures.append("redirect to a non-archivist host was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def double_redirect(request: Request, **_kwargs):
|
||||
if request.full_url.startswith("https://app.terraform.io/"):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://archivist.terraform.io/v1/object/other"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect)
|
||||
failures.append("second archivist redirect was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def not_ready(request: Request, **_kwargs):
|
||||
return FakeResponse(url=request.full_url, status=204)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready)
|
||||
failures.append("HTTP 204 was polled or accepted")
|
||||
except ValueError as exc:
|
||||
if "poll" not in str(exc):
|
||||
failures.append(f"HTTP 204 error was {exc}")
|
||||
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
for banned in ("/apply", "/discard", "/runs"):
|
||||
if banned in source:
|
||||
failures.append(f"download client contains run-control path {banned}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def _scripted_https_handler(fixture: bytes, archive_url: str):
|
||||
calls: list[str] = []
|
||||
api_prefix = "https://app.terraform.io/api/v2/plans/"
|
||||
|
||||
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
|
||||
handler_order = 100
|
||||
|
||||
def https_open(self, req: Request):
|
||||
url = req.full_url
|
||||
calls.append(url)
|
||||
headers = EmailMessage()
|
||||
if url.startswith(api_prefix):
|
||||
headers["Location"] = archive_url
|
||||
body = b""
|
||||
status = 307
|
||||
msg = "Temporary Redirect"
|
||||
elif url == archive_url:
|
||||
body = fixture
|
||||
status = 200
|
||||
msg = "OK"
|
||||
else:
|
||||
raise AssertionError(f"unexpected URL {url}")
|
||||
response = urllib.response.addinfourl(
|
||||
io.BytesIO(body),
|
||||
headers,
|
||||
url,
|
||||
code=status,
|
||||
)
|
||||
response.msg = msg
|
||||
return response
|
||||
|
||||
return ScriptedHTTPSHandler(), calls
|
||||
|
||||
|
||||
def test_download_standard_opener_redirect() -> list[str]:
|
||||
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
|
||||
failures: list[str] = []
|
||||
|
||||
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
|
||||
followed = urllib.request.build_opener(following_handler).open(api_url)
|
||||
try:
|
||||
if followed.status != 200:
|
||||
failures.append(
|
||||
f"standard opener first status was {followed.status}, not 200"
|
||||
)
|
||||
if following_calls != [api_url, archive_url]:
|
||||
failures.append(f"standard opener URLs were {following_calls}")
|
||||
finally:
|
||||
followed.close()
|
||||
|
||||
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
|
||||
try:
|
||||
plan = module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
handlers=(guard_handler,),
|
||||
)
|
||||
except ValueError as exc:
|
||||
failures.append(f"no-redirect download failed: {exc}")
|
||||
return failures
|
||||
|
||||
if plan["resource_changes"][1]["address"] != (
|
||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
||||
):
|
||||
failures.append("no-redirect download did not return the version-only fixture")
|
||||
if guard_calls != [api_url, archive_url]:
|
||||
failures.append(f"no-redirect download URLs were {guard_calls}")
|
||||
|
||||
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
|
||||
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
|
||||
try:
|
||||
module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
urlopen=following_urlopen,
|
||||
)
|
||||
failures.append("redirect-following urlopen was accepted as the first hop")
|
||||
except ValueError as exc:
|
||||
if "expected a redirect" not in str(exc):
|
||||
failures.append(f"following urlopen error was {exc}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cases = [
|
||||
("version-only", run_case("version-only.json"), 0),
|
||||
("wrong-label", run_case("wrong-label.json"), 1),
|
||||
("eb-setting-change", run_case("eb-setting-change.json"), 1),
|
||||
("nested-unknown-tags", run_case("nested-unknown-tags.json"), 1),
|
||||
("unknown-only-description", run_case("unknown-only-description.json"), 1),
|
||||
("iam-update", run_case("iam-update.json"), 1),
|
||||
("dns-update", run_case("dns-update.json"), 1),
|
||||
("create", run_case("create.json"), 1),
|
||||
("delete", run_case("delete.json"), 1),
|
||||
("replace", run_case("replace.json"), 1),
|
||||
("multiple-updates", run_case("multiple-updates.json"), 1),
|
||||
("empty", run_case("empty.json"), 1),
|
||||
]
|
||||
failures = [
|
||||
(name, result, expected)
|
||||
for name, result, expected in cases
|
||||
if result.returncode != expected
|
||||
]
|
||||
download_failures = test_download_pinning()
|
||||
redirect_failures = test_download_standard_opener_redirect()
|
||||
download_failures.extend(redirect_failures)
|
||||
if failures or download_failures:
|
||||
if failures:
|
||||
print(
|
||||
"FAIL: release plan-check cases failed: "
|
||||
+ ", ".join(name for name, _, _ in failures),
|
||||
file=sys.stderr,
|
||||
)
|
||||
for name, result, expected in failures:
|
||||
print(
|
||||
f"{name}: expected {expected}, got {result.returncode}\n"
|
||||
f"{result.stdout}{result.stderr}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for item in download_failures:
|
||||
print(f"FAIL: {item}", file=sys.stderr)
|
||||
return 1
|
||||
print("PASS: Terraform release plan safety checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["create"],
|
||||
"before": null,
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["delete"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
"after": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_route53_record.api_alias[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"alias": [
|
||||
{
|
||||
"name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com",
|
||||
"zone_id": "Z35SXDOTRQ7X7K"
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"alias": [
|
||||
{
|
||||
"name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com",
|
||||
"zone_id": "Z117KPS5GTRQ2G"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
||||
"name": "ASPNETCORE_ENVIRONMENT",
|
||||
"value": "Production"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
||||
"name": "ASPNETCORE_ENVIRONMENT",
|
||||
"value": "Development"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"resource_changes": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"permissions_boundary": "arn:aws:iam::396287094661:policy/shoc-backend-dev-deploy-boundary"
|
||||
},
|
||||
"after": {
|
||||
"permissions_boundary": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": { "description": "old" },
|
||||
"after": { "description": "new" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "prod", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true,
|
||||
"tags": { "env": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["delete", "create"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"name": "shoc-backend-dev"
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"name": "shoc-backend-dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true,
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.runtime",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": { "name": "shoc-backend-dev" },
|
||||
"after": { "name": "shoc-backend-dev" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "cccccccccccccccccccccccccccccccccccccccc-9-9",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ workflow waits for CI, tags `vX.Y.Z-staging` from main HEAD with
|
|||
`GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave
|
||||
`PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment.
|
||||
Staging remains `adoption_complete=false` with a pinned API CNAME until its
|
||||
import apply is proven.
|
||||
import apply is proven after the first `vX.Y.Z-staging` GitHub-owned zip.
|
||||
|
||||
HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative
|
||||
plans on every PR are the infra gate. Do not point `TFC_AWS_*` at
|
||||
|
|
@ -153,10 +153,6 @@ Terraform changes stay in separate PRs so a merge cannot race an HCP apply
|
|||
against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only
|
||||
tags skip HCP when trigger patterns do not match.
|
||||
|
||||
Until cutover, `.github/workflows/deploy.yml` still uses `TF_API_TOKEN` and
|
||||
`TERRAFORM_APP_CD_ENABLED`. Keep those secrets and the version-only plan guard
|
||||
on that leftover path only.
|
||||
|
||||
### Credentials
|
||||
|
||||
Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
||||
|
|
@ -164,23 +160,18 @@ Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
|||
`repo:Sea-Haven-Industries/shoc-backend:environment:<env>` plus
|
||||
`job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main`
|
||||
and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM
|
||||
change. After cutover, drop `TF_API_TOKEN` from GitHub Environments. The new
|
||||
CD path does not use it.
|
||||
change. The new CD path does not use `TF_API_TOKEN`.
|
||||
|
||||
GitHub Environment deployment branch and tag policies are repository
|
||||
settings, not this diff. Update them before the first merge to `main` and
|
||||
the first staging cut. The policy matches `GITHUB_REF` of the workflow run.
|
||||
settings, not this diff. The policy matches `GITHUB_REF` of the workflow run.
|
||||
Branch patterns never match tag refs; adding `v*` as a branch pattern fails
|
||||
the same way as an empty allowlist.
|
||||
|
||||
1. `dev` — allow branch `main`. Keep `dev` allowed while leftover
|
||||
`.github/workflows/deploy.yml` still deploys from that branch.
|
||||
2. `staging` — add a **tag-type** policy matching `v*.*.*-staging` for
|
||||
1. `dev` — allow branch `main`.
|
||||
2. `staging` — **tag-type** policy matching `v*.*.*-staging` for
|
||||
`deploy-tag.yaml`. Allow branch `main` because Actions → Release is
|
||||
`workflow_dispatch` on `main` and then calls `deploy.yaml`
|
||||
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Keep
|
||||
`staging` allowed while leftover `deploy.yml` still deploys from that
|
||||
branch.
|
||||
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`).
|
||||
|
||||
Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED`
|
||||
unset. Until the `prod` environment exists with reviewers, do not run
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue