feat(terraform): adopt live deployment roles safely

Add import-only dev and staging state with least-privilege HCP authentication and plan safety guards.
This commit is contained in:
Adam Moussa 2026-08-28 19:12:34 -04:00
parent b605d5be02
commit 25c2e84e8f
51 changed files with 3039 additions and 17 deletions

50
.github/workflows/ci-terraform.yaml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Terraform CI
on:
pull_request:
branches: [dev, staging, main]
paths:
- "terraform/**"
- "scripts/check-terraform-import-plan.py"
- "scripts/test-terraform-import-plan-check.py"
- ".github/workflows/ci-terraform.yaml"
push:
branches: [dev, staging, main]
paths:
- "terraform/**"
- "scripts/check-terraform-import-plan.py"
- "scripts/test-terraform-import-plan-check.py"
- ".github/workflows/ci-terraform.yaml"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dir:
- terraform
- terraform/bootstrap
- terraform/live/dev
- terraform/live/staging
defaults:
run:
working-directory: ${{ matrix.dir }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- name: Terraform fmt
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate

View file

@ -1,10 +1,10 @@
name: Validate and deploy dev
name: Validate and deploy
on:
pull_request:
branches: [dev]
branches: [dev, staging, main]
push:
branches: [dev]
branches: [dev, staging, main]
workflow_dispatch:
permissions:
@ -32,6 +32,11 @@ jobs:
cache: npm
cache-dependency-path: infra/cdk/package-lock.json
- name: Set up Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- name: Repository quality gate
run: bash scripts/governance-check.sh
@ -40,6 +45,15 @@ jobs:
npm ci --prefix infra/cdk
npm run synth --prefix infra/cdk
- name: Terraform fmt and validate
run: |
set -euo pipefail
for dir in terraform terraform/bootstrap; do
terraform -chdir="$dir" fmt -check -recursive
terraform -chdir="$dir" init -backend=false
terraform -chdir="$dir" validate
done
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
@ -66,22 +80,61 @@ jobs:
.artifacts/elastic-beanstalk/webhook-config.txt
deploy:
name: Deploy shoc-backend to Elastic Beanstalk dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
name: Deploy shoc-backend to Elastic Beanstalk
if: >
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' &&
contains(fromJSON('["refs/heads/dev","refs/heads/staging","refs/heads/main"]'), github.ref))
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: dev
name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name }}
concurrency:
group: deploy-dev
group: deploy-${{ github.ref_name == 'main' && 'prod' || github.ref_name }}
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Resolve deploy target
id: target
run: |
set -euo pipefail
case "${GITHUB_REF_NAME}" in
dev)
application=shoc-backend
environment=shoc-backend-dev
smoke_url=https://api.dev.seahaven.com
;;
staging)
application=shoc-backend
environment=shoc-backend-staging
smoke_url=https://api.staging.seahaven.com
;;
main)
application=shoc-backend
environment=shoc-backend-prod
smoke_url=https://api.seahaven.com
;;
*)
echo "Unsupported ref ${GITHUB_REF_NAME}" >&2
exit 1
;;
esac
{
echo "application=${application}"
echo "environment=${environment}"
echo "smoke_url=${smoke_url}"
} >> "${GITHUB_OUTPUT}"
{
echo "EB_APPLICATION_NAME=${application}"
echo "EB_ENVIRONMENT_NAME=${environment}"
echo "SMOKE_URL=${smoke_url}"
} >> "${GITHUB_ENV}"
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
@ -101,7 +154,7 @@ jobs:
run: |
set -euo pipefail
prev="$(aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].VersionLabel' \
--output text)"
@ -112,8 +165,8 @@ jobs:
uses: aws-actions/aws-elasticbeanstalk-deploy@7883cdd454c162051bf6fc13389536b045149b4c # v1.0.8
with:
aws-region: us-east-1
application-name: shoc-backend
environment-name: shoc-backend-dev
application-name: ${{ steps.target.outputs.application }}
environment-name: ${{ steps.target.outputs.environment }}
version-label: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
deployment-package-path: .artifacts/elastic-beanstalk/site.zip
s3-bucket-name: elasticbeanstalk-us-east-1-396287094661
@ -135,7 +188,7 @@ jobs:
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
@ -157,7 +210,7 @@ jobs:
exit 1
- name: Post-deploy smoke
run: bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com
run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}"
- name: Verify webhook secret source is operational
run: |
@ -173,7 +226,7 @@ jobs:
--header 'X-SH-Key-Id: deployment-smoke-invalid-key' \
--header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \
--data '{}' \
https://api.dev.seahaven.com/api/webhooks/work-orders)"
"${SMOKE_URL}/api/webhooks/work-orders")"
if [ "$status" != "401" ]; then
echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2
sed -n '1,20p' "$response_file" >&2
@ -202,7 +255,7 @@ jobs:
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
@ -223,10 +276,10 @@ jobs:
exit 0
fi
echo "Restoring shoc-backend-dev application code to version label: $prev"
echo "Restoring ${EB_ENVIRONMENT_NAME} application code to version label: $prev"
echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy."
aws elasticbeanstalk update-environment \
--environment-name shoc-backend-dev \
--environment-name "${EB_ENVIRONMENT_NAME}" \
--version-label "$prev" \
--region us-east-1
@ -234,7 +287,7 @@ jobs:
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text

11
.gitignore vendored
View file

@ -374,3 +374,14 @@ infra/cdk/.cdk.staging/
# Deployment packaging artifacts
.artifacts/
# Terraform (HCP remote state; never commit tfvars with secrets)
**/.terraform/
*.tfvars
!*.tfvars.example
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

View file

