Compare commits

..

No commits in common. "582af8fb2ceda51cd16eed4e220202f4d07b1e2f" and "cbecc7b4eaec3becb2f37f50d2346ba26726d415" have entirely different histories.

27 changed files with 2230 additions and 260 deletions

View file

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

1238
.github/workflows/deploy.yml vendored Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -73,6 +73,8 @@ 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

@ -6,7 +6,7 @@ namespace SeaHaven.Services.Helpers
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
/// manual POC override. The same rules back the board projection fallback, the
/// completion snapshot fallback, the create-time baseline, and the
/// "changing away from the contact the work order follows" lock comparison.
/// "changing away from the Site's live contact" lock comparison.
/// </summary>
public static class WorkOrderPocSiteContact
{
@ -28,21 +28,24 @@ namespace SeaHaven.Services.Helpers
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
/// <summary>
/// SH-190: true when a manual POC (trimmed name + phone) equals the single
/// contact the work order currently follows — the linked WorkOrderContacts
/// POC, or else the Site primary. Such an edit is not a change away from
/// that contact, so it must not lock or store an override. Comparing against
/// only the followed contact (rather than any live Site contact) means an
/// edit to a different live contact is still stored, since the work order
/// only ever displays the one it follows.
/// SH-190: a manual POC equal to one of the Site's live contacts (trimmed
/// name + phone) is not a change away from the Site and must not lock.
/// </summary>
public static bool Matches(string? name, string? phone, Contacts? contact)
public static bool MatchesAnySiteContact(
string? name,
string? phone,
IEnumerable<Contacts>? contacts)
{
if (contact == null)
if (contacts == null)
return false;
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
var normalizedName = Normalize(name);
var normalizedPhone = Normalize(phone);
return contacts
.Where(c => c.IsDeleted != true)
.Any(c => string.Equals(Normalize(((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim()), normalizedName, StringComparison.Ordinal)
&& string.Equals(Normalize(c.PhoneNumber), normalizedPhone, StringComparison.Ordinal));
}
private static string? Normalize(string? value)

View file

@ -67,21 +67,11 @@ namespace SeaHaven.Services.Implementation
var pocName = TrimOrNull(request.PocName);
var pocPhone = TrimOrNull(request.PocPhone);
var pocNotes = TrimOrNull(request.PocNotes);
// SH-190 AC3: a create-time POC equal to the single contact the work
// order will follow is not an override. Store nothing so the work order
// keeps following that contact until a dispatcher edits away from it.
// The followed contact is the linked POC (PocContactId) when one is
// supplied, otherwise the Site primary. Comparing against the followed
// contact rather than any live Site contact means a create-time POC that
// matches a *different* Site contact is stored, since the board only
// shows the one the work order follows. A supplied PocContactId that is
// not among the Site's live contacts leaves no follow target, so the
// typed POC is stored.
// SH-190 AC3: a create-time POC equal to one of the Site's live contacts
// is not an override. Store nothing so the work order keeps following
// the Site's live contact until a dispatcher edits away from it.
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
var followContact = request.PocContactId.HasValue
? siteContacts.FirstOrDefault(c => c.Id == request.PocContactId.Value)
: WorkOrderPocSiteContact.ResolvePrimary(siteContacts);
if (WorkOrderPocSiteContact.Matches(pocName, pocPhone, followContact))
if (WorkOrderPocSiteContact.MatchesAnySiteContact(pocName, pocPhone, siteContacts))
{
pocName = null;
pocPhone = null;

View file

@ -13,8 +13,7 @@ namespace SeaHaven.Services.Implementation
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
/// FieldChanged audit entries (which also write field locks so later syncs never
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
/// An edit equal to the contact the work order currently follows (its linked
/// WorkOrderContacts POC, or else the Site primary) stores no override, so a
/// An edit equal to one of the Site's live contacts stores no override, so a
/// never-overridden work order keeps following the Site (SH-190 AC3).
/// </summary>
public class WorkOrderPocService : IWorkOrderPocService
@ -76,19 +75,13 @@ namespace SeaHaven.Services.Implementation
var newPhone = TrimOrNull(request.PocPhone);
var newNotes = TrimOrNull(request.PocNotes);
// SH-190: changing away from the contact the work order follows
// locks the POC. An edit that equals that single contact — the
// linked WorkOrderContacts POC, or else the Site primary — (and
// blanking both fields) stores no override, so the work order keeps
// following it. Comparing against the followed contact rather than
// any live Site contact means editing to a *different* Site contact
// is stored as an override, since the board only ever shows the one
// the work order follows.
var followContact = linkedContact ?? sitePrimary;
// SH-190: changing away from the Site's live contact locks the POC.
// An edit that equals one of the Site's live contacts (and blanking
// both fields) stores no override, so the work order follows the Site.
var overrideName = newName;
var overridePhone = newPhone;
if ((overrideName != null || overridePhone != null)
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
&& WorkOrderPocSiteContact.MatchesAnySiteContact(overrideName, overridePhone, workOrder.Locations?.Contacts))
{
overrideName = null;
overridePhone = null;

View file

@ -402,129 +402,6 @@ public class WorkOrderPocServiceTests
Assert.True(row.PocCustomized);
}
[Fact]
public async Task EditToSecondSiteContactStoresOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
await context.SaveChangesAsync();
// The WO follows the Site primary (Alice). Editing to the second live Site
// contact (Bob) is a change away from what the board shows and must be
// stored and locked, not silently cleared back to Alice.
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Bob Backup", "312-555-0200", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Bob Backup", persisted.PocName);
Assert.Equal("312-555-0200", persisted.PocPhone);
Assert.Equal("Bob Backup", row.PocName);
Assert.Equal("312-555-0200", row.PocPhone);
Assert.True(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
Assert.Equal("Alice Site", auditName.OldValue);
Assert.Equal("Bob Backup", auditName.NewValue);
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
}
[Fact]
public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride()
{
await using var context = CreateContext();
await SeedScopeAsync(context);
var workOrder = NewWorkOrder(context);
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
var linked = new Contacts
{
FirstName = "Carol",
LastName = "Linked",
PhoneNumber = "312-555-0300"
};
context.Contacts.Add(linked);
await context.SaveChangesAsync();
context.WorkOrderContacts.Add(new WorkOrderContacts
{
WorkorderId = workOrder.Id,
ContactId = linked.Id
});
await context.SaveChangesAsync();
// The WO follows its linked contact (Carol). Typing the Site's primary
// (Alice) is a change away from what the board shows and must be stored,
// not cleared back to Carol.
var service = CreatePocService(context);
var row = await service.UpdatePocAsync(
workOrder.Id,
Poc("Alice Site", "312-555-0100", null, workOrder),
WorkOrderAccountTestHelpers.AccountUser(),
"actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
Assert.Equal("Alice Site", persisted.PocName);
Assert.Equal("312-555-0100", persisted.PocPhone);
Assert.Equal("Alice Site", row.PocName);
Assert.Equal("312-555-0100", row.PocPhone);
Assert.True(row.PocCustomized);
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
Assert.Equal("Carol Linked", auditName.OldValue);
Assert.Equal("Alice Site", auditName.NewValue);
}
[Fact]
public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored()
{
await using var context = CreateContext();
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Contacts.Add(new Contacts
{
FirstName = "Alice",
LastName = "Site",
PhoneNumber = "312-555-0100",
LocationId = 1,
SiteContactOrder = 0
});
context.Contacts.Add(new Contacts
{
FirstName = "Bob",
LastName = "Backup",
PhoneNumber = "312-555-0200",
LocationId = 1,
SiteContactOrder = 1
});
await context.SaveChangesAsync();
// Create-time POC equal to the second Site contact (Bob) is not the contact
// the WO would follow (the primary, Alice), so it must be stored.
var service = CreateCreateService(context);
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
PocName = "Bob Backup",
PocPhone = "312-555-0200"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
Assert.Equal("Bob Backup", persisted.PocName);
Assert.Equal("312-555-0200", persisted.PocPhone);
Assert.True(row.PocCustomized);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()

View file

@ -0,0 +1,328 @@
#!/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,10 +9,6 @@ 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 = {
@ -56,18 +52,6 @@ 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 = {
@ -108,18 +92,6 @@ 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

@ -0,0 +1,295 @@
#!/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

@ -0,0 +1,16 @@
{
"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

@ -0,0 +1,16 @@
{
"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

@ -0,0 +1,28 @@
{
"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

@ -0,0 +1,34 @@
{
"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

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

View file

@ -0,0 +1,18 @@
{
"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

@ -0,0 +1,32 @@
{
"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

@ -0,0 +1,39 @@
{
"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

@ -0,0 +1,20 @@
{
"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

@ -0,0 +1,39 @@
{
"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

@ -0,0 +1,48 @@
{
"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

@ -0,0 +1,22 @@
{
"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. Environment secrets `TF_API_TOKEN` and
`AWS_DEPLOY_ROLE_ARN` were removed at cutover.
by application CD after cutover. Adoption may still have the older
`AWS_DEPLOY_ROLE_ARN` secret until that cutover.
## Local validation

View file

@ -15,10 +15,7 @@ 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. 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.
resources must never enter an environment state.
Secret values are not Terraform resources, variables, outputs, or managed EB
settings. Terraform manages the app-config secret shell and maps approved JSON
@ -135,10 +132,7 @@ 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. 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
drift. 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.
@ -148,9 +142,8 @@ 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 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.
Staging remains `adoption_complete=false` with a pinned API CNAME until its
import apply is proven.
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
@ -160,6 +153,10 @@ 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`,
@ -167,18 +164,23 @@ 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. The new CD path does not use `TF_API_TOKEN`.
change. After cutover, drop `TF_API_TOKEN` from GitHub Environments. The new
CD path does not use it.
GitHub Environment deployment branch and tag policies are repository
settings, not this diff. The policy matches `GITHUB_REF` of the workflow run.
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.
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`.
2. `staging` — **tag-type** policy matching `v*.*.*-staging` for
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
`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`).
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Keep
`staging` allowed while leftover `deploy.yml` still deploys from that
branch.
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,62 +289,20 @@ 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 {
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}/*",
]
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:GetBucketPolicy",
"s3:GetBucketAcl",
"s3:GetBucketVersioning",
"s3:GetBucketOwnershipControls",
]
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
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 = true
manage_eb_settings = true
adoption_complete = false
manage_eb_settings = false
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 = null
instance_security_group_id = "sg-02ea36a6719217fa2"
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"