Merge branch 'main' into fix/ab/sh-379-manual-poc-override

This commit is contained in:
Adam Moussa 2026-09-18 17:31:45 -04:00 • committed by GitHub
commit 503faa8c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 99 additions and 2206 deletions

View file

@ -10,6 +10,8 @@ on:
permissions:
contents: read
checks: read
id-token: write
jobs:
target:

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ on:
permissions:
contents: write
checks: read
id-token: write
jobs:
cut:

View file

@ -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

View file

@ -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())

View file

@ -9,6 +9,10 @@ COMMON_RESOURCES = {
"module.environment.aws_iam_role_policy.runtime_app_config": "aws_iam_role_policy",
"module.environment.aws_iam_role_policy_attachment.web_tier": "aws_iam_role_policy_attachment",
"module.environment.aws_secretsmanager_secret.app_config": "aws_secretsmanager_secret",
"module.environment.aws_ssm_parameter.deploy_application_name": "aws_ssm_parameter",
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": "aws_ssm_parameter",
"module.environment.aws_ssm_parameter.deploy_environment_name": "aws_ssm_parameter",
"module.environment.aws_ssm_parameter.deploy_smoke_url": "aws_ssm_parameter",
}
REQUIRED_RESOURCES = {
@ -52,6 +56,18 @@ DEV_IMPORT_IDS = {
"module.environment.aws_route53_record.api_alias[0]": (
"Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A"
),
"module.environment.aws_ssm_parameter.deploy_application_name": (
"/shoc-backend/dev/deploy/application-name"
),
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": (
"/shoc-backend/dev/deploy/artifacts-bucket"
),
"module.environment.aws_ssm_parameter.deploy_environment_name": (
"/shoc-backend/dev/deploy/environment-name"
),
"module.environment.aws_ssm_parameter.deploy_smoke_url": (
"/shoc-backend/dev/deploy/smoke-url"
),
}
DEV_IMPORT_BASELINE = {
@ -92,6 +108,18 @@ STAGING_IMPORT_IDS = {
"module.environment.aws_route53_record.api_cname[0]": (
"Z02602739VQWBWCAGXP4_api.staging.seahaven.com_CNAME"
),
"module.environment.aws_ssm_parameter.deploy_application_name": (
"/shoc-backend/staging/deploy/application-name"
),
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": (
"/shoc-backend/staging/deploy/artifacts-bucket"
),
"module.environment.aws_ssm_parameter.deploy_environment_name": (
"/shoc-backend/staging/deploy/environment-name"
),
"module.environment.aws_ssm_parameter.deploy_smoke_url": (
"/shoc-backend/staging/deploy/smoke-url"
),
}
STAGING_IMPORT_BASELINE = {

View file

@ -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())

View file

@ -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"
}
}
}
]
}

View file

@ -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
}
}
]
}

View file

@ -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"
}
]
}
}
}
]
}

View file

@ -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" }
}
}
}
]
}

View file

@ -1,3 +0,0 @@
{
"resource_changes": []
}

View file

@ -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
}
}
}
]
}

View file

@ -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" }
}
}
]
}

View file

@ -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 }
}
}
}
]
}

View file

@ -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"
}
}
}
]
}

View file

@ -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
}
}
}
]
}

View file

@ -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
}
}
}
]
}

View file

@ -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" }
}
}
}
]
}

View file

@ -37,8 +37,8 @@ only explicitly allowlisted ownership metadata and the narrowed dev deploy S3
policy.
The GitHub Environment **variable** `DEPLOY_ROLE_ARN` is the OIDC role used
by application CD after cutover. Adoption may still have the older
`AWS_DEPLOY_ROLE_ARN` secret until that cutover.
by application CD. Environment secrets `TF_API_TOKEN` and
`AWS_DEPLOY_ROLE_ARN` were removed at cutover.
## Local validation

View file

@ -15,7 +15,10 @@ shared or Elastic Beanstalk-generated infrastructure.
The shared `shoc-backend` Elastic Beanstalk application and
`shoc-sqlserver-shared` RDS instance, VPC, subnets, EB service role, shared
certificate, shared RDS security group, and EB-generated SG/ALB/ASG/CloudFormation
resources must never enter an environment state.
resources must never enter an environment state. Both roots leave
`instance_security_group_id` null: the AWS provider reports the EB-generated
`awseb-*-AWSEBSecurityGroup` as an empty `SecurityGroups` setting, so pinning it
produces a permanent update diff.
Secret values are not Terraform resources, variables, outputs, or managed EB
settings. Terraform manages the app-config secret shell and maps approved JSON
@ -132,7 +135,10 @@ exact bundle it uploads; bundle bytes never enter Terraform plans or state.
GitHub Actions owns application versions. It compiles the bundle, uploads it,
creates the Elastic Beanstalk application version, and calls
`UpdateEnvironment`. Terraform ignores `version_label` so those deploys are not
drift. If health, smoke, or the webhook probe fails after that update, the job
drift. The non-legacy deploy policy writes bundles only under
`shoc-backend/releases/<environment>/*`; the Elastic Beanstalk staging
prefixes (`resources/_runtime/_embedded_extensions/shoc-backend/*` and
`resources/environments/<env-id>/*`) keep the full object and ACL action set. If health, smoke, or the webhook probe fails after that update, the job
restores the previous Elastic Beanstalk version label. Database migrations
already applied by the failed bundle are not reverted. Deploy parameters are read from `/shoc-backend/<env>/deploy/*` SSM
parameters this module writes.
@ -142,8 +148,9 @@ cut from **Actions → Release** (`environment`, `bump`, `message`). That
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.
Staging import is proven after the first GitHub-owned zip
(`v0.0.1-staging`). Terraform now manages the declared Elastic Beanstalk
settings. The API CNAME stays pinned to the imported ALB target.
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 +160,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 +167,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

View file

@ -289,20 +289,62 @@ data "aws_iam_policy_document" "deploy" {
}
}
# deploy.yaml uploads each bundle to
# <app>/releases/<environment>/<sha>/<run>/site.zip and Elastic Beanstalk
# reads it back from there. The deploy role never writes another
# environment's release prefix.
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
sid = "UploadReleaseBundle"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:GetObject",
]
resources = [
"arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/releases/${var.environment}/*",
]
}
}
# Elastic Beanstalk stages the processed version, embedded extensions,
# and manifests under environment-scoped prefixes and requires object ACLs
# on this BucketOwnerPreferred bucket.
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
sid = "ManageEnvironmentArtifacts"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:PutObjectAcl",
"s3:PutObjectVersionAcl",
"s3:GetObject",
"s3:GetObjectAcl",
"s3:GetObjectVersion",
"s3:GetObjectVersionAcl",
"s3:DeleteObject",
]
resources = [
"arn:aws:s3:::${local.eb_bucket_name}/resources/_runtime/_embedded_extensions/${var.eb_application_name}/*",
"arn:aws:s3:::${local.eb_bucket_name}/resources/environments/${var.eb_environment_id}/*",
]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:GetBucketPolicy",
"s3:GetBucketAcl",
"s3:GetBucketVersioning",
"s3:GetBucketOwnershipControls",
]
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
}
}

View file

@ -26,8 +26,8 @@ module "environment" {
aws_account_id = local.aws_account_id
aws_region = local.aws_region
environment = "staging"
adoption_complete = false
manage_eb_settings = false
adoption_complete = true
manage_eb_settings = true
eb_application_name = local.eb_application_name
eb_environment_name = local.eb_environment_name
eb_environment_id = local.eb_environment_id
@ -35,7 +35,7 @@ module "environment" {
vpc_id = "vpc-0d16336143f3da25e"
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
instance_security_group_id = "sg-02ea36a6719217fa2"
instance_security_group_id = null
eb_service_role_name = "shoc-eb-service-role"
shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
runtime_role_name = "shoc-backend-staging"