@ -24,6 +24,7 @@
| G7 | Cancellation forwarding | §6 | behavior tests on changed I/O paths + analyzer | review-enforced on changed paths |
| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` |
| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced |
| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` |
## How to run locally
@ -45,6 +46,8 @@ The script:
changed C# files it skips G3 with an explicit "skipped: no changed C#" line.
4. builds the complete solution in Release with no restore (G4).
5. runs the complete solution test suite in Release with no rebuild (G5).
6. verifies that the Terraform plan guard rejects create, delete, replacement,
and unapproved update actions (G10).
## Migration gates (G6)

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Reject unsafe actions in a live Terraform import plan."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ALLOWED_MANAGED_TYPES = {"aws_iam_role", "aws_iam_role_policy"}
UNSAFE_ACTIONS = {"create", "delete"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("plan_json", type=Path)
parser.add_argument(
"--allow-update",
action="store_true",
help="Allow in-place updates after the initial no-op import is proven.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
violations: list[str] = []
managed = 0
updates = 0
for resource in plan.get("resource_changes", []):
if resource.get("mode", "managed") != "managed":
continue
resource_type = resource.get("type", "")
address = resource.get("address", "<unknown>")
actions = set(resource.get("change", {}).get("actions", []))
managed += 1
if resource_type not in ALLOWED_MANAGED_TYPES:
violations.append(
f"{address}: managed type {resource_type!r} is outside the live ownership boundary"
)
unsafe = sorted(actions & UNSAFE_ACTIONS)
if unsafe:
violations.append(f"{address}: unsafe actions {unsafe}")
if "update" in actions:
updates += 1
if not args.allow_update:
violations.append(
f"{address}: update is forbidden during the initial no-op import"
)
if violations:
print("FAIL: live Terraform plan is not import-safe", file=sys.stderr)
for violation in violations:
print(f" - {violation}", file=sys.stderr)
return 1
mode = "controlled update" if args.allow_update else "no-op import"
print(
f"PASS: {mode} plan has {managed} managed resources, "
f"{updates} updates, and no create/delete/replace actions"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

113
scripts/deploy-api-tf.sh Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# deploy-api-tf.sh — local Elastic Beanstalk publish for the Terraform POC.
# Refuses live names. GitHub Actions is the real CD path once the branch is
# pushed; this script exists only because GHA cannot run until then.
#
# Usage:
# export AWS_PROFILE=seahaven-external-dev
# bash scripts/deploy-api-tf.sh
set -euo pipefail
REGION="${AWS_REGION:-us-east-1}"
APPLICATION_NAME="shoc-backend-tf-poc"
ENVIRONMENT_NAME="shoc-backend-tf-poc"
SMOKE_URL="https://tf-poc.api.dev.seahaven.com"
S3_BUCKET="elasticbeanstalk-us-east-1-396287094661"
log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
die() { printf '\033[31mERR\033[0m %s\n' "$1" >&2; exit 1; }
[[ "${APPLICATION_NAME}" != "shoc-backend" ]] \
|| die "refusing live Elastic Beanstalk application shoc-backend"
[[ "${ENVIRONMENT_NAME}" != "shoc-backend-dev" ]] \
|| die "refusing live Elastic Beanstalk environment shoc-backend-dev"
[[ "${SMOKE_URL}" != "https://api.dev.seahaven.com" ]] \
|| die "refusing live hostname api.dev.seahaven.com"
command -v aws >/dev/null 2>&1 || die "aws CLI is required"
command -v curl >/dev/null 2>&1 || die "curl is required"
ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
[[ "${ACCOUNT}" == "396287094661" ]] \
|| die "refusing to deploy outside seahaven-external-dev (caller account ${ACCOUNT})"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
log "package source bundle"
bash scripts/package-elastic-beanstalk.sh
VERSION_LABEL="local-$(date -u +%Y%m%d%H%M%S)-${USER:-unknown}"
BUNDLE=".artifacts/elastic-beanstalk/site.zip"
KEY="shoc-backend-tf-poc/${VERSION_LABEL}.zip"
log "capture current environment version"
prev="$(aws elasticbeanstalk describe-environments \
--environment-names "${ENVIRONMENT_NAME}" \
--region "${REGION}" \
--query 'Environments[0].VersionLabel' \
--output text)"
echo "${prev}" > .artifacts/elastic-beanstalk/previous-version.txt
echo "Previous version label: ${prev}"
log "upload bundle ${KEY}"
aws s3 cp "${BUNDLE}" "s3://${S3_BUCKET}/${KEY}" --region "${REGION}"
log "create application version ${VERSION_LABEL}"
aws elasticbeanstalk create-application-version \
--application-name "${APPLICATION_NAME}" \
--version-label "${VERSION_LABEL}" \
--source-bundle "S3Bucket=${S3_BUCKET},S3Key=${KEY}" \
--region "${REGION}"
log "update environment ${ENVIRONMENT_NAME}"
aws elasticbeanstalk update-environment \
--environment-name "${ENVIRONMENT_NAME}" \
--version-label "${VERSION_LABEL}" \
--region "${REGION}"
log "wait until expected version is Ready"
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names "${ENVIRONMENT_NAME}" \
--region "${REGION}" \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: ${status}; version: ${current}; health: ${health}"
if [ "${status}" = "Ready" ]; then
if [ "${current}" = "${VERSION_LABEL}" ] && { [ "${health}" = "Green" ] || [ "${health}" = "Yellow" ]; }; then
echo "Expected application version is Ready and healthy."
break
fi
die "Environment became Ready without activating expected version ${VERSION_LABEL}."
fi
sleep 15
done
[[ "${status}" = "Ready" ]] || die "Expected application version did not become Ready."
log "smoke ${SMOKE_URL}"
bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}"
log "webhook secret source"
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
status_code="$(curl --silent --show-error \
--output "$response_file" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header "X-SH-Timestamp: $(date +%s)" \
--header 'X-SH-Key-Id: deployment-smoke-invalid-key' \
--header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \
--data '{}' \
"${SMOKE_URL}/api/webhooks/work-orders")"
if [ "${status_code}" != "401" ]; then
die "Expected enabled webhook to reject the invalid probe with 401; received ${status_code}."
fi
echo "webhook HTTP 401"

View file

@ -79,4 +79,8 @@ log "G5: full test suite"
"$DOTNET" test "$SOLUTION" -c Release --no-build --nologo
ok "G5: full test suite"
log "G10: Terraform import plan safety"
python scripts/test-terraform-import-plan-check.py
ok "G10: Terraform import plan safety"
log "governance-check: all required repository gates passed"

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Small deterministic tests for check-terraform-import-plan.py."""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
def run_case(actions: list[str], *, allow_update: bool = False) -> subprocess.CompletedProcess[str]:
plan = {
"resource_changes": [
{
"address": "module.deploy_role.aws_iam_role.github_deploy",
"mode": "managed",
"type": "aws_iam_role",
"change": {"actions": actions},
}
]
}
with tempfile.TemporaryDirectory() as directory:
plan_path = Path(directory) / "plan.json"
plan_path.write_text(json.dumps(plan), encoding="utf-8")
command = [sys.executable, str(SCRIPT), str(plan_path)]
if allow_update:
command.append("--allow-update")
return subprocess.run(command, check=False, capture_output=True, text=True)
def main() -> int:
cases = [
("no-op import", run_case(["no-op"]), 0),
("initial update", run_case(["update"]), 1),
("controlled update", run_case(["update"], allow_update=True), 0),
("create", run_case(["create"]), 1),
("replacement", run_case(["delete", "create"]), 1),
("destroy", run_case(["delete"]), 1),
]
failures = [
(name, result, expected)
for name, result, expected in cases
if result.returncode != expected
]
if failures:
print(
"FAIL: 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,
)
return 1
print("PASS: Terraform import plan safety checks")
return 0
if __name__ == "__main__":
raise SystemExit(main())

53
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,53 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=",
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=",
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.9.0"
constraints = "~> 3.6"
hashes = [
"h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=",
"h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=",
"h1:o0s5Mk9NXMP60nlheO1r0LsDGGratFb3oL0t7bD2QnM=",
"h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=",
"zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1",
"zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea",
"zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f",
"zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0",
"zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61",
"zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc",
"zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e",
"zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef",
"zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b",
"zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257",
"zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04",
]
}

170
terraform/README.md Normal file
View file

@ -0,0 +1,170 @@
# Terraform deployment infrastructure
The root module remains the isolated `tf-poc` Elastic Beanstalk and SQL Server
stack in `seahaven-external-dev` (`396287094661`). It must never be reused for
dev or staging state.
Live adoption is deliberately smaller. [`live/`](live/) imports only the
existing GitHub Actions deploy roles and inline policies. All application,
environment, database, certificate, DNS, network, runtime IAM, and secret
resources remain external and are read only as inventory.
The Terraform roots are:
- `./`: isolated POC workload in `shoc-backend-tf-poc`.
- `bootstrap/`: consolidated POC/dev/staging HCP role bootstrap in
`shoc-backend-bootstrap`.
- `live/dev/`: import-only dev deploy-role ownership in
`shoc-backend-dev`.
- `live/staging/`: import-only staging deploy-role ownership in
`shoc-backend-staging`.
IAM lives in this repo, not org-baseline. App CD never runs a bootstrap root.
Auto-apply stays off for every workspace.
The stabilized POC bootstrap deliberately leaves both POC HCP roles read only
and removes `ViewOnlyAccess` plus the broad workload-mutation inline policy.
The POC GitHub deploy role remains unchanged until a separately approved
narrowing or teardown. Future POC teardown runs directly under the approved
SSO administrator session, not the locked HCP apply role.
## Locked names
- Hostname: `tf-poc.api.dev.seahaven.com` (zone `Z07671212N75U4YLPWZR8`)
- EB application / environment: `shoc-backend-tf-poc`
- RDS identifier: `shoc-backend-tf-poc`
- Catalog: `shoc_tf_poc` (create this database once after RDS is available)
- Deploy role: `arn:aws:iam::396287094661:role/tf-managed/githubdeploy-shoc-backend-tf-poc`
- GitHub Environment: `tf-poc` (OIDC `environment:tf-poc`)
- HCP org `seahaven`, project `seahaven-external-dev`
## HCP layout
All SHOC backend environments live in AWS account `396287094661` and HCP
The POC workspaces remain isolated until teardown. The live bootstrap owns
`hcptf-shoc-backend-{dev,staging}` and matching `-plan` roles. The live
environment workspaces own only their existing GitHub deploy role and inline
policy. See [`live/README.md`](live/README.md).
## Console setup (once)
1. In HCP Terraform, create project `seahaven-external-dev` if it does not exist.
2. Create workspace `shoc-backend-bootstrap`:
- VCS later, or CLI-driven until the branch is pushed
- Working directory: `terraform/bootstrap`
- Execution mode: **Local**
- Auto-apply: off
3. Create workspace `shoc-backend-tf-poc`:
- Same branch
- Working directory: `terraform`
- Execution mode: **Remote**
- Auto-apply: off
- Speculative plans: on
4. Do **not** use HCP "Quick setup AWS dynamic credentials".
## Discovery (before first workload apply)
```bash
export AWS_PROFILE=seahaven-external-dev
aws elasticbeanstalk describe-environments \
--environment-names shoc-backend-dev \
--region us-east-1 \
--query 'Environments[0].{Vpc:EndpointURL}'
aws elasticbeanstalk describe-configuration-settings \
--application-name shoc-backend \
--environment-name shoc-backend-dev \
--region us-east-1 \
--query "ConfigurationSettings[0].OptionSettings[?Namespace=='aws:ec2:vpc']"
```
Copy `vpc-REPLACE_ME` and subnet lists into a local `terraform/terraform.tfvars`
(gitignored). Confirm:
- `app.terraform.io` OIDC exists (`create_tfc_oidc_provider=false` in bootstrap).
If the data source fails, set `create_tfc_oidc_provider=true`.
- `external-dev-execution-boundary` exists.
- `aws-elasticbeanstalk-service-role` exists.
- GitHub OIDC provider `token.actions.githubusercontent.com` exists.
## Bootstrap apply (Adam)
SSO AdministratorAccess in this account is subject to the external-dev SCP, so
both `hcptf-*` roles set `permissions_boundary` to
`external-dev-execution-boundary`.
```bash
export AWS_PROFILE=seahaven-external-dev
cd terraform/bootstrap
terraform login
terraform init
terraform apply
```
Then on workspace `shoc-backend-tf-poc`, set workspace-scoped env vars:
- `TFC_AWS_PROVIDER_AUTH=true`
- `TFC_AWS_PLAN_ROLE_ARN` = output `hcp_plan_role_arn`
- `TFC_AWS_APPLY_ROLE_ARN` = output `hcp_apply_role_arn`
Never put those in a project variable set. Set `sendgrid_api_key` as a
sensitive Terraform variable on that workspace when you want mail to work.
## Workload apply
HCP Manual apply on `shoc-backend-tf-poc`. Confirm `api.dev.seahaven.com` still
serves live before and after.
After RDS is available, create the catalog once (SQL Server Express does not
accept `db_name` on `aws_db_instance`):
```sql
CREATE DATABASE [shoc_tf_poc];
```
Then first app deploy can run migrations into that catalog.
## App deploy
GitHub Actions is the real CD path. `.github/workflows/deploy.yml` maps:
- `dev` → `dev`
- `staging` → `staging`
- `main` → `prod`
The live roots import the roles already referenced by the `dev` and `staging`
GitHub Environment secrets. The role ARNs do not change during adoption.
The POC local fallback remains available until teardown:
```bash
export AWS_PROFILE=seahaven-external-dev
bash scripts/deploy-api-tf.sh
```
The script refuses live names (`shoc-backend-dev`, `api.dev.seahaven.com`).
## After POC confirmation
1. Create `shoc-backend-dev` and `shoc-backend-staging` workspaces. Do not
reuse POC state.
2. Apply `bootstrap/` only after approval to create the four narrowly scoped
live HCP roles.
3. Follow the no-op import and controlled-update sequence in
[`live/README.md`](live/README.md).
4. Retire CDK deploy-role ownership only after both role imports are proven.
5. Destroy the POC workload last. Bootstrap state remains.
Do not apply this module as `environment=dev` without imports.
## Local CI equivalent
```bash
terraform -chdir=terraform fmt -check -recursive
terraform -chdir=terraform init -backend=false && terraform -chdir=terraform validate
terraform -chdir=terraform/bootstrap init -backend=false && terraform -chdir=terraform/bootstrap validate
terraform -chdir=terraform/live/dev init -backend=false && terraform -chdir=terraform/live/dev validate
terraform -chdir=terraform/live/staging init -backend=false && terraform -chdir=terraform/live/staging validate
```

37
terraform/acm.tf Normal file
View file

@ -0,0 +1,37 @@
resource "aws_acm_certificate" "api" {
domain_name = var.domain_name
validation_method = "DNS"
tags = {
Name = var.domain_name
Project = "shoc-backend"
}
lifecycle {
create_before_destroy = true
}
depends_on = [terraform_data.account_guard]
}
resource "aws_route53_record" "acm_validation" {
for_each = {
for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = var.hosted_zone_id
}
resource "aws_acm_certificate_validation" "api" {
certificate_arn = aws_acm_certificate.api.arn
validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn]
}

29
terraform/bootstrap/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,29 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=",
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=",
"h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}

228
terraform/bootstrap/iam.tf Normal file
View file

@ -0,0 +1,228 @@
# Account-global HCP Terraform OIDC provider. An account may hold exactly one
# provider per URL. Default is data-source because the frontend stack owns it.
resource "aws_iam_openid_connect_provider" "terraform_cloud" {
count = var.create_tfc_oidc_provider ? 1 : 0
url = "https://app.terraform.io"
client_id_list = ["aws.workload.identity"]
thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "hcp_plan_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.tfc_oidc_arn]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = ["${local.hcp_sub_prefix}:plan"]
}
}
}
data "aws_iam_policy_document" "hcp_apply_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.tfc_oidc_arn]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = ["${local.hcp_sub_prefix}:apply"]
}
}
}
data "aws_iam_policy_document" "hcp_refresh" {
statement {
sid = "RefreshManagedIam"
effect = "Allow"
actions = [
"iam:GetInstanceProfile",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListRolePolicies",
"iam:ListRoleTags",
]
resources = [
local.github_deploy_role_arn,
local.eb_ec2_role_arn,
local.eb_service_role_arn,
"arn:aws:iam::${local.account_id}:instance-profile/tf-managed/${var.eb_ec2_role_name}",
]
}
statement {
sid = "ListOidcProviders"
effect = "Allow"
actions = ["iam:ListOpenIDConnectProviders"]
resources = ["*"]
}
statement {
sid = "ReadGithubOidcProvider"
effect = "Allow"
actions = ["iam:GetOpenIDConnectProvider"]
resources = [local.github_oidc_arn]
}
statement {
sid = "RefreshRds"
effect = "Allow"
actions = [
"rds:DescribeDBInstances",
"rds:DescribeDBParameterGroups",
"rds:DescribeDBSubnetGroups",
"rds:ListTagsForResource",
]
# RDS describe APIs do not support resource-level permissions.
resources = ["*"]
}
statement {
sid = "RefreshSecrets"
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:ListSecretVersionIds",
]
resources = [
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:shoc-backend-tf-poc/jwt-JXLaUx",
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:shoc-backend-tf-poc/webhook-hmac-eThZhu",
"arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:rds!db-6e0e2e34-dea1-47b0-8e92-b90bde9cfe20-Mxeu3J",
]
}
statement {
sid = "ListCertificates"
effect = "Allow"
actions = ["acm:ListCertificates"]
resources = ["*"]
}
statement {
sid = "RefreshElasticBeanstalk"
effect = "Allow"
actions = [
"elasticbeanstalk:DescribeApplications",
"elasticbeanstalk:DescribeConfigurationOptions",
"elasticbeanstalk:DescribeConfigurationSettings",
"elasticbeanstalk:DescribeEnvironmentResources",
"elasticbeanstalk:DescribeEnvironments",
"elasticbeanstalk:ListTagsForResource",
]
# Elastic Beanstalk describe APIs do not support resource-level permissions.
resources = ["*"]
}
statement {
sid = "RefreshPocCertificate"
effect = "Allow"
actions = [
"acm:DescribeCertificate",
"acm:GetCertificate",
"acm:ListTagsForCertificate",
]
resources = ["arn:aws:acm:${var.aws_region}:${local.account_id}:certificate/4fcc2dff-bb11-4204-9107-86d6ce2b95a2"]
}
statement {
sid = "RefreshRoute53"
effect = "Allow"
actions = [
"route53:GetChange",
"route53:GetHostedZone",
"route53:ListResourceRecordSets",
"route53:ListTagsForResource",
]
resources = [
local.hosted_zone_arn,
"arn:aws:route53:::change/*",
]
}
statement {
sid = "RefreshNetwork"
effect = "Allow"
actions = [
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVpcs",
]
# EC2 describe APIs do not support resource-level permissions.
resources = ["*"]
}
}
resource "aws_iam_role" "hcp_plan" {
name = "hcptf-shoc-backend-tf-poc-plan"
description = "HCP Terraform PLAN role for shoc-backend-tf-poc"
assume_role_policy = data.aws_iam_policy_document.hcp_plan_assume.json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
depends_on = [terraform_data.account_guard]
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "hcp_plan_refresh" {
name = "shoc-backend-tf-poc-plan-refresh"
role = aws_iam_role.hcp_plan.id
policy = data.aws_iam_policy_document.hcp_refresh.json
}
resource "aws_iam_role" "hcp_apply" {
name = "hcptf-shoc-backend-tf-poc"
description = "Read-only HCP Terraform APPLY role for the stabilized shoc-backend-tf-poc"
assume_role_policy = data.aws_iam_policy_document.hcp_apply_assume.json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
depends_on = [terraform_data.account_guard]
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "hcp_apply_iam" {
name = "shoc-backend-tf-poc-iam"
role = aws_iam_role.hcp_apply.id
policy = data.aws_iam_policy_document.hcp_refresh.json
}

View file

@ -0,0 +1,215 @@
locals {
live_certificate_arn = "arn:aws:acm:${var.aws_region}:${local.account_id}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
live_github_oidc_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
live_rds_arn = "arn:aws:rds:${var.aws_region}:${local.account_id}:db:shoc-sqlserver-shared"
live_environments = {
dev = {
workspace = "shoc-backend-dev"
deploy_role_name = "githubdeploy-shoc-backend-dev"
runtime_role_name = "shoc-backend-dev"
instance_profile_name = "shoc-backend-dev"
hosted_zone_id = "Z07671212N75U4YLPWZR8"
}
staging = {
workspace = "shoc-backend-staging"
deploy_role_name = "githubdeploy-shoc-backend-staging"
runtime_role_name = "shoc-backend-staging"
instance_profile_name = "shoc-backend-staging"
hosted_zone_id = "Z02602739VQWBWCAGXP4"
}
}
}
data "aws_iam_policy_document" "live_plan_assume" {
for_each = local.live_environments
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.tfc_oidc_arn]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = ["organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${each.value.workspace}:run_phase:plan"]
}
}
}
data "aws_iam_policy_document" "live_apply_assume" {
for_each = local.live_environments
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.tfc_oidc_arn]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:aud"
values = ["aws.workload.identity"]
}
condition {
test = "StringEquals"
variable = "app.terraform.io:sub"
values = ["organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${each.value.workspace}:run_phase:apply"]
}
}
}
data "aws_iam_policy_document" "live_plan" {
for_each = local.live_environments
statement {
sid = "CallerIdentity"
effect = "Allow"
actions = ["sts:GetCallerIdentity"]
resources = ["*"]
}
statement {
sid = "ReadExactIamResources"
effect = "Allow"
actions = [
"iam:GetInstanceProfile",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfileTags",
"iam:ListInstanceProfilesForRole",
"iam:ListRolePolicies",
"iam:ListRoleTags",
]
resources = [
"arn:aws:iam::${local.account_id}:role/${each.value.deploy_role_name}",
"arn:aws:iam::${local.account_id}:role/${each.value.runtime_role_name}",
"arn:aws:iam::${local.account_id}:instance-profile/${each.value.instance_profile_name}",
local.eb_service_role_arn,
]
}
statement {
sid = "ReadGithubOidc"
effect = "Allow"
actions = ["iam:GetOpenIDConnectProvider"]
resources = [local.live_github_oidc_arn]
}
statement {
sid = "ReadSharedInventory"
effect = "Allow"
actions = [
"acm:ListCertificates",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcs",
"iam:ListOpenIDConnectProviders",
"rds:DescribeDBInstances",
"route53:ListHostedZonesByName",
]
# These AWS read APIs do not support resource-level permissions.
resources = ["*"]
}
statement {
sid = "ReadPinnedCertificate"
effect = "Allow"
actions = ["acm:DescribeCertificate", "acm:ListTagsForCertificate"]
resources = [local.live_certificate_arn]
}
statement {
sid = "ReadSharedRdsTags"
effect = "Allow"
actions = ["rds:ListTagsForResource"]
resources = [local.live_rds_arn]
}
statement {
sid = "ReadPinnedHostedZone"
effect = "Allow"
actions = ["route53:GetHostedZone", "route53:ListResourceRecordSets", "route53:ListTagsForResource"]
resources = ["arn:aws:route53:::hostedzone/${each.value.hosted_zone_id}"]
}
}
data "aws_iam_policy_document" "live_apply" {
for_each = local.live_environments
source_policy_documents = [data.aws_iam_policy_document.live_plan[each.key].json]
statement {
sid = "UpdateImportedDeployRole"
effect = "Allow"
actions = [
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateRole",
]
resources = ["arn:aws:iam::${local.account_id}:role/${each.value.deploy_role_name}"]
}
}
resource "aws_iam_role" "live_plan" {
for_each = local.live_environments
name = "hcptf-shoc-backend-${each.key}-plan"
description = "Import/read-only HCP Terraform plan role for shoc-backend ${each.key}."
assume_role_policy = data.aws_iam_policy_document.live_plan_assume[each.key].json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
depends_on = [terraform_data.account_guard]
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "live_plan" {
for_each = local.live_environments
name = "shoc-backend-${each.key}-import-plan"
role = aws_iam_role.live_plan[each.key].id
policy = data.aws_iam_policy_document.live_plan[each.key].json
}
resource "aws_iam_role" "live_apply" {
for_each = local.live_environments
name = "hcptf-shoc-backend-${each.key}"
description = "Import/update-only HCP Terraform apply role for shoc-backend ${each.key}."
assume_role_policy = data.aws_iam_policy_document.live_apply_assume[each.key].json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
depends_on = [terraform_data.account_guard]
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy" "live_apply" {
for_each = local.live_environments
name = "shoc-backend-${each.key}-import-apply"
role = aws_iam_role.live_apply[each.key].id
policy = data.aws_iam_policy_document.live_apply[each.key].json
}

View file

@ -0,0 +1,43 @@
data "aws_caller_identity" "current" {}
data "aws_iam_openid_connect_provider" "terraform_cloud" {
count = var.create_tfc_oidc_provider ? 0 : 1
url = "https://app.terraform.io"
}
locals {
account_id = data.aws_caller_identity.current.account_id
hcp_sub_prefix = "organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${var.hcp_workload_workspace}:run_phase"
tfc_oidc_arn = var.create_tfc_oidc_provider ? aws_iam_openid_connect_provider.terraform_cloud[0].arn : data.aws_iam_openid_connect_provider.terraform_cloud[0].arn
github_deploy_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${var.github_deploy_role_name}"
eb_ec2_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${var.eb_ec2_role_name}"
github_oidc_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
hosted_zone_arn = "arn:aws:route53:::hostedzone/${var.hosted_zone_id}"
eb_service_role_arn = "arn:aws:iam::${local.account_id}:role/${var.eb_service_role_name}"
application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:application/${var.eb_application_name}"
environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}"
rds_arn = "arn:aws:rds:${var.aws_region}:${local.account_id}:db:${var.rds_identifier}"
live_protected_role_arns = [
"arn:aws:iam::${local.account_id}:role/githubdeploy-shoc-backend-dev",
"arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*",
"arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole",
"arn:aws:iam::${local.account_id}:role/shoc-backend-dev",
]
live_eb_environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/shoc-backend/shoc-backend-dev"
live_cfn_stack_arn = "arn:aws:cloudformation:${var.aws_region}:${local.account_id}:stack/awseb-e-hehnrqjjrt-stack/*"
}
resource "terraform_data" "account_guard" {
lifecycle {
precondition {
condition = local.account_id == var.aws_account_id
error_message = "Refuse to apply outside seahaven-external-dev (${var.aws_account_id}). Caller is ${local.account_id}."
}
}
}

View file

@ -0,0 +1,29 @@
output "tfc_oidc_provider_arn" {
description = "app.terraform.io OIDC provider ARN"
value = local.tfc_oidc_arn
}
output "hcp_plan_role_arn" {
description = "Set TFC_AWS_PLAN_ROLE_ARN on workspace shoc-backend-tf-poc"
value = aws_iam_role.hcp_plan.arn
}
output "hcp_apply_role_arn" {
description = "Set TFC_AWS_APPLY_ROLE_ARN on workspace shoc-backend-tf-poc"
value = aws_iam_role.hcp_apply.arn
}
output "create_tfc_oidc_provider" {
description = "Whether this bootstrap created the OIDC provider (false means it was data-sourced)"
value = var.create_tfc_oidc_provider
}
output "live_workspace_roles" {
description = "HCP Terraform dynamic credential roles for dev and staging."
value = {
for environment in keys(local.live_environments) : environment => {
plan_role_arn = aws_iam_role.live_plan[environment].arn
apply_role_arn = aws_iam_role.live_apply[environment].arn
}
}
}

View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "shoc-backend"
ManagedBy = "terraform"
Workspace = "shoc-backend-bootstrap"
}
}
}

View file

@ -0,0 +1,15 @@
# Copy to terraform.tfvars for local apply. Defaults already match
# seahaven-external-dev; this file documents the locked names.
aws_region = "us-east-1"
aws_account_id = "396287094661"
hcp_organization = "seahaven"
hcp_project = "seahaven-external-dev"
hcp_workload_workspace = "shoc-backend-tf-poc"
create_tfc_oidc_provider = false
github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc"
eb_ec2_role_name = "shoc-backend-tf-poc-ec2"
eb_application_name = "shoc-backend-tf-poc"
eb_environment_name = "shoc-backend-tf-poc"
rds_identifier = "shoc-backend-tf-poc"
hosted_zone_id = "Z07671212N75U4YLPWZR8"
eb_service_role_name = "shoc-eb-service-role"

View file

@ -0,0 +1,88 @@
variable "aws_region" {
type = string
description = "AWS region for IAM (global) and any regional data sources"
default = "us-east-1"
}
variable "aws_account_id" {
type = string
description = "seahaven-external-dev. Apply refuses any other account."
default = "396287094661"
validation {
condition = var.aws_account_id == "396287094661"
error_message = "This bootstrap is only for seahaven-external-dev (396287094661)."
}
}
variable "hcp_organization" {
type = string
description = "HCP Terraform organization name in OIDC trust subs"
default = "seahaven"
}
variable "hcp_project" {
type = string
description = "HCP Terraform project name in OIDC trust subs"
default = "seahaven-external-dev"
}
variable "hcp_workload_workspace" {
type = string
description = "Workload workspace the plan/apply roles trust (not this bootstrap workspace)"
default = "shoc-backend-tf-poc"
}
variable "create_tfc_oidc_provider" {
type = bool
description = "Create the account-global app.terraform.io OIDC provider. Default false: import the provider the frontend POC already created. Set true only if that data source fails."
default = false
}
variable "github_deploy_role_name" {
type = string
description = "GitHub OIDC deploy role name (path /tf-managed/ is fixed in IAM ARNs)"
default = "githubdeploy-shoc-backend-tf-poc"
}
variable "eb_ec2_role_name" {
type = string
description = "Elastic Beanstalk instance role name (path /tf-managed/)"
default = "shoc-backend-tf-poc-ec2"
}
variable "eb_application_name" {
type = string
description = "POC Elastic Beanstalk application the apply role may manage"
default = "shoc-backend-tf-poc"
}
variable "eb_environment_name" {
type = string
description = "POC Elastic Beanstalk environment the apply role may manage"
default = "shoc-backend-tf-poc"
}
variable "rds_identifier" {
type = string
description = "POC RDS instance identifier the apply role may manage"
default = "shoc-backend-tf-poc"
}
variable "hosted_zone_id" {
type = string
description = "dev.seahaven.com zone; apply may change records here (tf-poc + ACM validation only in workload TF)"
default = "Z07671212N75U4YLPWZR8"
}
variable "execution_boundary_arn" {
type = string
description = "SCP-required permissions boundary for CreateRole in this account"
default = "arn:aws:iam::396287094661:policy/external-dev-execution-boundary"
}
variable "eb_service_role_name" {
type = string
description = "Existing Elastic Beanstalk service role to PassRole (not created by this stack)"
default = "shoc-eb-service-role"
}

View file

@ -0,0 +1,18 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "shoc-backend-bootstrap"
}
}
}

View file

@ -0,0 +1,234 @@
resource "aws_elastic_beanstalk_application" "api" {
name = var.eb_application_name
description = "SHOC API ${var.environment} (Terraform). Parallel to live shoc-backend during the POC."
tags = {
Name = var.eb_application_name
}
depends_on = [terraform_data.account_guard]
}
resource "aws_elastic_beanstalk_environment" "api" {
name = var.eb_environment_name
application = aws_elastic_beanstalk_application.api.name
solution_stack_name = data.aws_elastic_beanstalk_solution_stack.dotnet.name
tier = "WebServer"
cname_prefix = var.eb_environment_name
wait_for_ready_timeout = "40m"
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "EnvironmentType"
value = "LoadBalanced"
}
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "LoadBalancerType"
value = "application"
}
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "ServiceRole"
value = data.aws_iam_role.eb_service.arn
}
setting {
namespace = "aws:ec2:vpc"
name = "VPCId"
value = var.vpc_id
}
setting {
namespace = "aws:ec2:vpc"
name = "Subnets"
value = join(",", var.private_subnet_ids)
}
setting {
namespace = "aws:ec2:vpc"
name = "ELBSubnets"
value = join(",", var.public_subnet_ids)
}
setting {
namespace = "aws:ec2:vpc"
name = "ELBScheme"
value = "public"
}
setting {
namespace = "aws:ec2:vpc"
name = "AssociatePublicIpAddress"
value = var.associate_public_ip ? "true" : "false"
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "IamInstanceProfile"
value = aws_iam_instance_profile.eb_ec2.name
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "InstanceType"
value = var.eb_instance_type
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "SecurityGroups"
value = aws_security_group.eb.id
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "DisableIMDSv1"
value = "true"
}
setting {
namespace = "aws:autoscaling:asg"
name = "MinSize"
value = "1"
}
setting {
namespace = "aws:autoscaling:asg"
name = "MaxSize"
value = "1"
}
setting {
namespace = "aws:elbv2:loadbalancer"
name = "SecurityGroups"
value = aws_security_group.alb.id
}
setting {
namespace = "aws:elbv2:loadbalancer"
name = "ManagedSecurityGroup"
value = aws_security_group.alb.id
}
setting {
namespace = "aws:elbv2:listener:443"
name = "Protocol"
value = "HTTPS"
}
setting {
namespace = "aws:elbv2:listener:443"
name = "SSLCertificateArns"
value = aws_acm_certificate_validation.api.certificate_arn
}
setting {
namespace = "aws:elbv2:listener:443"
name = "SSLPolicy"
value = "ELBSecurityPolicy-TLS13-1-2-2021-06"
}
setting {
namespace = "aws:elbv2:listener:80"
name = "Protocol"
value = "HTTP"
}
setting {
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "HealthCheckPath"
value = "/swagger/v1/swagger.json"
}
setting {
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "Port"
value = "80"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "ASPNETCORE_ENVIRONMENT"
value = "Production"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "ConnectionStrings__DefaultConnection"
value = local.connection_string
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "JWT__Secret"
value = aws_secretsmanager_secret_version.jwt.secret_string
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "SendGrid__ApiKey"
value = var.sendgrid_api_key
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Enabled"
value = "true"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Region"
value = var.aws_region
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__SecretId"
value = aws_secretsmanager_secret.webhook.arn
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "Sync__Enabled"
value = "false"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderReconciliation__Enabled"
value = "false"
}
setting {
namespace = "aws:elasticbeanstalk:command"
name = "DeploymentPolicy"
value = "AllAtOnce"
}
tags = {
Name = var.eb_environment_name
}
depends_on = [
aws_iam_role_policy_attachment.eb_web_tier,
aws_iam_instance_profile.eb_ec2,
aws_db_instance.poc,
]
}
resource "aws_route53_record" "api" {
zone_id = var.hosted_zone_id
name = var.domain_name
type = "A"
alias {
name = aws_elastic_beanstalk_environment.api.cname
zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id
evaluate_target_health = true
}
}

View file

@ -0,0 +1,145 @@
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:environment:${var.github_environment}"]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = var.github_deploy_role_name
path = local.content_role_path
description = "GitHub Actions deploy role for ${var.github_repo} environment ${var.github_environment}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
lifecycle {
ignore_changes = [permissions_boundary]
}
depends_on = [terraform_data.account_guard]
}
locals {
poc_environment_id = aws_elastic_beanstalk_environment.api.id
poc_environment_stack = "awseb-${aws_elastic_beanstalk_environment.api.id}-stack"
poc_application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:application/${var.eb_application_name}"
poc_environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}"
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "DescribeDiscovery"
effect = "Allow"
actions = [
"autoscaling:Describe*",
"ec2:Describe*",
"elasticbeanstalk:DescribeEnvironments",
"elasticbeanstalk:DescribeApplicationVersions",
"elasticbeanstalk:DescribeEvents",
"elasticloadbalancing:Describe*",
]
resources = ["*"]
}
statement {
sid = "CreateApplicationVersion"
effect = "Allow"
actions = [
"elasticbeanstalk:CreateApplicationVersion",
]
resources = [
local.poc_application_arn,
"arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:applicationversion/${var.eb_application_name}/*",
]
}
statement {
sid = "UpdatePocEnvironment"
effect = "Allow"
actions = ["elasticbeanstalk:UpdateEnvironment"]
resources = [local.poc_environment_arn]
}
statement {
sid = "PocManagedCfn"
effect = "Allow"
actions = [
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:GetTemplate",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:ListStackResources",
"cloudformation:CancelUpdateStack",
"cloudformation:UpdateStack",
]
resources = [
"arn:aws:cloudformation:${var.aws_region}:${local.account_id}:stack/${local.poc_environment_stack}/*",
]
}
statement {
sid = "PocAsgProcess"
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [
"arn:aws:autoscaling:${var.aws_region}:${local.account_id}:autoScalingGroup:*:autoScalingGroupName/${local.poc_environment_stack}-*",
]
}
statement {
sid = "EbServiceObjects"
effect = "Allow"
actions = ["s3:PutObject"]
resources = [
"arn:aws:s3:::elasticbeanstalk-${var.aws_region}-${local.account_id}/${var.eb_application_name}/*",
]
}
statement {
sid = "EbServiceBuckets"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = ["arn:aws:s3:::elasticbeanstalk-${var.aws_region}-${local.account_id}"]
}
statement {
sid = "DenyLiveEnvironment"
effect = "Deny"
actions = ["elasticbeanstalk:*"]
resources = [
"arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/shoc-backend/shoc-backend-dev",
]
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = "${var.github_deploy_role_name}-eb"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}

69
terraform/iam_runtime.tf Normal file
View file

@ -0,0 +1,69 @@
data "aws_iam_policy_document" "eb_ec2_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "eb_ec2" {
name = var.eb_ec2_role_name
path = local.content_role_path
description = "Elastic Beanstalk instance role for ${var.eb_environment_name}"
assume_role_policy = data.aws_iam_policy_document.eb_ec2_assume.json
max_session_duration = 3600
permissions_boundary = var.execution_boundary_arn
lifecycle {
ignore_changes = [permissions_boundary]
}
depends_on = [terraform_data.account_guard]
}
resource "aws_iam_role_policy_attachment" "eb_web_tier" {
role = aws_iam_role.eb_ec2.name
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
}
resource "aws_iam_role_policy_attachment" "eb_worker_tier" {
role = aws_iam_role.eb_ec2.name
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier"
}
resource "aws_iam_role_policy_attachment" "eb_ssm" {
role = aws_iam_role.eb_ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
data "aws_iam_policy_document" "eb_ec2_secrets" {
statement {
sid = "PocSecrets"
effect = "Allow"
actions = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
]
resources = [
aws_secretsmanager_secret.jwt.arn,
aws_secretsmanager_secret.webhook.arn,
aws_db_instance.poc.master_user_secret[0].secret_arn,
]
}
}
resource "aws_iam_role_policy" "eb_ec2_secrets" {
name = "shoc-backend-tf-poc-secrets"
role = aws_iam_role.eb_ec2.id
policy = data.aws_iam_policy_document.eb_ec2_secrets.json
}
resource "aws_iam_instance_profile" "eb_ec2" {
name = var.eb_ec2_role_name
path = local.content_role_path
role = aws_iam_role.eb_ec2.name
}

73
terraform/live/README.md Normal file
View file

@ -0,0 +1,73 @@
# Live deploy-role adoption
These roots replace CDK ownership of the existing GitHub Actions deploy roles.
They do not create or manage Elastic Beanstalk, RDS, ACM, Route 53, VPC,
subnets, security groups, runtime roles, instance profiles, or secrets.
## Ownership
- `dev/` imports `githubdeploy-shoc-backend-dev` and its existing inline policy.
- `staging/` imports `githubdeploy-shoc-backend-staging` and its existing inline
policy.
- `modules/environment-inventory/` reads and pins shared and environment
resources without owning them.
- `../bootstrap/` owns the four narrowly scoped live HCP Terraform plan/apply
roles alongside the temporary POC role pair.
The shared `shoc-backend` Elastic Beanstalk application and
`shoc-sqlserver-shared` RDS instance must never enter either environment state.
## Two-phase adoption
Each live root pins `adoption_complete=false` in reviewed code. It is not an
HCP workspace variable.
1. Create the HCP workspace and configure dynamic credentials.
2. Run the declarative imports.
3. Export the HCP plan as JSON and run:
```bash
python scripts/check-terraform-import-plan.py plan.json
```
The first plan must be a no-op after import. The guard rejects updates,
creates, deletes, replacements, and managed resource types outside the
deploy role and inline policy.
4. Apply the no-op import only after review.
5. Change the environment root to `adoption_complete=true` in a reviewed code
change, then review the controlled in-place role and policy update:
```bash
python scripts/check-terraform-import-plan.py plan.json --allow-update
```
6. Apply only when the plan contains updates to the imported deploy role and
policy, with no create, delete, or replacement actions.
The reviewed `adoption_complete=true` change updates the ownership
tag/description and narrows the dev role to the staging-style S3 bucket and
application prefix. Read-only AWS APIs retain `Resource = "*"` only where AWS
does not support resource-level permissions.
## Pinned live identities
- Dev: workspace `shoc-backend-dev`; EB environment `shoc-backend-dev`
(`e-hehnrqjjrt`); .NET 8 AL2023 `3.11.3`; `api.dev.seahaven.com`.
- Staging: workspace `shoc-backend-staging`; EB environment
`shoc-backend-staging` (`e-6c9m4vb62z`); .NET 8 AL2023 `3.11.3`;
`api.staging.seahaven.com`.
The environment roots are intentionally not general-purpose modules. Exact
identifiers make accidental cross-environment reuse fail review and planning.
## Safety invariants
- Never reuse `shoc-backend-tf-poc` state.
- Auto-apply remains off.
- HCP apply roles have no IAM create/delete permissions and no service
mutation permissions outside the exact imported deploy role.
- Both managed resources have `prevent_destroy`.
- Do not retire the CDK stack until the no-op import and controlled policy
update have both succeeded.
- Do not destroy the POC workload until dev and staging deployment smoke tests
have stabilized.

26
terraform/live/dev/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,26 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}

View file

@ -0,0 +1,9 @@
import {
to = module.deploy_role.aws_iam_role.github_deploy
id = "githubdeploy-shoc-backend-dev"
}
import {
to = module.deploy_role.aws_iam_role_policy.github_deploy
id = "githubdeploy-shoc-backend-dev:GithubDeployRoleDefaultPolicyE8F540D1"
}

View file

@ -0,0 +1,42 @@
locals {
aws_account_id = "396287094661"
aws_region = "us-east-1"
eb_application_name = "shoc-backend"
eb_environment_name = "shoc-backend-dev"
eb_environment_id = "e-hehnrqjjrt"
eb_platform = "64bit Amazon Linux 2023 v3.11.3 running .NET 8"
api_domain = "api.dev.seahaven.com"
}
module "inventory" {
source = "../modules/environment-inventory"
aws_account_id = local.aws_account_id
vpc_id = "vpc-0d16336143f3da25e"
subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
rds_identifier = "shoc-sqlserver-shared"
eb_service_role_name = "shoc-eb-service-role"
runtime_role_name = "shoc-backend-dev"
instance_profile_name = "shoc-backend-dev"
security_group_ids = ["sg-0c8bb7cf2c193de57", "sg-050e5a737e98ba699"]
certificate_domain = "*.seahaven.com"
expected_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
hosted_zone_name = "dev.seahaven.com"
expected_hosted_zone_id = "Z07671212N75U4YLPWZR8"
}
module "deploy_role" {
source = "../modules/deploy-role"
aws_account_id = local.aws_account_id
aws_region = local.aws_region
github_repo = "Sea-Haven-Industries/shoc-backend"
environment = "dev"
eb_application_name = local.eb_application_name
eb_environment_name = local.eb_environment_name
eb_environment_id = local.eb_environment_id
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
adoption_complete = false
legacy_dev_s3_policy = true
}

View file

@ -0,0 +1,20 @@
output "github_deploy_role_arn" {
description = "Existing dev GitHub deploy role ARN."
value = module.deploy_role.role_arn
}
output "shared_rds_arn" {
description = "Data-sourced shared RDS ARN."
value = module.inventory.shared_rds_arn
}
output "pinned_eb_environment" {
description = "Pinned existing dev Elastic Beanstalk environment identity."
value = {
application = local.eb_application_name
environment = local.eb_environment_name
id = local.eb_environment_id
platform = local.eb_platform
api_domain = local.api_domain
}
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = "us-east-1"
}

View file

@ -0,0 +1,19 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
cloud {
organization = "seahaven"
workspaces {
project = "seahaven-external-dev"
name = "shoc-backend-dev"
}
}
}

View file

@ -0,0 +1,173 @@
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
locals {
application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:application/${var.eb_application_name}"
environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}"
environment_stack_name = "awseb-${var.eb_environment_id}-stack"
environment_stack_arn = "arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*"
environment_asg_arn = "arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*"
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy
}
data "aws_iam_policy_document" "assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:environment:${var.environment}"]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = var.github_deploy_role_name
path = "/"
description = var.adoption_complete ? (
"Least-privilege GitHub OIDC deploy role for shoc-backend ${var.environment}. Terraform-owned; application/environment/S3 are owned by Elastic Beanstalk."
) : (
"Least-privilege GitHub OIDC deploy role for shoc-backend ${var.environment}. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk."
)
assume_role_policy = data.aws_iam_policy_document.assume.json
max_session_duration = 3600
tags = {
Component = "deploy-role"
Environment = var.environment
ManagedBy = var.adoption_complete ? "terraform" : "cdk"
Project = "shoc-backend"
}
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "deploy" {
statement {
effect = "Allow"
actions = [
"autoscaling:Describe*",
"ec2:Describe*",
"elasticbeanstalk:DescribeApplicationVersions",
"elasticbeanstalk:DescribeEnvironments",
"elasticbeanstalk:DescribeEvents",
"elasticloadbalancing:Describe*",
]
# These AWS read APIs do not support resource-level permissions.
resources = ["*"]
}
statement {
effect = "Allow"
actions = ["elasticbeanstalk:CreateApplicationVersion"]
resources = [
local.application_arn,
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:applicationversion/${var.eb_application_name}/*",
]
}
statement {
effect = "Allow"
actions = ["elasticbeanstalk:UpdateEnvironment"]
resources = [local.environment_arn]
}
statement {
effect = "Allow"
actions = [
"cloudformation:CancelUpdateStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:UpdateStack",
]
resources = [local.environment_stack_arn]
}
statement {
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [local.environment_asg_arn]
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = ["s3:Delete*", "s3:Get*", "s3:Put*"]
resources = [
"arn:aws:s3:::elasticbeanstalk-*/*",
]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = [
"s3:GetBucket*",
"s3:ListBucket",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
]
resources = ["arn:aws:s3:::elasticbeanstalk-*"]
}
}
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}/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
}
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = var.policy_name
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.deploy.json
lifecycle {
prevent_destroy = true
}
}

View file

@ -0,0 +1,14 @@
output "role_arn" {
description = "Existing GitHub deploy role ARN."
value = aws_iam_role.github_deploy.arn
}
output "environment_arn" {
description = "Exact Elastic Beanstalk environment ARN the deploy role may update."
value = local.environment_arn
}
output "environment_stack_arn" {
description = "Exact Elastic Beanstalk CloudFormation stack ARN pattern."
value = local.environment_stack_arn
}

View file

@ -0,0 +1,62 @@
variable "aws_account_id" {
type = string
description = "AWS account containing the existing deploy role."
}
variable "aws_region" {
type = string
description = "AWS region containing the Elastic Beanstalk environment."
}
variable "github_repo" {
type = string
description = "GitHub owner/repository allowed by the OIDC trust."
}
variable "environment" {
type = string
description = "GitHub Environment and deployment environment."
validation {
condition = contains(["dev", "staging"], var.environment)
error_message = "environment must be dev or staging."
}
}
variable "eb_application_name" {
type = string
description = "Existing Elastic Beanstalk application name."
}
variable "eb_environment_name" {
type = string
description = "Existing Elastic Beanstalk environment name."
}
variable "eb_environment_id" {
type = string
description = "Existing Elastic Beanstalk environment ID used in generated resource names."
}
variable "github_deploy_role_name" {
type = string
description = "Existing root-path GitHub OIDC deploy role name."
}
variable "policy_name" {
type = string
description = "Existing inline policy name created by CDK."
default = "GithubDeployRoleDefaultPolicyE8F540D1"
}
variable "adoption_complete" {
type = bool
description = "False preserves the current role exactly for a no-op import. True records Terraform ownership and applies the targeted S3 policy."
default = false
}
variable "legacy_dev_s3_policy" {
type = bool
description = "Whether the current role has the legacy account-wide Elastic Beanstalk S3 permissions. Used only during the no-op import phase."
default = false
}

View file

@ -0,0 +1,59 @@
data "aws_caller_identity" "current" {}
data "aws_vpc" "selected" {
id = var.vpc_id
}
data "aws_subnet" "selected" {
for_each = var.subnet_ids
id = each.value
}
data "aws_db_instance" "shared" {
db_instance_identifier = var.rds_identifier
}
data "aws_iam_role" "eb_service" {
name = var.eb_service_role_name
}
data "aws_iam_role" "runtime" {
name = var.runtime_role_name
}
data "aws_iam_instance_profile" "runtime" {
name = var.instance_profile_name
}
data "aws_security_group" "environment" {
for_each = var.security_group_ids
id = each.value
}
data "aws_acm_certificate" "shared" {
domain = var.certificate_domain
statuses = ["ISSUED"]
most_recent = true
}
data "aws_route53_zone" "api" {
name = var.hosted_zone_name
private_zone = false
}
check "identity" {
assert {
condition = data.aws_caller_identity.current.account_id == var.aws_account_id
error_message = "Refusing to inspect resources outside the expected AWS account."
}
assert {
condition = data.aws_acm_certificate.shared.arn == var.expected_certificate_arn
error_message = "The resolved ACM certificate does not match the pinned live certificate."
}
assert {
condition = data.aws_route53_zone.api.zone_id == var.expected_hosted_zone_id
error_message = "The resolved Route 53 zone does not match the pinned live zone."
}
}

View file

@ -0,0 +1,24 @@
output "shared_rds_arn" {
description = "Existing shared RDS ARN. The live environment states never manage it."
value = data.aws_db_instance.shared.db_instance_arn
}
output "runtime_role_arn" {
description = "Existing environment-specific runtime role ARN."
value = data.aws_iam_role.runtime.arn
}
output "instance_profile_arn" {
description = "Existing environment-specific instance-profile ARN."
value = data.aws_iam_instance_profile.runtime.arn
}
output "certificate_arn" {
description = "Pinned existing shared ACM certificate ARN."
value = data.aws_acm_certificate.shared.arn
}
output "hosted_zone_id" {
description = "Pinned existing Route 53 hosted-zone ID."
value = data.aws_route53_zone.api.zone_id
}

View file

@ -0,0 +1,59 @@
variable "aws_account_id" {
type = string
description = "Expected AWS account ID."
}
variable "vpc_id" {
type = string
description = "Existing VPC ID."
}
variable "subnet_ids" {
type = set(string)
description = "Existing Elastic Beanstalk subnet IDs."
}
variable "rds_identifier" {
type = string
description = "Existing shared RDS instance identifier."
}
variable "eb_service_role_name" {
type = string
description = "Existing shared Elastic Beanstalk service role."
}
variable "runtime_role_name" {
type = string
description = "Existing environment-specific EC2 role."
}
variable "instance_profile_name" {
type = string
description = "Existing environment-specific EC2 instance profile."
}
variable "security_group_ids" {
type = set(string)
description = "Existing environment-specific Elastic Beanstalk and load-balancer security groups."
}
variable "certificate_domain" {
type = string
description = "Primary domain on the existing shared ACM certificate."
}
variable "hosted_zone_name" {
type = string
description = "Existing Route 53 hosted-zone name."
}
variable "expected_certificate_arn" {
type = string
description = "Exact existing ACM certificate ARN."
}
variable "expected_hosted_zone_id" {
type = string
description = "Exact existing Route 53 hosted-zone ID."
}

View file

@ -0,0 +1,26 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}

View file

@ -0,0 +1,9 @@
import {
to = module.deploy_role.aws_iam_role.github_deploy
id = "githubdeploy-shoc-backend-staging"
}
import {
to = module.deploy_role.aws_iam_role_policy.github_deploy
id = "githubdeploy-shoc-backend-staging:GithubDeployRoleDefaultPolicyE8F540D1"
}

View file

@ -0,0 +1,42 @@
locals {
aws_account_id = "396287094661"
aws_region = "us-east-1"
eb_application_name = "shoc-backend"
eb_environment_name = "shoc-backend-staging"
eb_environment_id = "e-6c9m4vb62z"
eb_platform = "64bit Amazon Linux 2023 v3.11.3 running .NET 8"
api_domain = "api.staging.seahaven.com"
}
module "inventory" {
source = "../modules/environment-inventory"
aws_account_id = local.aws_account_id
vpc_id = "vpc-0d16336143f3da25e"
subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
rds_identifier = "shoc-sqlserver-shared"
eb_service_role_name = "shoc-eb-service-role"
runtime_role_name = "shoc-backend-staging"
instance_profile_name = "shoc-backend-staging"
security_group_ids = ["sg-02ea36a6719217fa2", "sg-0517062b0deef982d"]
certificate_domain = "*.seahaven.com"
expected_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
hosted_zone_name = "staging.seahaven.com"
expected_hosted_zone_id = "Z02602739VQWBWCAGXP4"
}
module "deploy_role" {
source = "../modules/deploy-role"
aws_account_id = local.aws_account_id
aws_region = local.aws_region
github_repo = "Sea-Haven-Industries/shoc-backend"
environment = "staging"
eb_application_name = local.eb_application_name
eb_environment_name = local.eb_environment_name
eb_environment_id = local.eb_environment_id
github_deploy_role_name = "githubdeploy-shoc-backend-staging"
adoption_complete = false
legacy_dev_s3_policy = false
}

View file

@ -0,0 +1,20 @@
output "github_deploy_role_arn" {
description = "Existing staging GitHub deploy role ARN."
value = module.deploy_role.role_arn
}
output "shared_rds_arn" {
description = "Data-sourced shared RDS ARN."
value = module.inventory.shared_rds_arn
}
output "pinned_eb_environment" {
description = "Pinned existing staging Elastic Beanstalk environment identity."
value = {
application = local.eb_application_name
environment = local.eb_environment_name
id = local.eb_environment_id
platform = local.eb_platform
api_domain = local.api_domain
}
}

View file

@ -0,0 +1,3 @@
provider "aws" {
region = "us-east-1"
}

View file

@ -0,0 +1,19 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
cloud {
organization = "seahaven"
workspaces {
project = "seahaven-external-dev"
name = "shoc-backend-staging"
}
}
}

48
terraform/locals.tf Normal file
View file

@ -0,0 +1,48 @@
data "aws_caller_identity" "current" {}
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
data "aws_iam_role" "eb_service" {
name = var.eb_service_role_name
}
data "aws_elastic_beanstalk_solution_stack" "dotnet" {
most_recent = true
name_regex = "^64bit Amazon Linux 2023 .* running .NET 8$"
}
data "aws_elastic_beanstalk_hosted_zone" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
content_role_path = "/tf-managed/"
webhook_secret_name = "shoc-backend-tf-poc/webhook-hmac"
jwt_secret_name = "shoc-backend-tf-poc/jwt"
}
resource "terraform_data" "account_guard" {
lifecycle {
precondition {
condition = local.account_id == var.aws_account_id
error_message = "Refuse to apply outside seahaven-external-dev (${var.aws_account_id}). Caller is ${local.account_id}."
}
precondition {
condition = can(regex("^vpc-", var.vpc_id))
error_message = "Set vpc_id from live Elastic Beanstalk discovery before apply."
}
precondition {
condition = length(var.private_subnet_ids) >= 2
error_message = "Set at least two private_subnet_ids before apply."
}
precondition {
condition = length(var.public_subnet_ids) >= 2
error_message = "Set at least two public_subnet_ids before apply."
}
}
}

49
terraform/outputs.tf Normal file
View file

@ -0,0 +1,49 @@
output "api_url" {
description = "Public URL of the POC API"
value = "https://${var.domain_name}"
}
output "eb_application_name" {
description = "Elastic Beanstalk application name"
value = aws_elastic_beanstalk_application.api.name
}
output "eb_environment_name" {
description = "Elastic Beanstalk environment name"
value = aws_elastic_beanstalk_environment.api.name
}
output "eb_environment_id" {
description = "Elastic Beanstalk environment id (e-xxxxxxxx)"
value = aws_elastic_beanstalk_environment.api.id
}
output "eb_cname" {
description = "Elastic Beanstalk environment CNAME"
value = aws_elastic_beanstalk_environment.api.cname
}
output "rds_endpoint" {
description = "RDS SQL Server address"
value = aws_db_instance.poc.address
}
output "database_name" {
description = "Catalog to CREATE DATABASE on the RDS instance before the first app deploy"
value = var.database_name
}
output "webhook_secret_arn" {
description = "Secrets Manager ARN for the POC webhook HMAC keyset"
value = aws_secretsmanager_secret.webhook.arn
}
output "github_deploy_role_arn" {
description = "Set GitHub Environment tf-poc secret AWS_DEPLOY_ROLE_ARN to this value"
value = aws_iam_role.github_deploy.arn
}
output "acm_certificate_arn" {
description = "ACM certificate ARN for the POC hostname"
value = aws_acm_certificate.api.arn
}

12
terraform/providers.tf Normal file
View file

@ -0,0 +1,12 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "shoc-backend"
ManagedBy = "terraform"
Workspace = "shoc-backend-tf-poc"
Environment = var.environment
}
}
}

66
terraform/rds.tf Normal file
View file

@ -0,0 +1,66 @@
resource "aws_db_subnet_group" "poc" {
name = "shoc-backend-tf-poc"
subnet_ids = var.private_subnet_ids
tags = {
Name = "shoc-backend-tf-poc"
}
depends_on = [terraform_data.account_guard]
}
resource "aws_db_instance" "poc" {
identifier = var.rds_identifier
engine = "sqlserver-ex"
instance_class = var.rds_instance_class
license_model = "license-included"
allocated_storage = var.rds_allocated_storage
max_allocated_storage = var.rds_allocated_storage
storage_type = "gp3"
storage_encrypted = true
username = var.rds_master_username
manage_master_user_password = true
db_subnet_group_name = aws_db_subnet_group.poc.name
vpc_security_group_ids = [aws_security_group.rds.id]
publicly_accessible = false
multi_az = false
port = 1433
backup_retention_period = 1
deletion_protection = false
skip_final_snapshot = true
apply_immediately = true
copy_tags_to_snapshot = true
tags = {
Name = var.rds_identifier
}
timeouts {
create = "90m"
update = "90m"
delete = "90m"
}
}
data "aws_secretsmanager_secret_version" "rds_master" {
secret_id = aws_db_instance.poc.master_user_secret[0].secret_arn
depends_on = [aws_db_instance.poc]
}
locals {
rds_master = jsondecode(data.aws_secretsmanager_secret_version.rds_master.secret_string)
connection_string = join(";", [
"Server=${aws_db_instance.poc.address},${aws_db_instance.poc.port}",
"Initial Catalog=${var.database_name}",
"User Id=${local.rds_master["username"]}",
"Password=${local.rds_master["password"]}",
"Encrypt=True",
"TrustServerCertificate=True",
"MultipleActiveResultSets=true",
])
}

49
terraform/secrets.tf Normal file
View file

@ -0,0 +1,49 @@
resource "random_password" "jwt" {
length = 64
special = false
}
resource "random_id" "webhook_secret" {
byte_length = 32
}
resource "aws_secretsmanager_secret" "jwt" {
name = local.jwt_secret_name
description = "JWT signing secret for shoc-backend-tf-poc. Rotate the secret version out of band; Terraform ignores later value changes."
recovery_window_in_days = 0
depends_on = [terraform_data.account_guard]
}
resource "aws_secretsmanager_secret_version" "jwt" {
secret_id = aws_secretsmanager_secret.jwt.id
secret_string = random_password.jwt.result
lifecycle {
ignore_changes = [secret_string]
}
}
resource "aws_secretsmanager_secret" "webhook" {
name = local.webhook_secret_name
description = "Work-order webhook HMAC keyset for shoc-backend-tf-poc. Do not use the prod ingest ARN."
recovery_window_in_days = 0
depends_on = [terraform_data.account_guard]
}
resource "aws_secretsmanager_secret_version" "webhook" {
secret_id = aws_secretsmanager_secret.webhook.id
secret_string = jsonencode({
keys = [
{
kid = "tf-poc"
secret = random_id.webhook_secret.hex
}
]
})
lifecycle {
ignore_changes = [secret_string]
}
}

View file

@ -0,0 +1,93 @@
resource "aws_security_group" "eb" {
name = "shoc-backend-tf-poc-eb"
description = "Elastic Beanstalk instances for shoc-backend-tf-poc"
vpc_id = var.vpc_id
tags = {
Name = "shoc-backend-tf-poc-eb"
Project = "shoc-backend"
}
depends_on = [terraform_data.account_guard]
}
resource "aws_vpc_security_group_egress_rule" "eb_all" {
security_group_id = aws_security_group.eb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
description = "Instances need outbound for Secrets Manager, Windows Update-style platform, and HTTPS."
}
resource "aws_security_group" "alb" {
name = "shoc-backend-tf-poc-alb"
description = "Application load balancer for shoc-backend-tf-poc"
vpc_id = var.vpc_id
tags = {
Name = "shoc-backend-tf-poc-alb"
Project = "shoc-backend"
}
depends_on = [terraform_data.account_guard]
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "HTTP (redirected to HTTPS by the load balancer)"
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "HTTPS"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_vpc_security_group_ingress_rule" "eb_from_alb" {
security_group_id = aws_security_group.eb.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "ALB to instance HTTP"
}
resource "aws_security_group" "rds" {
name = "shoc-backend-tf-poc-rds"
description = "SQL Server for shoc-backend-tf-poc"
vpc_id = var.vpc_id
tags = {
Name = "shoc-backend-tf-poc-rds"
Project = "shoc-backend"
}
depends_on = [terraform_data.account_guard]
}
resource "aws_vpc_security_group_ingress_rule" "rds_from_eb" {
security_group_id = aws_security_group.rds.id
referenced_security_group_id = aws_security_group.eb.id
from_port = 1433
to_port = 1433
ip_protocol = "tcp"
description = "EB instances to SQL Server"
}
resource "aws_vpc_security_group_egress_rule" "rds_all" {
security_group_id = aws_security_group.rds.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}

View file

@ -0,0 +1,25 @@
aws_region = "us-east-1"
aws_account_id = "396287094661"
environment = "tf-poc"
domain_name = "tf-poc.api.dev.seahaven.com"
hosted_zone_id = "Z07671212N75U4YLPWZR8"
hosted_zone_name = "dev.seahaven.com"
github_repo = "Sea-Haven-Industries/shoc-backend"
github_environment = "tf-poc"
github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc"
eb_application_name = "shoc-backend-tf-poc"
eb_environment_name = "shoc-backend-tf-poc"
eb_ec2_role_name = "shoc-backend-tf-poc-ec2"
eb_service_role_name = "shoc-eb-service-role"
eb_instance_type = "t3.small"
rds_identifier = "shoc-backend-tf-poc"
rds_instance_class = "db.t3.small"
rds_allocated_storage = 20
rds_master_username = "shoc_admin"
database_name = "shoc_tf_poc"
# Copied from live shoc-backend-dev. Same public subnets for instances and ALB.
vpc_id = "vpc-0d16336143f3da25e"
private_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
public_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
associate_public_ip = true

195
terraform/variables.tf Normal file
View file

@ -0,0 +1,195 @@
variable "aws_region" {
type = string
description = "AWS region for the API, RDS, ACM, and Elastic Beanstalk."
default = "us-east-1"
}
variable "aws_account_id" {
type = string
description = "seahaven-external-dev. Apply refuses any other account."
default = "396287094661"
validation {
condition = var.aws_account_id == "396287094661"
error_message = "This stack is only for seahaven-external-dev (396287094661)."
}
}
variable "environment" {
type = string
description = "Logical environment. POC apply is tf-poc only. dev/staging/prod are accepted so promotion does not rewrite the module."
default = "tf-poc"
validation {
condition = contains(["tf-poc", "dev", "staging", "prod"], var.environment)
error_message = "environment must be tf-poc, dev, staging, or prod."
}
}
variable "domain_name" {
type = string
description = "Public API hostname."
default = "tf-poc.api.dev.seahaven.com"
validation {
condition = var.environment != "tf-poc" || var.domain_name == "tf-poc.api.dev.seahaven.com"
error_message = "tf-poc hostname must be tf-poc.api.dev.seahaven.com."
}
validation {
condition = var.environment != "dev" || var.domain_name == "api.dev.seahaven.com"
error_message = "dev hostname must be api.dev.seahaven.com."
}
validation {
condition = var.domain_name != "api.tf-poc.dev.seahaven.com"
error_message = "Refuse api.tf-poc.dev.seahaven.com; use tf-poc.api.dev.seahaven.com."
}
}
variable "hosted_zone_id" {
type = string
description = "Route 53 zone for the hostname + ACM DNS validation."
default = "Z07671212N75U4YLPWZR8"
}
variable "hosted_zone_name" {
type = string
description = "Zone name; used only for documentation and FQDN checks."
default = "dev.seahaven.com"
}
variable "github_repo" {
type = string
description = "GitHub owner/name for the deploy OIDC trust"
default = "Sea-Haven-Industries/shoc-backend"
}
variable "github_environment" {
type = string
description = "GitHub Actions environment name used in the OIDC sub"
default = "tf-poc"
}
variable "github_deploy_role_name" {
type = string
description = "IAM role name at path /tf-managed/"
default = "githubdeploy-shoc-backend-tf-poc"
}
variable "eb_application_name" {
type = string
description = "Elastic Beanstalk application name. POC uses a separate app from live shoc-backend."
default = "shoc-backend-tf-poc"
validation {
condition = var.environment != "tf-poc" || var.eb_application_name == "shoc-backend-tf-poc"
error_message = "tf-poc Elastic Beanstalk application must be shoc-backend-tf-poc."
}
validation {
condition = var.environment != "dev" || var.eb_application_name == "shoc-backend"
error_message = "dev Elastic Beanstalk application must be shoc-backend."
}
}
variable "eb_environment_name" {
type = string
description = "Elastic Beanstalk environment name."
default = "shoc-backend-tf-poc"
validation {
condition = var.environment != "tf-poc" || var.eb_environment_name == "shoc-backend-tf-poc"
error_message = "tf-poc Elastic Beanstalk environment must be shoc-backend-tf-poc."
}
validation {
condition = var.environment != "dev" || var.eb_environment_name == "shoc-backend-dev"
error_message = "dev Elastic Beanstalk environment must be shoc-backend-dev."
}
}
variable "eb_ec2_role_name" {
type = string
description = "Instance role / instance-profile name at path /tf-managed/"
default = "shoc-backend-tf-poc-ec2"
}
variable "eb_service_role_name" {
type = string
description = "Existing Elastic Beanstalk service role (data-sourced, not created)"
default = "shoc-eb-service-role"
}
variable "eb_instance_type" {
type = string
description = "EC2 instance type for the POC environment"
default = "t3.small"
}
variable "vpc_id" {
type = string
description = "Existing VPC that hosts live Elastic Beanstalk. Required at apply; discover before first apply."
default = ""
}
variable "private_subnet_ids" {
type = list(string)
description = "Private subnets for RDS and EB instances (at least two AZs)."
default = []
}
variable "public_subnet_ids" {
type = list(string)
description = "Public subnets for the EB application load balancer (at least two AZs)."
default = []
}
variable "associate_public_ip" {
type = bool
description = "Associate a public IP on EB instances. Live shoc-backend-dev uses true on public subnets."
default = true
}
variable "rds_identifier" {
type = string
description = "RDS instance identifier"
default = "shoc-backend-tf-poc"
}
variable "rds_instance_class" {
type = string
description = "RDS SQL Server Express instance class"
default = "db.t3.small"
}
variable "rds_allocated_storage" {
type = number
description = "RDS allocated storage in GiB. SQL Server minimum is 20. Express database size remains 10 GiB."
default = 20
}
variable "rds_master_username" {
type = string
description = "RDS master username. Cannot be sa/admin/root."
default = "shoc_admin"
}
variable "database_name" {
type = string
description = "SQL Server catalog the API uses. Create this database once after RDS is available (RDS Express does not accept db_name)."
default = "shoc_tf_poc"
}
variable "sendgrid_api_key" {
type = string
description = "SendGrid API key. Set as a sensitive HCP workspace variable before smoke that sends mail. Empty is allowed for first apply."
default = ""
sensitive = true
}
variable "execution_boundary_arn" {
type = string
description = "SCP-required permissions boundary on CreateRole"
default = "arn:aws:iam::396287094661:policy/external-dev-execution-boundary"
}

22
terraform/versions.tf Normal file
View file

@ -0,0 +1,22 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "shoc-backend-tf-poc"
}
}
}