mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
feat(terraform): add safe backend environment adoption
Introduce import-guarded environment roots and retire temporary bootstrap and POC provisioning after ownership transfer.
This commit is contained in:
parent
b605d5be02
commit
f8bf102f35
37 changed files with 2262 additions and 32 deletions
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Backend CI
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, staging]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -24,6 +24,11 @@ jobs:
|
|||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore SeaHavenIndustries.sln
|
||||
|
||||
|
|
@ -32,3 +37,39 @@ jobs:
|
|||
|
||||
- name: Test
|
||||
run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release
|
||||
|
||||
- name: Terraform fmt and validate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
directories=()
|
||||
case "${{ github.base_ref }}" in
|
||||
dev)
|
||||
directories+=(terraform/live/tf-poc terraform/live/dev)
|
||||
;;
|
||||
staging)
|
||||
directories+=(terraform/live/staging)
|
||||
;;
|
||||
esac
|
||||
|
||||
for dir in "${directories[@]}"; do
|
||||
terraform -chdir="$dir" fmt -check -recursive
|
||||
terraform -chdir="$dir" init -backend=false
|
||||
terraform -chdir="$dir" validate
|
||||
done
|
||||
|
||||
- name: Terraform import plan guard tests
|
||||
run: python scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: Set up Node.js
|
||||
if: github.base_ref == 'dev'
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Validate CDK deployment infrastructure
|
||||
if: github.base_ref == 'dev'
|
||||
working-directory: infra/cdk
|
||||
run: |
|
||||
npm ci
|
||||
npm run synth
|
||||
|
|
|
|||
73
.github/workflows/deploy.yml
vendored
73
.github/workflows/deploy.yml
vendored
|
|
@ -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:
|
||||
|
|
@ -66,22 +66,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 +140,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 +151,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 +174,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 +196,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 +212,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 +241,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 +262,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 +273,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
11
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
| 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` |
|
||||
| G11 | Terraform/CDK static validation | import configuration integrity | commands below | `ci` on the matching PR base |
|
||||
|
||||
## How to run locally
|
||||
|
||||
|
|
@ -45,6 +47,23 @@ 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,
|
||||
unmanaged resource types, and updates not allowlisted by exact address (G10).
|
||||
|
||||
G10 permits only exact approved resource address/type pairs for the
|
||||
environment-owned boundary: Elastic
|
||||
Beanstalk environment, IAM role/inline policy/managed-policy attachment/
|
||||
instance profile, Secrets Manager secret metadata, Route 53 zone/record, and
|
||||
ACM certificate. Initial mode permits no update. Controlled mode requires one
|
||||
`--allow-update-address` argument per reviewed in-place update. Every invocation
|
||||
also requires `--environment dev`, `--environment staging`, or
|
||||
`--environment tf-poc`; an empty or incomplete environment plan fails.
|
||||
|
||||
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
|
||||
and `terraform validate`. PRs to `dev` validate `live/tf-poc` and `live/dev`,
|
||||
plus `npm ci && npm run synth` in `infra/cdk`. PRs to `staging` validate only
|
||||
`live/staging`. Org-baseline CloudFormation owns the HCP role substrate, so no
|
||||
backend bootstrap root remains in the matrix.
|
||||
|
||||
## Migration gates (G6)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# shoc-backend CDK (dev deployment IAM)
|
||||
# shoc-backend CDK
|
||||
|
||||
This CDK v2 app owns exactly one thing in the `shoc-backend` AWS account
|
||||
## Dev deploy-role stack
|
||||
|
||||
The existing `shoc-backend-deploy-dev` stack owns exactly one thing in the `shoc-backend` AWS account
|
||||
(`396287094661`, `us-east-1`): the **GitHub OIDC deploy role** used by the
|
||||
`dev` deployment workflow in `.github/workflows/deploy.yml`.
|
||||
|
||||
|
|
|
|||
113
scripts/check-terraform-import-plan.py
Normal file
113
scripts/check-terraform-import-plan.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/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
|
||||
|
||||
from terraform_import_plan_resources import REQUIRED_RESOURCES
|
||||
|
||||
|
||||
ALLOWED_MANAGED_TYPES = {
|
||||
resource_type
|
||||
for resources in REQUIRED_RESOURCES.values()
|
||||
for resource_type in resources.values()
|
||||
}
|
||||
UNSAFE_ACTIONS = {"create", "delete"}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Allow an in-place update to this exact address after the initial "
|
||||
"no-op import is proven. Repeat for each reviewed update."
|
||||
),
|
||||
)
|
||||
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
|
||||
allowed_update_addresses = set(args.allow_update_address)
|
||||
seen_update_addresses: set[str] = set()
|
||||
seen_addresses: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[args.environment]
|
||||
|
||||
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
|
||||
seen_addresses.add(address)
|
||||
|
||||
if resource_type not in ALLOWED_MANAGED_TYPES:
|
||||
violations.append(
|
||||
f"{address}: managed type {resource_type!r} is outside the live ownership boundary"
|
||||
)
|
||||
|
||||
expected_type = required_resources.get(address)
|
||||
if expected_type is None:
|
||||
violations.append(
|
||||
f"{address}: managed address is outside the live ownership boundary"
|
||||
)
|
||||
elif resource_type != expected_type:
|
||||
violations.append(
|
||||
f"{address}: expected managed type {expected_type!r}, got {resource_type!r}"
|
||||
)
|
||||
|
||||
unsafe = sorted(actions & UNSAFE_ACTIONS)
|
||||
if unsafe:
|
||||
violations.append(f"{address}: unsafe actions {unsafe}")
|
||||
|
||||
if "update" in actions:
|
||||
updates += 1
|
||||
seen_update_addresses.add(address)
|
||||
if address not in allowed_update_addresses:
|
||||
violations.append(
|
||||
f"{address}: update is not explicitly allowlisted"
|
||||
)
|
||||
|
||||
for unused in sorted(allowed_update_addresses - seen_update_addresses):
|
||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||
|
||||
for missing in sorted(set(required_resources) - seen_addresses):
|
||||
violations.append(f"{missing}: required managed resource is absent")
|
||||
|
||||
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 allowed_update_addresses 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())
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -66,7 +66,13 @@ RUNTIME="${RUNTIME:-linux-x64}"
|
|||
|
||||
[[ -f "$API_PROJECT" ]] || die "missing API project: $API_PROJECT"
|
||||
[[ -f "$MIGRATIONS_PROJECT" ]] || die "missing migrations project: $MIGRATIONS_PROJECT"
|
||||
command -v zip >/dev/null 2>&1 || die "zip is required to build the source bundle."
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
ARCHIVER="zip"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
ARCHIVER="python"
|
||||
else
|
||||
die "zip or python is required to build the source bundle."
|
||||
fi
|
||||
|
||||
GENERATED_ROOT="$(dirname "$STAGING_DIR")"
|
||||
case "$GENERATED_ROOT" in
|
||||
|
|
@ -84,8 +90,8 @@ log "publish $API_PROJECT (Release, self-contained, $RUNTIME)"
|
|||
--self-contained \
|
||||
--runtime "$RUNTIME" \
|
||||
-o "$STAGING_DIR" \
|
||||
/p:ContinuousIntegrationBuild=true \
|
||||
/p:UseAppHost=true
|
||||
-p:ContinuousIntegrationBuild=true \
|
||||
-p:UseAppHost=true
|
||||
|
||||
log "install dotnet-ef $EF_VERSION (local tool path)"
|
||||
if ! "$DOTNET" tool install dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR" 2>/dev/null; then
|
||||
|
|
@ -99,7 +105,7 @@ log "build EF migrations bundle (self-contained, $RUNTIME)"
|
|||
--startup-project "$API_PROJECT" \
|
||||
--configuration Release \
|
||||
--self-contained \
|
||||
--runtime "$RUNTIME" \
|
||||
--target-runtime "$RUNTIME" \
|
||||
--output "$STAGING_DIR/efbundle"
|
||||
|
||||
chmod 0755 "$STAGING_DIR/efbundle"
|
||||
|
|
@ -114,14 +120,43 @@ if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}'
|
|||
fi
|
||||
|
||||
log "assemble source bundle (contents, not the containing directory)"
|
||||
(
|
||||
cd "$STAGING_DIR"
|
||||
# ZIP stores file mtimes. Normalize them so identical source/build inputs
|
||||
# produce byte-identical source bundles.
|
||||
find . -type f -exec touch -t 198001010000 {} +
|
||||
find . -type f -print | LC_ALL=C sort \
|
||||
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"
|
||||
)
|
||||
if [[ "$ARCHIVER" == "zip" ]]; then
|
||||
(
|
||||
cd "$STAGING_DIR"
|
||||
# ZIP stores file mtimes. Normalize them so identical source/build inputs
|
||||
# produce byte-identical source bundles.
|
||||
find . -type f -exec touch -t 198001010000 {} +
|
||||
find . -type f -print | LC_ALL=C sort \
|
||||
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"
|
||||
)
|
||||
else
|
||||
python - "$STAGING_DIR" "$REPO_ROOT/$OUTPUT_ZIP" <<'PY'
|
||||
import pathlib
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
output = pathlib.Path(sys.argv[2])
|
||||
with zipfile.ZipFile(
|
||||
output,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
) as archive:
|
||||
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||
info = zipfile.ZipInfo(
|
||||
path.relative_to(root).as_posix(),
|
||||
date_time=(1980, 1, 1, 0, 0, 0),
|
||||
)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
mode = path.stat().st_mode
|
||||
if path.name == "efbundle":
|
||||
mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
info.external_attr = (mode & 0xFFFF) << 16
|
||||
archive.writestr(info, path.read_bytes(), compresslevel=9)
|
||||
PY
|
||||
fi
|
||||
|
||||
log "package written: $OUTPUT_ZIP"
|
||||
printf ' contents: %d files\n' "$(find "$STAGING_DIR" -type f | wc -l | tr -d ' ')"
|
||||
|
|
|
|||
32
scripts/terraform_import_plan_resources.py
Normal file
32
scripts/terraform_import_plan_resources.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Canonical managed-resource addresses for Terraform environment imports."""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment.aws_elastic_beanstalk_environment.this": "aws_elastic_beanstalk_environment",
|
||||
"module.environment.aws_iam_instance_profile.runtime": "aws_iam_instance_profile",
|
||||
"module.environment.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment.aws_iam_role.runtime": "aws_iam_role",
|
||||
"module.environment.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
"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",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
"dev": {
|
||||
**COMMON_RESOURCES,
|
||||
"module.environment.aws_iam_role_policy.runtime_dynamo[0]": "aws_iam_role_policy",
|
||||
"module.environment.aws_iam_role_policy.runtime_webhook[0]": "aws_iam_role_policy",
|
||||
"module.environment.aws_route53_record.api_alias[0]": "aws_route53_record",
|
||||
},
|
||||
"staging": {
|
||||
**COMMON_RESOURCES,
|
||||
"module.environment.aws_iam_role_policy.runtime_webhook[0]": "aws_iam_role_policy",
|
||||
"module.environment.aws_route53_record.api_cname[0]": "aws_route53_record",
|
||||
},
|
||||
"tf-poc": {
|
||||
**COMMON_RESOURCES,
|
||||
"aws_acm_certificate.poc": "aws_acm_certificate",
|
||||
"aws_route53_zone.poc": "aws_route53_zone",
|
||||
"module.environment.aws_route53_record.api_cname[0]": "aws_route53_record",
|
||||
},
|
||||
}
|
||||
199
scripts/test-terraform-import-plan-check.py
Normal file
199
scripts/test-terraform-import-plan-check.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for check-terraform-import-plan.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from terraform_import_plan_resources import REQUIRED_RESOURCES
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||
|
||||
|
||||
def run_case(
|
||||
environment: str,
|
||||
*,
|
||||
actions_by_address: dict[str, list[str]] | None = None,
|
||||
omit_address: str | None = None,
|
||||
extra_resource: tuple[str, str, list[str]] | None = None,
|
||||
allowed_updates: tuple[str, ...] = (),
|
||||
empty: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
changes = []
|
||||
if not empty:
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if address == omit_address:
|
||||
continue
|
||||
actions = (actions_by_address or {}).get(address, ["no-op"])
|
||||
changes.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": {"actions": actions},
|
||||
}
|
||||
)
|
||||
if extra_resource:
|
||||
address, resource_type, actions = extra_resource
|
||||
changes.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": {"actions": actions},
|
||||
}
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
plan_path = Path(directory) / "plan.json"
|
||||
plan_path.write_text(
|
||||
json.dumps({"resource_changes": changes}), encoding="utf-8"
|
||||
)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(plan_path),
|
||||
"--environment",
|
||||
environment,
|
||||
]
|
||||
for allowed_address in allowed_updates:
|
||||
command.extend(["--allow-update-address", allowed_address])
|
||||
return subprocess.run(command, check=False, capture_output=True, text=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
controlled_address = "module.environment.aws_iam_role.github_deploy"
|
||||
cases = [
|
||||
*[
|
||||
(f"{environment} no-op", run_case(environment), 0)
|
||||
for environment in REQUIRED_RESOURCES
|
||||
],
|
||||
("empty", run_case("dev", empty=True), 1),
|
||||
(
|
||||
"missing required",
|
||||
run_case("dev", omit_address=controlled_address),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"initial update",
|
||||
run_case("dev", actions_by_address={controlled_address: ["update"]}),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"controlled update",
|
||||
run_case(
|
||||
"dev",
|
||||
actions_by_address={controlled_address: ["update"]},
|
||||
allowed_updates=(controlled_address,),
|
||||
),
|
||||
0,
|
||||
),
|
||||
(
|
||||
"tf-poc controlled update",
|
||||
run_case(
|
||||
"tf-poc",
|
||||
actions_by_address={controlled_address: ["update"]},
|
||||
allowed_updates=(controlled_address,),
|
||||
),
|
||||
0,
|
||||
),
|
||||
(
|
||||
"wrong controlled address",
|
||||
run_case(
|
||||
"dev",
|
||||
actions_by_address={controlled_address: ["update"]},
|
||||
allowed_updates=("module.environment.aws_iam_role.runtime",),
|
||||
),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"create",
|
||||
run_case("dev", actions_by_address={controlled_address: ["create"]}),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"replacement",
|
||||
run_case(
|
||||
"dev",
|
||||
actions_by_address={controlled_address: ["delete", "create"]},
|
||||
),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"destroy",
|
||||
run_case("dev", actions_by_address={controlled_address: ["delete"]}),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"outside address",
|
||||
run_case(
|
||||
"dev",
|
||||
extra_resource=(
|
||||
"module.environment.aws_iam_role.other",
|
||||
"aws_iam_role",
|
||||
["no-op"],
|
||||
),
|
||||
),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"wrong type",
|
||||
run_case(
|
||||
"dev",
|
||||
extra_resource=(controlled_address, "aws_iam_role_policy", ["no-op"]),
|
||||
),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"dev resource in staging",
|
||||
run_case(
|
||||
"staging",
|
||||
extra_resource=(
|
||||
"module.environment.aws_iam_role_policy.runtime_dynamo[0]",
|
||||
"aws_iam_role_policy",
|
||||
["no-op"],
|
||||
),
|
||||
),
|
||||
1,
|
||||
),
|
||||
(
|
||||
"live webhook policy in tf-poc",
|
||||
run_case(
|
||||
"tf-poc",
|
||||
extra_resource=(
|
||||
"module.environment.aws_iam_role_policy.runtime_webhook[0]",
|
||||
"aws_iam_role_policy",
|
||||
["no-op"],
|
||||
),
|
||||
),
|
||||
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())
|
||||
48
terraform/README.md
Normal file
48
terraform/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Terraform deployment infrastructure
|
||||
|
||||
Terraform adopts the environment-owned Sea Haven backend infrastructure while
|
||||
keeping shared and Elastic Beanstalk-generated resources outside state.
|
||||
|
||||
## Roots
|
||||
|
||||
- `live/dev/` imports the existing dev environment-owned resources.
|
||||
- `live/staging/` imports the existing staging environment-owned resources.
|
||||
- `live/tf-poc/` manages the retained import-rehearsal environment after its
|
||||
completed transfer from CloudFormation.
|
||||
|
||||
Shared RDS, application, VPC, subnet, service-role, shared-certificate, and
|
||||
Elastic Beanstalk-generated inventory remains data-only or provider-managed.
|
||||
Secret metadata is managed, but secret values are never authored in Terraform
|
||||
configuration. Elastic Beanstalk receives secret values through
|
||||
`environmentsecrets` ARN/key references.
|
||||
|
||||
## HCP credentials
|
||||
|
||||
Org-baseline CloudFormation owns the HCP Terraform plan/apply roles and their
|
||||
manager tags. The retired `shoc-backend-bootstrap` workspace and backend
|
||||
bootstrap root were removed after the four dev/staging roles transferred
|
||||
without replacement.
|
||||
|
||||
## Environment adoption
|
||||
|
||||
Follow [`live/README.md`](live/README.md). For each dev/staging adoption, the
|
||||
first plan must import the environment-owned resources with zero create,
|
||||
update, delete, or replacement actions. The second reviewed phase may update
|
||||
only explicitly allowlisted ownership metadata and the narrowed dev deploy S3
|
||||
policy.
|
||||
|
||||
The GitHub Environment secret `AWS_DEPLOY_ROLE_ARN` retains the existing role
|
||||
ARN throughout adoption.
|
||||
|
||||
## Local validation
|
||||
|
||||
```bash
|
||||
terraform -chdir=terraform fmt -check -recursive
|
||||
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
|
||||
terraform -chdir=terraform/live/tf-poc init -backend=false
|
||||
terraform -chdir=terraform/live/tf-poc validate
|
||||
python scripts/test-terraform-import-plan-check.py
|
||||
```
|
||||
153
terraform/live/README.md
Normal file
153
terraform/live/README.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Backend environment adoption
|
||||
|
||||
These roots adopt environment-owned infrastructure without taking ownership of
|
||||
shared or Elastic Beanstalk-generated infrastructure.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `dev/` and `staging/` import the existing EB environment, runtime
|
||||
role/profile/policies, deploy role/policy, app-config secret metadata, and API
|
||||
record.
|
||||
- `tf-poc/` manages the retained rehearsal environment after its completed
|
||||
CloudFormation-to-Terraform transfer, excluding the live-only webhook and
|
||||
Dynamo policies. It also owns the child zone and DNS-validated ACM
|
||||
certificate.
|
||||
- `modules/environment-inventory/` reads and pins only shared resources.
|
||||
- Org-baseline CloudFormation owns the narrowly scoped HCP Terraform plan/apply
|
||||
roles.
|
||||
|
||||
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. The `shoc_tf_poc` SQL catalog
|
||||
is out of band.
|
||||
|
||||
Secret values are not Terraform resources, variables, outputs, or managed EB
|
||||
settings. Terraform manages the app-config secret shell and maps approved JSON
|
||||
keys through `aws:elasticbeanstalk:application:environmentsecrets` using
|
||||
`secret-arn:json-key` references. Ordinary application environment settings are
|
||||
limited to non-secret ASP.NET and webhook configuration. The pinned .NET 8
|
||||
AL2023 platform 3.11.3 supports Secrets Manager JSON-key extraction.
|
||||
|
||||
## Mandatory live secret migration
|
||||
|
||||
Before importing dev or staging, perform a separately approved production
|
||||
mutation from a trusted local session:
|
||||
|
||||
1. Create a temporary `OptionSettings` JSON file containing the exact
|
||||
`environmentsecrets` ARN/key references configured in that root and the five
|
||||
non-secret ordinary environment settings.
|
||||
2. Create a temporary `OptionsToRemove` JSON file naming the old raw
|
||||
secret-valued keys in `aws:elasticbeanstalk:application:environment`.
|
||||
3. Run `aws elasticbeanstalk update-environment` for exactly
|
||||
`shoc-backend-dev` or `shoc-backend-staging` with
|
||||
`--option-settings file://...` and `--options-to-remove file://...`.
|
||||
Include the provider-normalized sorted `Subnets` and `ELBSubnets` values in
|
||||
this same approved update if live ordering differs.
|
||||
4. Delete both files, wait for the replacement environment to become Ready and
|
||||
healthy, and run `scripts/smoke-elastic-beanstalk.sh` against the exact API.
|
||||
5. Verify the raw ordinary secret settings are absent before generating the
|
||||
first Terraform plan.
|
||||
|
||||
This migration is not performed by Terraform. The first import plan remains
|
||||
zero-change only after the migration succeeds.
|
||||
|
||||
## Mandatory role-boundary attachment
|
||||
|
||||
After the org baseline creates the dedicated boundary policies, perform a
|
||||
separately approved production IAM mutation that attaches:
|
||||
|
||||
- `shoc-backend-dev-runtime-boundary` to `shoc-backend-dev`
|
||||
- `shoc-backend-staging-runtime-boundary` to `shoc-backend-staging`
|
||||
- `shoc-backend-dev-deploy-boundary` to `githubdeploy-shoc-backend-dev`
|
||||
- `shoc-backend-staging-deploy-boundary` to
|
||||
`githubdeploy-shoc-backend-staging`
|
||||
|
||||
Attach all four boundaries before the SCP and HCP `PutRolePolicy` exceptions
|
||||
become effective. In the same approved pre-import phase, add the immutable
|
||||
`HcpTerraformWorkspace` tag to each GitHub deploy role:
|
||||
|
||||
- `githubdeploy-shoc-backend-dev`: `shoc-backend-dev`
|
||||
- `githubdeploy-shoc-backend-staging`: `shoc-backend-staging`
|
||||
|
||||
Verify each exact runtime and deploy boundary ARN and workspace tag from
|
||||
`GetRole` before importing. Terraform requires the boundaries to be present
|
||||
during `adoption_complete=false`, so the first import remains zero-change.
|
||||
Terraform does not perform this pre-import mutation.
|
||||
|
||||
## Two-phase adoption
|
||||
|
||||
Each dev/staging root pins `adoption_complete=false` in reviewed code until its
|
||||
initial import is proven. It is not an HCP workspace variable. The retained
|
||||
tf-poc rehearsal has completed both phases and therefore pins
|
||||
`adoption_complete=true`.
|
||||
|
||||
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 --environment dev
|
||||
```
|
||||
|
||||
The first plan must be a no-op after import. The guard rejects updates,
|
||||
creates, deletes, replacements, and managed resource types outside the
|
||||
approved environment-owned boundary.
|
||||
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
|
||||
# Dev example. Omit any address that is not updating.
|
||||
python scripts/check-terraform-import-plan.py plan.json --environment dev \
|
||||
--allow-update-address module.environment.aws_iam_instance_profile.runtime \
|
||||
--allow-update-address module.environment.aws_iam_role.runtime \
|
||||
--allow-update-address module.environment.aws_iam_role.github_deploy \
|
||||
--allow-update-address module.environment.aws_iam_role_policy.github_deploy \
|
||||
--allow-update-address module.environment.aws_secretsmanager_secret.app_config
|
||||
```
|
||||
|
||||
6. Apply only when every update address is named on the command line and the
|
||||
plan contains no create, delete, or replacement action.
|
||||
|
||||
The reviewed `adoption_complete=true` change updates ownership tags on IAM
|
||||
roles, instance profiles, and app-config secrets, and narrows the dev role to
|
||||
the staging-style S3 bucket and application prefix. Elastic Beanstalk
|
||||
environment tags remain at their imported values. EB accepts an added
|
||||
`ManagedBy` tag request but can fail the asynchronous service-managed
|
||||
CloudFormation propagation after Terraform reports success. Terraform still
|
||||
manages the declared EB settings. Deploy-role descriptions and immutable
|
||||
`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain
|
||||
`Resource = "*"` only where AWS does not support resource-level permissions.
|
||||
|
||||
## POC retained identifiers
|
||||
|
||||
The tf-poc HCP workspace stores the exact retained environment ID, app-config
|
||||
secret ARN, child-zone ID, and certificate ARN declared in
|
||||
`tf-poc/variables.tf`. The declarative import blocks consumed those identifiers
|
||||
during the completed transfer. Do not guess or replace them, and do not put
|
||||
credentials or secret values in HCP variables.
|
||||
|
||||
ACM DNS validation remains part of the Terraform-owned certificate resource;
|
||||
its generated validation record is not a separate ownership target. The public
|
||||
delegation of `tf-poc.seahaven.com` from `seahaven.com` remains outside this
|
||||
Terraform state.
|
||||
|
||||
## 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
|
||||
|
||||
- Auto-apply remains off.
|
||||
- Org baseline owns final HCP plan/apply permissions and manager tags.
|
||||
- Every imported Terraform resource has `prevent_destroy`.
|
||||
- The tf-poc CloudFormation creator path was removed after its no-op import,
|
||||
controlled update, and retained-resource ownership transfer completed.
|
||||
26
terraform/live/dev/.terraform.lock.hcl
generated
Normal file
26
terraform/live/dev/.terraform.lock.hcl
generated
Normal 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",
|
||||
]
|
||||
}
|
||||
54
terraform/live/dev/imports.tf
Normal file
54
terraform/live/dev/imports.tf
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
to = module.environment.aws_elastic_beanstalk_environment.this
|
||||
id = "e-hehnrqjjrt"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.runtime
|
||||
id = "shoc-backend-dev"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_instance_profile.runtime
|
||||
id = "shoc-backend-dev"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy_attachment.web_tier
|
||||
id = "shoc-backend-dev/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_app_config
|
||||
id = "shoc-backend-dev:shoc-dev-secrets-read"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_webhook[0]
|
||||
id = "shoc-backend-dev:shoc-procurement-webhook-hmac-read"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_dynamo[0]
|
||||
id = "shoc-backend-dev:shoc-assume-dynamo-reader"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.github_deploy
|
||||
id = "githubdeploy-shoc-backend-dev"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.github_deploy
|
||||
id = "githubdeploy-shoc-backend-dev:GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_secretsmanager_secret.app_config
|
||||
id = "arn:aws:secretsmanager:us-east-1:396287094661:secret:shoc/dev/app-config-jLRBiw"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_route53_record.api_alias[0]
|
||||
id = "Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A"
|
||||
}
|
||||
100
terraform/live/dev/main.tf
Normal file
100
terraform/live/dev/main.tf
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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 = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
|
||||
api_domain = "api.dev.seahaven.com"
|
||||
}
|
||||
|
||||
module "inventory" {
|
||||
source = "../modules/environment-inventory"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
rds_identifier = "shoc-sqlserver-shared"
|
||||
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 "environment" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
environment = "dev"
|
||||
adoption_complete = false
|
||||
eb_application_name = local.eb_application_name
|
||||
eb_environment_name = local.eb_environment_name
|
||||
eb_environment_id = local.eb_environment_id
|
||||
platform_arn = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
|
||||
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-0c8bb7cf2c193de57"
|
||||
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-dev"
|
||||
runtime_app_config_policy_name = "shoc-dev-secrets-read"
|
||||
runtime_webhook_policy_name = "shoc-procurement-webhook-hmac-read"
|
||||
runtime_dynamo_policy_name = "shoc-assume-dynamo-reader"
|
||||
permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-dev-runtime-boundary"
|
||||
github_deploy_permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-dev-deploy-boundary"
|
||||
app_config_secret_name = "shoc/dev/app-config"
|
||||
app_config_json_keys = [
|
||||
"ConnectionStrings__DefaultConnection",
|
||||
"Dynamo__ExternalId",
|
||||
"Dynamo__Region",
|
||||
"Dynamo__SourceRoleArn",
|
||||
"JWT__Secret",
|
||||
"JWT__ValidAudience",
|
||||
"JWT__ValidIssuer",
|
||||
"SendGrid__ApiKey",
|
||||
]
|
||||
app_config_policy_sid = "ReadDevAppConfig"
|
||||
webhook_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB"
|
||||
webhook_read_policy_sid = "ReadProcurementWebhookHmacKeyset"
|
||||
webhook_decrypt_policy_sid = "DecryptWebhookSecretViaSecretsManager"
|
||||
dynamo_reader_role_arn = "arn:aws:iam::328440206208:role/shoc-dynamo-reader"
|
||||
dynamo_policy_sid = "AssumeDynamoReaderInMain"
|
||||
github_repo = "Sea-Haven-Industries/shoc-backend"
|
||||
github_environment = "dev"
|
||||
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = true
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "A"
|
||||
metadata_before_adoption = {
|
||||
runtime_role_description = "SHOC backend dev compute role (EB instance profile)"
|
||||
runtime_role_tags = {
|
||||
env = "dev"
|
||||
project = "shoc"
|
||||
}
|
||||
instance_profile_tags = {
|
||||
env = "dev"
|
||||
project = "shoc"
|
||||
}
|
||||
app_config_description = "SHOC dev application config (conn string, JWT, SendGrid)"
|
||||
app_config_tags = {
|
||||
env = "dev"
|
||||
project = "shoc"
|
||||
}
|
||||
deploy_role_description = "Least-privilege GitHub OIDC deploy role for shoc-backend dev. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk."
|
||||
deploy_role_tags = {
|
||||
Component = "deploy-role"
|
||||
Environment = "dev"
|
||||
HcpTerraformWorkspace = "shoc-backend-dev"
|
||||
ManagedBy = "cdk"
|
||||
Project = "shoc-backend"
|
||||
}
|
||||
environment_tags = {
|
||||
Name = "shoc-backend-dev"
|
||||
env = "dev"
|
||||
project = "shoc"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
terraform/live/dev/outputs.tf
Normal file
20
terraform/live/dev/outputs.tf
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
output "github_deploy_role_arn" {
|
||||
description = "Existing dev GitHub deploy role ARN."
|
||||
value = module.environment.github_deploy_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
|
||||
}
|
||||
}
|
||||
3
terraform/live/dev/providers.tf
Normal file
3
terraform/live/dev/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
19
terraform/live/dev/versions.tf
Normal file
19
terraform/live/dev/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
terraform/live/modules/environment-inventory/main.tf
Normal file
33
terraform/live/modules/environment-inventory/main.tf
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_db_instance" "shared" {
|
||||
db_instance_identifier = var.rds_identifier
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
14
terraform/live/modules/environment-inventory/outputs.tf
Normal file
14
terraform/live/modules/environment-inventory/outputs.tf
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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 "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
|
||||
}
|
||||
29
terraform/live/modules/environment-inventory/variables.tf
Normal file
29
terraform/live/modules/environment-inventory/variables.tf
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
variable "aws_account_id" {
|
||||
type = string
|
||||
description = "Expected AWS account ID."
|
||||
}
|
||||
|
||||
variable "rds_identifier" {
|
||||
type = string
|
||||
description = "Existing shared RDS instance identifier."
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
524
terraform/live/modules/environment-owned/main.tf
Normal file
524
terraform/live/modules/environment-owned/main.tf
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
data "aws_iam_openid_connect_provider" "github" {
|
||||
url = "https://token.actions.githubusercontent.com"
|
||||
}
|
||||
|
||||
data "aws_elastic_beanstalk_hosted_zone" "current" {}
|
||||
|
||||
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"
|
||||
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
|
||||
use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy
|
||||
app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_assume" {
|
||||
statement {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["ec2.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "runtime" {
|
||||
name = var.runtime_role_name
|
||||
path = "/"
|
||||
description = var.metadata_before_adoption.runtime_role_description
|
||||
assume_role_policy = data.aws_iam_policy_document.runtime_assume.json
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.permissions_boundary_arn
|
||||
tags = var.adoption_complete ? merge(var.metadata_before_adoption.runtime_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.runtime_role_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "web_tier" {
|
||||
role = aws_iam_role.runtime.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_app_config" {
|
||||
statement {
|
||||
sid = var.app_config_policy_sid
|
||||
effect = "Allow"
|
||||
actions = ["secretsmanager:GetSecretValue"]
|
||||
resources = [local.app_config_secret_pattern]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "runtime_app_config" {
|
||||
name = var.runtime_app_config_policy_name
|
||||
role = aws_iam_role.runtime.id
|
||||
policy = data.aws_iam_policy_document.runtime_app_config.json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_webhook" {
|
||||
count = var.work_order_webhook_enabled ? 1 : 0
|
||||
|
||||
statement {
|
||||
sid = var.webhook_read_policy_sid
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"secretsmanager:DescribeSecret",
|
||||
"secretsmanager:GetSecretValue",
|
||||
]
|
||||
resources = [var.webhook_secret_arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = var.webhook_decrypt_policy_sid
|
||||
effect = "Allow"
|
||||
actions = ["kms:Decrypt"]
|
||||
resources = ["arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18"]
|
||||
|
||||
condition {
|
||||
test = "StringEquals"
|
||||
variable = "kms:ViaService"
|
||||
values = ["secretsmanager.us-east-1.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "runtime_webhook" {
|
||||
count = var.work_order_webhook_enabled ? 1 : 0
|
||||
|
||||
name = var.runtime_webhook_policy_name
|
||||
role = aws_iam_role.runtime.id
|
||||
policy = data.aws_iam_policy_document.runtime_webhook[0].json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_dynamo" {
|
||||
count = var.dynamo_reader_role_arn == null ? 0 : 1
|
||||
|
||||
statement {
|
||||
sid = var.dynamo_policy_sid
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
resources = [var.dynamo_reader_role_arn]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "runtime_dynamo" {
|
||||
count = var.dynamo_reader_role_arn == null ? 0 : 1
|
||||
|
||||
name = var.runtime_dynamo_policy_name
|
||||
role = aws_iam_role.runtime.id
|
||||
policy = data.aws_iam_policy_document.runtime_dynamo[0].json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_instance_profile" "runtime" {
|
||||
name = var.runtime_role_name
|
||||
path = "/"
|
||||
role = aws_iam_role.runtime.name
|
||||
tags = var.adoption_complete ? merge(var.metadata_before_adoption.instance_profile_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.instance_profile_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret" "app_config" {
|
||||
name = var.app_config_secret_name
|
||||
description = var.metadata_before_adoption.app_config_description
|
||||
tags = var.adoption_complete ? merge(var.metadata_before_adoption.app_config_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.app_config_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
ignore_changes = [
|
||||
force_overwrite_replica_secret,
|
||||
recovery_window_in_days,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "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 = "/"
|
||||
description = var.metadata_before_adoption.deploy_role_description
|
||||
assume_role_policy = data.aws_iam_policy_document.deploy_assume.json
|
||||
max_session_duration = 3600
|
||||
permissions_boundary = var.github_deploy_permissions_boundary_arn
|
||||
tags = var.adoption_complete ? merge(var.metadata_before_adoption.deploy_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.deploy_role_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "deploy" {
|
||||
statement {
|
||||
sid = var.environment == "tf-poc" ? "DescribeDeploymentResources" : null
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"autoscaling:Describe*",
|
||||
"ec2:Describe*",
|
||||
"elasticbeanstalk:DescribeApplicationVersions",
|
||||
"elasticbeanstalk:DescribeEnvironments",
|
||||
"elasticbeanstalk:DescribeEvents",
|
||||
"elasticloadbalancing:Describe*",
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = var.environment == "tf-poc" ? "CreateApplicationVersion" : null
|
||||
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 {
|
||||
sid = var.environment == "tf-poc" ? "UpdatePocEnvironment" : null
|
||||
effect = "Allow"
|
||||
actions = ["elasticbeanstalk:UpdateEnvironment"]
|
||||
resources = [local.environment_arn]
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.environment != "tf-poc" ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"cloudformation:CancelUpdateStack",
|
||||
"cloudformation:DescribeStackEvents",
|
||||
"cloudformation:DescribeStackResource",
|
||||
"cloudformation:DescribeStackResources",
|
||||
"cloudformation:DescribeStacks",
|
||||
"cloudformation:GetTemplate",
|
||||
"cloudformation:ListStackResources",
|
||||
"cloudformation:UpdateStack",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.environment != "tf-poc" ? [1] : []
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"autoscaling:PutNotificationConfiguration",
|
||||
"autoscaling:ResumeProcesses",
|
||||
"autoscaling:SuspendProcesses",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
sid = var.environment == "tf-poc" ? "UploadApplicationVersion" : null
|
||||
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 {
|
||||
sid = var.environment == "tf-poc" ? "UseBeanstalkBucket" : null
|
||||
effect = "Allow"
|
||||
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
|
||||
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = var.environment == "tf-poc" ? [1] : []
|
||||
content {
|
||||
sid = "DenyLiveEnvironments"
|
||||
effect = "Deny"
|
||||
actions = ["elasticbeanstalk:*"]
|
||||
resources = [
|
||||
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-dev",
|
||||
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-staging",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "github_deploy" {
|
||||
name = var.github_deploy_policy_name
|
||||
role = aws_iam_role.github_deploy.id
|
||||
policy = data.aws_iam_policy_document.deploy.json
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_elastic_beanstalk_environment" "this" {
|
||||
name = var.eb_environment_name
|
||||
application = var.eb_application_name
|
||||
platform_arn = var.platform_arn
|
||||
tier = "WebServer"
|
||||
cname_prefix = var.eb_environment_name
|
||||
|
||||
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 = var.eb_service_role_name
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:ec2:vpc"
|
||||
name = "VPCId"
|
||||
value = var.vpc_id
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:ec2:vpc"
|
||||
name = "Subnets"
|
||||
value = join(",", sort(var.instance_subnet_ids))
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:ec2:vpc"
|
||||
name = "ELBSubnets"
|
||||
value = join(",", sort(var.load_balancer_subnet_ids))
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:ec2:vpc"
|
||||
name = "ELBScheme"
|
||||
value = "public"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:ec2:vpc"
|
||||
name = "AssociatePublicIpAddress"
|
||||
value = "true"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:autoscaling:launchconfiguration"
|
||||
name = "IamInstanceProfile"
|
||||
value = aws_iam_instance_profile.runtime.name
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:autoscaling:launchconfiguration"
|
||||
name = "InstanceType"
|
||||
value = "t3.small"
|
||||
}
|
||||
|
||||
dynamic "setting" {
|
||||
for_each = var.instance_security_group_id == null ? [] : [var.instance_security_group_id]
|
||||
content {
|
||||
namespace = "aws:autoscaling:launchconfiguration"
|
||||
name = "SecurityGroups"
|
||||
value = setting.value
|
||||
}
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:autoscaling:asg"
|
||||
name = "MinSize"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:autoscaling:asg"
|
||||
name = "MaxSize"
|
||||
value = "1"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elbv2:listener:443"
|
||||
name = "Protocol"
|
||||
value = "HTTPS"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elbv2:listener:443"
|
||||
name = "SSLCertificateArns"
|
||||
value = var.shared_certificate_arn
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:environment:process:default"
|
||||
name = "HealthCheckPath"
|
||||
value = "/"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:environment:process:default"
|
||||
name = "MatcherHTTPCode"
|
||||
value = "200-499"
|
||||
}
|
||||
|
||||
dynamic "setting" {
|
||||
for_each = var.app_config_json_keys
|
||||
content {
|
||||
namespace = "aws:elasticbeanstalk:application:environmentsecrets"
|
||||
name = setting.value
|
||||
value = "${aws_secretsmanager_secret.app_config.arn}:${setting.value}"
|
||||
}
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:application:environment"
|
||||
name = "ASPNETCORE_ENVIRONMENT"
|
||||
value = "Production"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:application:environment"
|
||||
name = "ASPNETCORE_URLS"
|
||||
value = "http://0.0.0.0:5000"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:application:environment"
|
||||
name = "WorkOrderWebhook__Enabled"
|
||||
value = var.work_order_webhook_enabled ? "true" : "false"
|
||||
}
|
||||
|
||||
setting {
|
||||
namespace = "aws:elasticbeanstalk:application:environment"
|
||||
name = "WorkOrderWebhook__Region"
|
||||
value = var.aws_region
|
||||
}
|
||||
|
||||
dynamic "setting" {
|
||||
for_each = var.webhook_secret_arn == null ? [] : [var.webhook_secret_arn]
|
||||
content {
|
||||
namespace = "aws:elasticbeanstalk:application:environment"
|
||||
name = "WorkOrderWebhook__SecretId"
|
||||
value = setting.value
|
||||
}
|
||||
}
|
||||
|
||||
tags = var.metadata_before_adoption.environment_tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
ignore_changes = [
|
||||
wait_for_ready_timeout,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "api_alias" {
|
||||
count = var.api_record_type == "A" ? 1 : 0
|
||||
|
||||
zone_id = var.hosted_zone_id
|
||||
name = var.api_domain
|
||||
type = "A"
|
||||
|
||||
alias {
|
||||
name = aws_elastic_beanstalk_environment.this.cname
|
||||
zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id
|
||||
evaluate_target_health = true
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "api_cname" {
|
||||
count = var.api_record_type == "CNAME" ? 1 : 0
|
||||
|
||||
zone_id = var.hosted_zone_id
|
||||
name = var.api_domain
|
||||
type = "CNAME"
|
||||
ttl = 60
|
||||
records = [aws_elastic_beanstalk_environment.this.endpoint_url]
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
15
terraform/live/modules/environment-owned/outputs.tf
Normal file
15
terraform/live/modules/environment-owned/outputs.tf
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
output "environment_arn" {
|
||||
value = aws_elastic_beanstalk_environment.this.arn
|
||||
}
|
||||
|
||||
output "runtime_role_arn" {
|
||||
value = aws_iam_role.runtime.arn
|
||||
}
|
||||
|
||||
output "github_deploy_role_arn" {
|
||||
value = aws_iam_role.github_deploy.arn
|
||||
}
|
||||
|
||||
output "app_config_secret_arn" {
|
||||
value = aws_secretsmanager_secret.app_config.arn
|
||||
}
|
||||
220
terraform/live/modules/environment-owned/variables.tf
Normal file
220
terraform/live/modules/environment-owned/variables.tf
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
variable "aws_account_id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "environment" {
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "staging", "tf-poc"], var.environment)
|
||||
error_message = "environment must be dev, staging, or tf-poc."
|
||||
}
|
||||
}
|
||||
|
||||
variable "adoption_complete" {
|
||||
type = bool
|
||||
description = "False preserves existing ownership metadata. True changes only documented metadata and the dev deploy S3 policy."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "eb_application_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "eb_environment_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "eb_environment_id" {
|
||||
type = string
|
||||
description = "Existing environment ID. Empty only before the CDK POC has been provisioned."
|
||||
}
|
||||
|
||||
variable "platform_arn" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "vpc_id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "instance_subnet_ids" {
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "load_balancer_subnet_ids" {
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "instance_security_group_id" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Pinned existing instance SG setting. Null lets Elastic Beanstalk retain its provider-managed generated SG."
|
||||
}
|
||||
|
||||
variable "eb_service_role_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "shared_certificate_arn" {
|
||||
type = string
|
||||
description = "Existing shared certificate for dev/staging, or the POC certificate ARN."
|
||||
}
|
||||
|
||||
variable "runtime_role_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "runtime_app_config_policy_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "runtime_webhook_policy_name" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "runtime_dynamo_policy_name" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "permissions_boundary_arn" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "github_deploy_permissions_boundary_arn" {
|
||||
type = string
|
||||
description = "Exact org-baseline permissions boundary ARN for the environment GitHub deploy role."
|
||||
|
||||
validation {
|
||||
condition = can(regex(
|
||||
"^arn:aws:iam::${var.aws_account_id}:policy/shoc-backend-${var.environment}-deploy-boundary$",
|
||||
var.github_deploy_permissions_boundary_arn,
|
||||
))
|
||||
error_message = "github_deploy_permissions_boundary_arn must be the exact environment deploy boundary ARN."
|
||||
}
|
||||
}
|
||||
|
||||
variable "app_config_secret_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "app_config_json_keys" {
|
||||
type = set(string)
|
||||
description = "Exact JSON keys exposed through Elastic Beanstalk environmentsecrets."
|
||||
}
|
||||
|
||||
variable "app_config_policy_sid" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "webhook_secret_arn" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "work_order_webhook_enabled" {
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "webhook_read_policy_sid" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "webhook_decrypt_policy_sid" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "dynamo_reader_role_arn" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Dev-only cross-account role. Null for staging and tf-poc."
|
||||
}
|
||||
|
||||
variable "dynamo_policy_sid" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
check "dynamo_policy_pair" {
|
||||
assert {
|
||||
condition = (var.runtime_dynamo_policy_name == null) == (var.dynamo_reader_role_arn == null)
|
||||
error_message = "runtime_dynamo_policy_name and dynamo_reader_role_arn must both be set or both be null."
|
||||
}
|
||||
}
|
||||
|
||||
check "webhook_policy_pair" {
|
||||
assert {
|
||||
condition = (
|
||||
var.work_order_webhook_enabled &&
|
||||
var.runtime_webhook_policy_name != null &&
|
||||
var.webhook_secret_arn != null
|
||||
) || (
|
||||
!var.work_order_webhook_enabled &&
|
||||
var.runtime_webhook_policy_name == null &&
|
||||
var.webhook_secret_arn == null
|
||||
)
|
||||
error_message = "Enabled webhooks require a runtime policy and secret ARN; disabled webhooks require both to be null."
|
||||
}
|
||||
}
|
||||
|
||||
variable "github_repo" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "github_environment" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "github_deploy_role_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "github_deploy_policy_name" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "legacy_dev_s3_policy" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "hosted_zone_id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "api_domain" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "api_record_type" {
|
||||
type = string
|
||||
|
||||
validation {
|
||||
condition = contains(["A", "CNAME"], var.api_record_type)
|
||||
error_message = "api_record_type must be A or CNAME."
|
||||
}
|
||||
}
|
||||
|
||||
variable "metadata_before_adoption" {
|
||||
description = "Exact current metadata preserved while adoption_complete is false."
|
||||
type = object({
|
||||
runtime_role_description = string
|
||||
runtime_role_tags = map(string)
|
||||
instance_profile_tags = map(string)
|
||||
app_config_description = string
|
||||
app_config_tags = map(string)
|
||||
deploy_role_description = string
|
||||
deploy_role_tags = map(string)
|
||||
environment_tags = map(string)
|
||||
})
|
||||
}
|
||||
26
terraform/live/staging/.terraform.lock.hcl
generated
Normal file
26
terraform/live/staging/.terraform.lock.hcl
generated
Normal 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",
|
||||
]
|
||||
}
|
||||
49
terraform/live/staging/imports.tf
Normal file
49
terraform/live/staging/imports.tf
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
to = module.environment.aws_elastic_beanstalk_environment.this
|
||||
id = "e-6c9m4vb62z"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.runtime
|
||||
id = "shoc-backend-staging"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_instance_profile.runtime
|
||||
id = "shoc-backend-staging"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy_attachment.web_tier
|
||||
id = "shoc-backend-staging/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_app_config
|
||||
id = "shoc-backend-staging:shoc-staging-secrets-read"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_webhook[0]
|
||||
id = "shoc-backend-staging:shoc-backend-staging-webhook-secret-access"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.github_deploy
|
||||
id = "githubdeploy-shoc-backend-staging"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.github_deploy
|
||||
id = "githubdeploy-shoc-backend-staging:GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_secretsmanager_secret.app_config
|
||||
id = "arn:aws:secretsmanager:us-east-1:396287094661:secret:shoc/staging/app-config-CVV99L"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_route53_record.api_cname[0]
|
||||
id = "Z02602739VQWBWCAGXP4_api.staging.seahaven.com_CNAME"
|
||||
}
|
||||
88
terraform/live/staging/main.tf
Normal file
88
terraform/live/staging/main.tf
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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 = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
|
||||
api_domain = "api.staging.seahaven.com"
|
||||
}
|
||||
|
||||
module "inventory" {
|
||||
source = "../modules/environment-inventory"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
rds_identifier = "shoc-sqlserver-shared"
|
||||
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 "environment" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
environment = "staging"
|
||||
adoption_complete = false
|
||||
eb_application_name = local.eb_application_name
|
||||
eb_environment_name = local.eb_environment_name
|
||||
eb_environment_id = local.eb_environment_id
|
||||
platform_arn = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
|
||||
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"
|
||||
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"
|
||||
runtime_app_config_policy_name = "shoc-staging-secrets-read"
|
||||
runtime_webhook_policy_name = "shoc-backend-staging-webhook-secret-access"
|
||||
permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-staging-runtime-boundary"
|
||||
github_deploy_permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-staging-deploy-boundary"
|
||||
app_config_secret_name = "shoc/staging/app-config"
|
||||
app_config_json_keys = [
|
||||
"ConnectionStrings__DefaultConnection",
|
||||
"JWT__Secret",
|
||||
"JWT__ValidAudience",
|
||||
"JWT__ValidIssuer",
|
||||
"SendGrid__ApiKey",
|
||||
]
|
||||
webhook_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB"
|
||||
github_repo = "Sea-Haven-Industries/shoc-backend"
|
||||
github_environment = "staging"
|
||||
github_deploy_role_name = "githubdeploy-shoc-backend-staging"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = false
|
||||
hosted_zone_id = "Z02602739VQWBWCAGXP4"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "CNAME"
|
||||
metadata_before_adoption = {
|
||||
runtime_role_description = "SHOC backend staging compute role (EB instance profile)"
|
||||
runtime_role_tags = {
|
||||
env = "staging"
|
||||
project = "shoc"
|
||||
}
|
||||
instance_profile_tags = {}
|
||||
app_config_description = "SHOC staging application config (conn string, JWT, SendGrid)"
|
||||
app_config_tags = {
|
||||
env = "staging"
|
||||
project = "shoc"
|
||||
}
|
||||
deploy_role_description = "Least-privilege GitHub OIDC deploy role for shoc-backend staging. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk."
|
||||
deploy_role_tags = {
|
||||
Component = "deploy-role"
|
||||
Environment = "staging"
|
||||
HcpTerraformWorkspace = "shoc-backend-staging"
|
||||
ManagedBy = "cdk"
|
||||
Project = "shoc-backend"
|
||||
}
|
||||
environment_tags = {
|
||||
Name = "shoc-backend-staging"
|
||||
env = "staging"
|
||||
project = "shoc"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
terraform/live/staging/outputs.tf
Normal file
20
terraform/live/staging/outputs.tf
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
output "github_deploy_role_arn" {
|
||||
description = "Existing staging GitHub deploy role ARN."
|
||||
value = module.environment.github_deploy_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
|
||||
}
|
||||
}
|
||||
3
terraform/live/staging/providers.tf
Normal file
3
terraform/live/staging/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
19
terraform/live/staging/versions.tf
Normal file
19
terraform/live/staging/versions.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
terraform/live/tf-poc/.terraform.lock.hcl
generated
Normal file
26
terraform/live/tf-poc/.terraform.lock.hcl
generated
Normal 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",
|
||||
]
|
||||
}
|
||||
54
terraform/live/tf-poc/imports.tf
Normal file
54
terraform/live/tf-poc/imports.tf
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
to = aws_route53_zone.poc
|
||||
id = var.poc_hosted_zone_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = aws_acm_certificate.poc
|
||||
id = var.poc_certificate_arn
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_elastic_beanstalk_environment.this
|
||||
id = var.poc_environment_id
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.runtime
|
||||
id = "shoc-backend-tf-poc"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_instance_profile.runtime
|
||||
id = "shoc-backend-tf-poc"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy_attachment.web_tier
|
||||
id = "shoc-backend-tf-poc/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.runtime_app_config
|
||||
id = "shoc-backend-tf-poc:shoc-tf-poc-secrets-read"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role.github_deploy
|
||||
id = "githubdeploy-shoc-backend-tf-poc"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_iam_role_policy.github_deploy
|
||||
id = "githubdeploy-shoc-backend-tf-poc:githubdeploy-shoc-backend-tf-poc-eb"
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_secretsmanager_secret.app_config
|
||||
id = var.poc_app_config_secret_arn
|
||||
}
|
||||
|
||||
import {
|
||||
to = module.environment.aws_route53_record.api_cname[0]
|
||||
id = "${var.poc_hosted_zone_id}_api.tf-poc.seahaven.com_CNAME"
|
||||
}
|
||||
115
terraform/live/tf-poc/main.tf
Normal file
115
terraform/live/tf-poc/main.tf
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
data "aws_caller_identity" "current" {}
|
||||
|
||||
data "aws_vpc" "shared" {
|
||||
id = "vpc-0d16336143f3da25e"
|
||||
}
|
||||
|
||||
data "aws_db_instance" "shared" {
|
||||
db_instance_identifier = "shoc-sqlserver-shared"
|
||||
}
|
||||
|
||||
data "aws_iam_role" "eb_service" {
|
||||
name = "shoc-eb-service-role"
|
||||
}
|
||||
|
||||
check "account" {
|
||||
assert {
|
||||
condition = data.aws_caller_identity.current.account_id == "396287094661"
|
||||
error_message = "Refusing to inspect or adopt the POC outside account 396287094661."
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route53_zone" "poc" {
|
||||
name = "tf-poc.seahaven.com"
|
||||
comment = "Terraform import rehearsal child zone. Parent NS delegation is a separate approved operation."
|
||||
force_destroy = false
|
||||
|
||||
tags = {
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_acm_certificate" "poc" {
|
||||
domain_name = "*.tf-poc.seahaven.com"
|
||||
validation_method = "DNS"
|
||||
|
||||
tags = {
|
||||
Name = "shoc-backend-terraform-import-poc/Certificate"
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
module "environment" {
|
||||
source = "../modules/environment-owned"
|
||||
|
||||
aws_account_id = "396287094661"
|
||||
aws_region = "us-east-1"
|
||||
environment = "tf-poc"
|
||||
adoption_complete = true
|
||||
eb_application_name = "shoc-backend"
|
||||
eb_environment_name = "shoc-backend-tf-poc"
|
||||
eb_environment_id = var.poc_environment_id
|
||||
platform_arn = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
|
||||
vpc_id = data.aws_vpc.shared.id
|
||||
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
|
||||
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
|
||||
instance_security_group_id = null
|
||||
eb_service_role_name = data.aws_iam_role.eb_service.name
|
||||
shared_certificate_arn = aws_acm_certificate.poc.arn
|
||||
runtime_role_name = "shoc-backend-tf-poc"
|
||||
runtime_app_config_policy_name = "shoc-tf-poc-secrets-read"
|
||||
runtime_webhook_policy_name = null
|
||||
permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-tf-poc-runtime-boundary"
|
||||
github_deploy_permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-tf-poc-deploy-boundary"
|
||||
app_config_secret_name = "shoc/tf-poc/app-config"
|
||||
app_config_json_keys = [
|
||||
"ConnectionStrings__DefaultConnection",
|
||||
"JWT__Secret",
|
||||
"JWT__ValidAudience",
|
||||
"JWT__ValidIssuer",
|
||||
"SendGrid__ApiKey",
|
||||
]
|
||||
webhook_secret_arn = null
|
||||
work_order_webhook_enabled = false
|
||||
github_repo = "Sea-Haven-Industries/shoc-backend"
|
||||
github_environment = "tf-poc"
|
||||
github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc"
|
||||
github_deploy_policy_name = "githubdeploy-shoc-backend-tf-poc-eb"
|
||||
legacy_dev_s3_policy = false
|
||||
hosted_zone_id = aws_route53_zone.poc.zone_id
|
||||
api_domain = "api.tf-poc.seahaven.com"
|
||||
api_record_type = "CNAME"
|
||||
metadata_before_adoption = {
|
||||
runtime_role_description = "SHOC backend tf-poc compute role (EB instance profile)"
|
||||
runtime_role_tags = {
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
instance_profile_tags = {}
|
||||
app_config_description = "SHOC tf-poc application config (conn string, JWT, SendGrid)"
|
||||
app_config_tags = {
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
deploy_role_description = "GitHub OIDC deploy role for shoc-backend-tf-poc."
|
||||
deploy_role_tags = {
|
||||
HcpTerraformWorkspace = "shoc-backend-tf-poc"
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
environment_tags = {
|
||||
env = "tf-poc"
|
||||
project = "shoc"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
terraform/live/tf-poc/outputs.tf
Normal file
25
terraform/live/tf-poc/outputs.tf
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
output "environment_arn" {
|
||||
value = module.environment.environment_arn
|
||||
}
|
||||
|
||||
output "runtime_role_arn" {
|
||||
value = module.environment.runtime_role_arn
|
||||
}
|
||||
|
||||
output "github_deploy_role_arn" {
|
||||
value = module.environment.github_deploy_role_arn
|
||||
}
|
||||
|
||||
output "app_config_secret_arn" {
|
||||
value = module.environment.app_config_secret_arn
|
||||
}
|
||||
|
||||
output "child_zone_name_servers" {
|
||||
description = "For a separate, explicitly approved parent-zone delegation operation."
|
||||
value = aws_route53_zone.poc.name_servers
|
||||
}
|
||||
|
||||
output "shared_rds_arn" {
|
||||
description = "Data-only shared RDS instance. The shoc_tf_poc catalog remains out of band."
|
||||
value = data.aws_db_instance.shared.db_instance_arn
|
||||
}
|
||||
3
terraform/live/tf-poc/providers.tf
Normal file
3
terraform/live/tf-poc/providers.tf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
provider "aws" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
30
terraform/live/tf-poc/variables.tf
Normal file
30
terraform/live/tf-poc/variables.tf
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
variable "poc_environment_id" {
|
||||
type = string
|
||||
description = "Exact e-* ID of the retained POC environment."
|
||||
|
||||
validation {
|
||||
condition = can(regex("^e-[a-z0-9]+$", var.poc_environment_id))
|
||||
error_message = "poc_environment_id must be an Elastic Beanstalk e-* ID."
|
||||
}
|
||||
}
|
||||
|
||||
variable "poc_hosted_zone_id" {
|
||||
type = string
|
||||
description = "Exact Route 53 ID of the retained child zone."
|
||||
|
||||
validation {
|
||||
condition = can(regex("^Z[A-Z0-9]+$", var.poc_hosted_zone_id))
|
||||
error_message = "poc_hosted_zone_id must be a Route 53 hosted-zone ID."
|
||||
}
|
||||
}
|
||||
|
||||
variable "poc_certificate_arn" {
|
||||
type = string
|
||||
description = "Exact ARN of the retained ACM certificate."
|
||||
}
|
||||
|
||||
variable "poc_app_config_secret_arn" {
|
||||
type = string
|
||||
description = "Exact ARN of the retained POC app-config secret."
|
||||
}
|
||||
|
||||
19
terraform/live/tf-poc/versions.tf
Normal file
19
terraform/live/tf-poc/versions.tf
Normal 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-tf-poc"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue