Merge branch 'dev' into fix/user-list-full-display-name

This commit is contained in:
Arthur Bassi 2026-08-31 13:10:08 -03:00 • committed by GitHub
commit 0822b50a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2267 additions and 36 deletions

View file

@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["nuget", "npm", "github-actions"],
"enabledManagers": ["nuget", "npm", "github-actions", "terraform"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"packageRules": [
@ -17,6 +17,12 @@
"matchUpdateTypes": ["minor", "patch"],
"groupName": "nuget minor and patch"
},
{
"description": ["Group non-major Terraform updates"],
"matchManagers": ["terraform"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "terraform minor and patch"
},
{
"description": [
"Keep ASP.NET Core, EF Core, and dotnet-ef majors together"

View file

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

View file

@ -1,10 +1,8 @@
name: Validate and deploy dev
name: Validate and deploy
on:
pull_request:
branches: [dev]
push:
branches: [dev]
branches: [dev, staging, main]
workflow_dispatch:
permissions:
@ -66,22 +64,55 @@ 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 == 'workflow_dispatch' &&
contains(fromJSON('["refs/heads/dev","refs/heads/staging"]'), github.ref)
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: dev
name: ${{ github.ref_name }}
concurrency:
group: deploy-dev
group: deploy-${{ 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
;;
*)
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 +132,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 +143,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 +166,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 +188,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 +204,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 +233,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 +254,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 +265,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,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)

View file

@ -1,8 +1,13 @@
# shoc-backend CDK (dev deployment IAM)
# shoc-backend CDK
This CDK v2 app 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`.
## 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 retained GitHub
OIDC deploy role for dev. Automatic deployments are disabled while Terraform
adoption proceeds; dev, staging, and prod releases require an explicit
`workflow_dispatch` from the matching branch. The CDK stack remains until the
role's CloudFormation ownership transfer completes.
## Ownership boundary (deliberate)

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

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

@ -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 ' ')"

View 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",
},
}

View 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
View 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
View 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
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,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
View 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"
}
}
}

View 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
}
}

View file

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

View file

@ -0,0 +1,19 @@
terraform {
required_version = ">= 1.9.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,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."
}
}

View 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
}

View 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."
}

View 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
}
}

View 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
}

View 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)
})
}

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,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"
}

View 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"
}
}
}

View 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
}
}

View file

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

View file

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

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,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"
}

View 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"
}
}
}

View 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
}

View file

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

View 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."
}

View file

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