diff --git a/.github/renovate.json b/.github/renovate.json index 45e395d..3f64000 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 335b87c..4bd5541 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5981e3a..961d002 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index bac4700..659a71a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index e7b5c84..d78d118 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -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) diff --git a/infra/cdk/README.md b/infra/cdk/README.md index d4dab94..8c77048 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -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) diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py new file mode 100644 index 0000000..c8d9be3 --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -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", "") + 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()) diff --git a/scripts/governance-check.sh b/scripts/governance-check.sh index 4175287..503ef32 100755 --- a/scripts/governance-check.sh +++ b/scripts/governance-check.sh @@ -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" diff --git a/scripts/package-elastic-beanstalk.sh b/scripts/package-elastic-beanstalk.sh index c9e81df..99672fa 100755 --- a/scripts/package-elastic-beanstalk.sh +++ b/scripts/package-elastic-beanstalk.sh @@ -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 ' ')" diff --git a/scripts/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py new file mode 100644 index 0000000..75894af --- /dev/null +++ b/scripts/terraform_import_plan_resources.py @@ -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", + }, +} diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py new file mode 100644 index 0000000..f773ca0 --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -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()) diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..d807250 --- /dev/null +++ b/terraform/README.md @@ -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 +``` diff --git a/terraform/live/README.md b/terraform/live/README.md new file mode 100644 index 0000000..e7379c8 --- /dev/null +++ b/terraform/live/README.md @@ -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. diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 0000000..479237a --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/live/dev/imports.tf b/terraform/live/dev/imports.tf new file mode 100644 index 0000000..7f96b5a --- /dev/null +++ b/terraform/live/dev/imports.tf @@ -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" +} diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf new file mode 100644 index 0000000..3214b48 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -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" + } + } +} diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf new file mode 100644 index 0000000..fd6ec1f --- /dev/null +++ b/terraform/live/dev/outputs.tf @@ -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 + } +} diff --git a/terraform/live/dev/providers.tf b/terraform/live/dev/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/live/dev/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf new file mode 100644 index 0000000..efebe73 --- /dev/null +++ b/terraform/live/dev/versions.tf @@ -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" + } + } +} diff --git a/terraform/live/modules/environment-inventory/main.tf b/terraform/live/modules/environment-inventory/main.tf new file mode 100644 index 0000000..b806798 --- /dev/null +++ b/terraform/live/modules/environment-inventory/main.tf @@ -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." + } +} diff --git a/terraform/live/modules/environment-inventory/outputs.tf b/terraform/live/modules/environment-inventory/outputs.tf new file mode 100644 index 0000000..ac7f5e5 --- /dev/null +++ b/terraform/live/modules/environment-inventory/outputs.tf @@ -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 +} diff --git a/terraform/live/modules/environment-inventory/variables.tf b/terraform/live/modules/environment-inventory/variables.tf new file mode 100644 index 0000000..1258676 --- /dev/null +++ b/terraform/live/modules/environment-inventory/variables.tf @@ -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." +} diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf new file mode 100644 index 0000000..17c3b57 --- /dev/null +++ b/terraform/live/modules/environment-owned/main.tf @@ -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 + } +} diff --git a/terraform/live/modules/environment-owned/outputs.tf b/terraform/live/modules/environment-owned/outputs.tf new file mode 100644 index 0000000..b8c0b3b --- /dev/null +++ b/terraform/live/modules/environment-owned/outputs.tf @@ -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 +} diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf new file mode 100644 index 0000000..9980d9d --- /dev/null +++ b/terraform/live/modules/environment-owned/variables.tf @@ -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) + }) +} diff --git a/terraform/live/staging/.terraform.lock.hcl b/terraform/live/staging/.terraform.lock.hcl new file mode 100644 index 0000000..479237a --- /dev/null +++ b/terraform/live/staging/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/live/staging/imports.tf b/terraform/live/staging/imports.tf new file mode 100644 index 0000000..c5ad5e2 --- /dev/null +++ b/terraform/live/staging/imports.tf @@ -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" +} diff --git a/terraform/live/staging/main.tf b/terraform/live/staging/main.tf new file mode 100644 index 0000000..baa5f4b --- /dev/null +++ b/terraform/live/staging/main.tf @@ -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" + } + } +} diff --git a/terraform/live/staging/outputs.tf b/terraform/live/staging/outputs.tf new file mode 100644 index 0000000..1c01244 --- /dev/null +++ b/terraform/live/staging/outputs.tf @@ -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 + } +} diff --git a/terraform/live/staging/providers.tf b/terraform/live/staging/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/live/staging/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/live/staging/versions.tf b/terraform/live/staging/versions.tf new file mode 100644 index 0000000..7372ab4 --- /dev/null +++ b/terraform/live/staging/versions.tf @@ -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" + } + } +} diff --git a/terraform/live/tf-poc/.terraform.lock.hcl b/terraform/live/tf-poc/.terraform.lock.hcl new file mode 100644 index 0000000..479237a --- /dev/null +++ b/terraform/live/tf-poc/.terraform.lock.hcl @@ -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", + ] +} diff --git a/terraform/live/tf-poc/imports.tf b/terraform/live/tf-poc/imports.tf new file mode 100644 index 0000000..d462961 --- /dev/null +++ b/terraform/live/tf-poc/imports.tf @@ -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" +} diff --git a/terraform/live/tf-poc/main.tf b/terraform/live/tf-poc/main.tf new file mode 100644 index 0000000..3a833f7 --- /dev/null +++ b/terraform/live/tf-poc/main.tf @@ -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" + } + } +} diff --git a/terraform/live/tf-poc/outputs.tf b/terraform/live/tf-poc/outputs.tf new file mode 100644 index 0000000..c4e0a44 --- /dev/null +++ b/terraform/live/tf-poc/outputs.tf @@ -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 +} diff --git a/terraform/live/tf-poc/providers.tf b/terraform/live/tf-poc/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/live/tf-poc/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/live/tf-poc/variables.tf b/terraform/live/tf-poc/variables.tf new file mode 100644 index 0000000..03e409e --- /dev/null +++ b/terraform/live/tf-poc/variables.tf @@ -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." +} + diff --git a/terraform/live/tf-poc/versions.tf b/terraform/live/tf-poc/versions.tf new file mode 100644 index 0000000..53736ed --- /dev/null +++ b/terraform/live/tf-poc/versions.tf @@ -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" + } + } +}