mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 19:13:13 +00:00
Merge branch 'main' into fix/ab/sh-381-mobile-video-mime
This commit is contained in:
commit
51417a65b5
23 changed files with 99 additions and 2206 deletions
2
.github/workflows/deploy-tag.yaml
vendored
2
.github/workflows/deploy-tag.yaml
vendored
|
|
@ -10,6 +10,8 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
target:
|
||||
|
|
|
|||
1238
.github/workflows/deploy.yml
vendored
1238
.github/workflows/deploy.yml
vendored
File diff suppressed because it is too large
Load diff
1
.github/workflows/release.yaml
vendored
1
.github/workflows/release.yaml
vendored
|
|
@ -24,6 +24,7 @@ on:
|
|||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue