From fa9bda7554f55a505dfdd8e24f014a7345cbb416 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 31 Aug 2026 19:08:53 -0400 Subject: [PATCH] feat(deploy): move dev application CD through Terraform GitHub creates the immutable Elastic Beanstalk version; HCP Terraform is the only UpdateEnvironment caller via a guarded version_label run. --- .github/renovate.json | 10 +- .github/workflows/ci.yml | 14 +- .github/workflows/dependency-review.yml | 4 +- .github/workflows/deploy.yml | 410 +++++++++-- .gitignore | 6 - .security-review/suppressions.json | 13 - QUALITY_GATES.md | 20 +- REVIEW_AND_PR_FRAMEWORK.md | 4 + infra/cdk/README.md | 306 -------- infra/cdk/app.ts | 20 - infra/cdk/cdk.json | 8 - infra/cdk/deploy-dev-stack.ts | 180 ----- infra/cdk/package-lock.json | 694 ------------------ infra/cdk/package.json | 24 - infra/cdk/tsconfig.json | 23 - scripts/check-terraform-release-plan.py | 284 +++++++ scripts/governance-check.sh | 4 + scripts/package-elastic-beanstalk.sh | 9 +- scripts/test-terraform-release-plan-check.py | 200 +++++ .../terraform-release-plans/create.json | 16 + .../terraform-release-plans/delete.json | 16 + .../terraform-release-plans/dns-update.json | 28 + .../eb-setting-change.json | 34 + .../terraform-release-plans/empty.json | 3 + .../terraform-release-plans/iam-update.json | 18 + .../multiple-updates.json | 32 + .../terraform-release-plans/replace.json | 20 + .../terraform-release-plans/version-only.json | 48 ++ .../terraform-release-plans/wrong-label.json | 22 + scripts/validate-elastic-beanstalk-bundle.sh | 34 + terraform/README.md | 1 + terraform/live/README.md | 69 +- terraform/live/dev/main.tf | 1 + terraform/live/dev/variables.tf | 15 + .../live/modules/environment-owned/main.tf | 15 +- .../modules/environment-owned/variables.tf | 16 + 36 files changed, 1242 insertions(+), 1379 deletions(-) delete mode 100644 .security-review/suppressions.json delete mode 100644 infra/cdk/README.md delete mode 100644 infra/cdk/app.ts delete mode 100644 infra/cdk/cdk.json delete mode 100644 infra/cdk/deploy-dev-stack.ts delete mode 100644 infra/cdk/package-lock.json delete mode 100644 infra/cdk/package.json delete mode 100644 infra/cdk/tsconfig.json create mode 100644 scripts/check-terraform-release-plan.py create mode 100644 scripts/test-terraform-release-plan-check.py create mode 100644 scripts/testdata/terraform-release-plans/create.json create mode 100644 scripts/testdata/terraform-release-plans/delete.json create mode 100644 scripts/testdata/terraform-release-plans/dns-update.json create mode 100644 scripts/testdata/terraform-release-plans/eb-setting-change.json create mode 100644 scripts/testdata/terraform-release-plans/empty.json create mode 100644 scripts/testdata/terraform-release-plans/iam-update.json create mode 100644 scripts/testdata/terraform-release-plans/multiple-updates.json create mode 100644 scripts/testdata/terraform-release-plans/replace.json create mode 100644 scripts/testdata/terraform-release-plans/version-only.json create mode 100644 scripts/testdata/terraform-release-plans/wrong-label.json create mode 100755 scripts/validate-elastic-beanstalk-bundle.sh create mode 100644 terraform/live/dev/variables.tf diff --git a/.github/renovate.json b/.github/renovate.json index 3f64000..230da6d 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", "terraform"], + "enabledManagers": ["nuget", "github-actions", "terraform"], "minimumReleaseAge": "3 days", "internalChecksFilter": "strict", "packageRules": [ @@ -56,12 +56,6 @@ "matchPackageNames": ["FluentValidation{/,}**"], "matchUpdateTypes": ["major"], "groupName": "fluentvalidation" - }, - { - "description": ["Keep aws-cdk and aws-cdk-lib together"], - "matchPackageNames": ["aws-cdk", "aws-cdk-lib"], - "matchUpdateTypes": ["major"], - "groupName": "aws cdk" } ] -} \ No newline at end of file +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bd5541..1cdf310 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,15 +61,5 @@ jobs: - 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 + - name: Terraform release plan guard tests + run: python scripts/test-terraform-release-plan-check.py diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 13650e5..b5f44fb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,6 +3,4 @@ on: pull_request: jobs: review: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main - with: - allow-ghsas: GHSA-mh99-v99m-4gvg + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 961d002..4a7091e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,8 @@ name: Validate and deploy on: pull_request: branches: [dev, staging, main] + push: + branches: [dev] workflow_dispatch: permissions: @@ -23,60 +25,374 @@ jobs: with: dotnet-version: "8.0.x" - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22.22.1" - cache: npm - cache-dependency-path: infra/cdk/package-lock.json - - name: Repository quality gate run: bash scripts/governance-check.sh - - name: Validate CDK deployment infrastructure - run: | - npm ci --prefix infra/cdk - npm run synth --prefix infra/cdk - - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh - name: Inspect source bundle contract + run: bash scripts/validate-elastic-beanstalk-bundle.sh + + deploy-dev: + name: Deploy shoc-backend-dev through Terraform + if: > + (github.event_name == 'push' && github.ref == 'refs/heads/dev' && + vars.TERRAFORM_APP_CD_ENABLED == 'true') || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + contents: read + id-token: write + environment: + name: dev + concurrency: + group: deploy-dev + cancel-in-progress: false + env: + TF_CLOUD_ORGANIZATION: seahaven + TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} + EB_APPLICATION_NAME: shoc-backend + EB_ENVIRONMENT_NAME: shoc-backend-dev + SMOKE_URL: https://api.dev.seahaven.com + EB_BUCKET: elasticbeanstalk-us-east-1-396287094661 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: "8.0.x" + + - name: Build Elastic Beanstalk source bundle + run: bash scripts/package-elastic-beanstalk.sh + + - name: Validate exact release bundle + run: bash scripts/validate-elastic-beanstalk-bundle.sh + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Capture current environment version run: | set -euo pipefail - unzip -t .artifacts/elastic-beanstalk/site.zip - unzip -Z1 .artifacts/elastic-beanstalk/site.zip \ - > .artifacts/elastic-beanstalk/zip-contents.txt - grep -Fxq "efbundle" .artifacts/elastic-beanstalk/zip-contents.txt - grep -Fxq ".ebextensions/01_migrations.config" \ - .artifacts/elastic-beanstalk/zip-contents.txt - grep -Fxq ".ebextensions/02_webhook_config.config" \ - .artifacts/elastic-beanstalk/zip-contents.txt - unzip -p .artifacts/elastic-beanstalk/site.zip \ - .ebextensions/02_webhook_config.config \ - > .artifacts/elastic-beanstalk/webhook-config.txt - grep -Fxq ' WorkOrderWebhook__Enabled: "true"' \ - .artifacts/elastic-beanstalk/webhook-config.txt - grep -Fxq ' WorkOrderWebhook__Region: us-east-1' \ - .artifacts/elastic-beanstalk/webhook-config.txt - grep -Fxq \ - ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ - .artifacts/elastic-beanstalk/webhook-config.txt + prev="$(aws elasticbeanstalk describe-environments \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].VersionLabel' \ + --output text)" + echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt + echo "Previous version label: $prev" - deploy: - name: Deploy shoc-backend to Elastic Beanstalk + - name: Assign immutable release identity + id: release + run: | + set -euo pipefail + version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + s3_key="shoc-backend/releases/dev/${GITHUB_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip" + { + echo "version_label=${version_label}" + echo "s3_key=${s3_key}" + } >> "${GITHUB_OUTPUT}" + + - name: Upload immutable bundle + run: | + set -euo pipefail + aws s3 cp .artifacts/elastic-beanstalk/site.zip \ + "s3://${EB_BUCKET}/${{ steps.release.outputs.s3_key }}" \ + --region us-east-1 + + - name: Create Elastic Beanstalk application version + run: | + set -euo pipefail + aws elasticbeanstalk create-application-version \ + --application-name "${EB_APPLICATION_NAME}" \ + --version-label "${{ steps.release.outputs.version_label }}" \ + --description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" \ + --source-bundle "S3Bucket=${EB_BUCKET},S3Key=${{ steps.release.outputs.s3_key }}" \ + --process \ + --region us-east-1 + + status="UNPROCESSED" + for _ in $(seq 1 36); do + status="$(aws elasticbeanstalk describe-application-versions \ + --application-name "${EB_APPLICATION_NAME}" \ + --version-labels "${{ steps.release.outputs.version_label }}" \ + --region us-east-1 \ + --query 'ApplicationVersions[0].Status' \ + --output text)" + echo "application version status: $status" + if [ "$status" = "PROCESSED" ]; then + exit 0 + fi + if [ "$status" = "FAILED" ]; then + echo "Elastic Beanstalk failed to process ${{ steps.release.outputs.version_label }}." >&2 + exit 1 + fi + sleep 5 + done + echo "Application version did not become PROCESSED." >&2 + exit 1 + + - name: Create Terraform release run + id: release-run + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"' + with: + workspace: shoc-backend-dev + message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions" + + - name: Read Terraform release plan counts + id: release-plan + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.release-run.outputs.plan_id }} + + - name: Reject non-version-only resource counts + env: + PLAN_ADD: ${{ steps.release-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.release-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 + exit 1 + fi + + - name: Guard version-only Terraform plan + run: | + set -euo pipefail + python scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.release-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.release.outputs.version_label }}" + + - name: Discard release run when the guard fails + if: failure() && steps.release-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Rejected by the version-only plan guard from GitHub Actions + + - name: Apply Terraform release run + id: release-apply + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.release-run.outputs.run_id }} + comment: Apply version-only release from GitHub Actions ${{ github.sha }} + + - name: Verify exact application version is active + run: | + set -euo pipefail + expected="${{ steps.release.outputs.version_label }}" + status="Unknown" + current="Unknown" + health="Unknown" + + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + + if [ "$status" = "Ready" ]; then + if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then + echo "Expected application version is Ready and healthy." + exit 0 + fi + echo "Environment became Ready without activating expected version $expected." >&2 + exit 1 + fi + sleep 15 + done + + echo "Expected application version did not become Ready within the deployment window." >&2 + exit 1 + + - name: Post-deploy smoke + run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" + + - name: Verify webhook secret source is operational + run: | + set -euo pipefail + response_file="$(mktemp)" + trap 'rm -f "$response_file"' EXIT + status="$(curl --silent --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --header "X-SH-Timestamp: $(date +%s)" \ + --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ + --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ + --data '{}' \ + "${SMOKE_URL}/api/webhooks/work-orders")" + if [ "$status" != "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 + exit 1 + fi + + - name: Restore previous application version on failure (schema is not reverted) + if: failure() + run: | + set -euo pipefail + prev_file=".artifacts/elastic-beanstalk/previous-version.txt" + if [ ! -f "$prev_file" ]; then + echo "No previous version captured; nothing to roll back." >&2 + exit 0 + fi + prev="$(cat "$prev_file")" + if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then + echo "No previous version recorded; nothing to roll back." >&2 + exit 0 + fi + + echo "Waiting for any in-flight environment update to settle..." + status="Unknown" + current="Unknown" + health="Unknown" + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + break + fi + sleep 15 + done + + if [ "$status" != "Ready" ]; then + echo "Environment did not settle before rollback." >&2 + exit 1 + fi + if [ "$current" = "$prev" ]; then + echo "Environment is already on previous version $prev." + exit 0 + fi + if [[ ! "$prev" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then + echo "Previous version $prev is not a Terraform-managed release label; cannot roll back through HCP." >&2 + exit 1 + fi + echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}" + id: rollback-prepare + + - name: Create Terraform rollback run + id: rollback-run + if: failure() && steps.rollback-prepare.outputs.rollback_label != '' + uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + env: + TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"' + with: + workspace: shoc-backend-dev + message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions" + + - name: Read Terraform rollback plan counts + id: rollback-plan + if: failure() && steps.rollback-run.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + plan: ${{ steps.rollback-run.outputs.plan_id }} + + - name: Reject non-version-only rollback counts + id: rollback-count-guard + if: failure() && steps.rollback-plan.outcome == 'success' + env: + PLAN_ADD: ${{ steps.rollback-plan.outputs.add }} + PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }} + PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }} + run: | + set -euo pipefail + if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then + echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2 + exit 1 + fi + + - name: Guard version-only Terraform rollback plan + id: rollback-json-guard + if: failure() && steps.rollback-count-guard.outcome == 'success' + run: | + set -euo pipefail + python scripts/check-terraform-release-plan.py \ + --plan-id "${{ steps.rollback-run.outputs.plan_id }}" \ + --expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" + + - name: Discard rollback run when the guard fails + if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success' + uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Rejected by the version-only rollback plan guard from GitHub Actions + + - name: Apply Terraform rollback run + id: rollback-apply + if: failure() && steps.rollback-json-guard.outcome == 'success' + uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2 + with: + run: ${{ steps.rollback-run.outputs.run_id }} + comment: Apply version-only rollback from GitHub Actions ${{ github.sha }} + + - name: Verify previous application version is active + if: failure() && steps.rollback-apply.outcome == 'success' + run: | + set -euo pipefail + prev="${{ steps.rollback-prepare.outputs.rollback_label }}" + echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." + status="Unknown" + current="Unknown" + health="Unknown" + for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ + --region us-east-1 \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: $status; version: $current; health: $health" + if [ "$status" = "Ready" ]; then + if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then + echo "Application version restore complete; previous code is Ready and healthy." + exit 0 + fi + echo "Rollback reached Ready in an unexpected version/health state." >&2 + exit 1 + fi + sleep 15 + done + echo "Environment did not return to Ready within rollback window." >&2 + exit 1 + + deploy-staging: + name: Deploy shoc-backend-staging to Elastic Beanstalk if: > - github.event_name == 'workflow_dispatch' && - contains(fromJSON('["refs/heads/dev","refs/heads/staging"]'), github.ref) + github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging' needs: validate runs-on: ubuntu-latest permissions: contents: read id-token: write environment: - name: ${{ github.ref_name }} + name: staging concurrency: - group: deploy-${{ github.ref_name }} + group: deploy-staging cancel-in-progress: false steps: - name: Checkout @@ -86,22 +402,9 @@ jobs: 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 + application=shoc-backend + environment=shoc-backend-staging + smoke_url=https://api.staging.seahaven.com { echo "application=${application}" echo "environment=${environment}" @@ -121,6 +424,9 @@ jobs: - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh + - name: Validate exact release bundle + run: bash scripts/validate-elastic-beanstalk-bundle.sh + - name: Configure AWS credentials (OIDC) uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: diff --git a/.gitignore b/.gitignore index 659a71a..fb3bf73 100644 --- a/.gitignore +++ b/.gitignore @@ -366,12 +366,6 @@ FodyWeavers.xsd appsettings.Development.json .DS_Store -# CDK (infra/cdk) generated artifacts -infra/cdk/node_modules/ -infra/cdk/dist/ -infra/cdk/cdk.out/ -infra/cdk/.cdk.staging/ - # Deployment packaging artifacts .artifacts/ diff --git a/.security-review/suppressions.json b/.security-review/suppressions.json deleted file mode 100644 index d5e6120..0000000 --- a/.security-review/suppressions.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "suppressions": [ - { - "advisory": "GHSA-mh99-v99m-4gvg", - "package": "brace-expansion", - "introducedBy": "aws-cdk-lib@2.262.1", - "scope": "Build-time CDK synthesis only; no untrusted pattern input or runtime deployment artifact.", - "reason": "The vulnerable copy is bundled by the latest aws-cdk-lib release and cannot be overridden or updated independently. Dependabot monitors the pinned CDK dependency.", - "reviewBy": "2026-08-10", - "tracking": "SH-133" - } - ] -} diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index d78d118..6d579ae 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -25,7 +25,8 @@ | 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 | +| G11 | Terraform static validation | import configuration integrity | commands below | `ci` on the matching PR base | +| G12 | Terraform release plan safety | dev application CD version_label | `python scripts/test-terraform-release-plan-check.py` | `architecture-quality` → `governance-check.sh` | ## How to run locally @@ -49,6 +50,8 @@ The script: 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). +7. verifies that the release plan guard accepts only a version-only update of + `module.environment.aws_elastic_beanstalk_environment.this` (G12). G10 permits only exact approved resource address/type pairs for the environment-owned boundary: Elastic @@ -60,10 +63,17 @@ 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. +and `terraform validate`. PRs to `dev` validate `live/tf-poc` and `live/dev`. +PRs to `staging` validate only `live/staging`. Org-baseline CloudFormation owns +the HCP role substrate, and Terraform owns the dev deploy role, so no backend +CDK or bootstrap root remains in the matrix. + +G12 accepts only a local or downloaded plan JSON whose sole managed update is +`module.environment.aws_elastic_beanstalk_environment.this` with +`version_label` as the only changed attribute. Counts of `0` add / `1` change / +`0` destroy are not a substitute. The optional download uses +`GET /api/v2/plans/:id/json-output` on `app.terraform.io` with one redirect to +`archivist.terraform.io` and does not create, apply, discard, or poll runs. ## Migration gates (G6) diff --git a/REVIEW_AND_PR_FRAMEWORK.md b/REVIEW_AND_PR_FRAMEWORK.md index 04b70dc..3b32672 100644 --- a/REVIEW_AND_PR_FRAMEWORK.md +++ b/REVIEW_AND_PR_FRAMEWORK.md @@ -109,6 +109,10 @@ Suppressions are single-diagnostic and cite the ADR — **no wildcard suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity sweeps, no `#pragma` swaths). See architecture §10. +Do not mix deployable application changes with Terraform or CDK changes. The +first Terraform-owned application-CD change is the allowed exception because it +introduces `release_version_label`. Later PRs must keep those diffs separate. + ## 8. PR description contract (minimal) - **Summary** — what changed and why, in plain language. diff --git a/infra/cdk/README.md b/infra/cdk/README.md deleted file mode 100644 index 5ab4d24..0000000 --- a/infra/cdk/README.md +++ /dev/null @@ -1,306 +0,0 @@ -# shoc-backend CDK - -## 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) - -CDK owns: - -- The IAM role `githubdeploy-shoc-backend-dev`. -- Its OIDC trust relationship to - `arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com` - scoped to `repo:Sea-Haven-Industries/shoc-backend:environment:dev`. -- Its least-privilege inline permissions policy. - -CDK does **not** own, create, import, replace, or modify any of the following. -They are referenced by exact identifier only and remain owned by their original -provisioning path: - -- Elastic Beanstalk application `shoc-backend` -- Elastic Beanstalk environment `shoc-backend-dev` -- DNS, VPC, EC2, RDS, and existing service/instance roles -- S3 bucket `elasticbeanstalk-us-east-1-396287094661` -- Environment configuration / option settings -- Database schema (migrations are applied by Elastic Beanstalk at deploy time, - not by CDK) - -The role is retained on stack deletion (`DeletionPolicy=Retain`, -`UpdateReplacePolicy=Retain`) so an accidental teardown cannot orphan the trust -or lock out deployments. - -## Least-privilege policy summary - -The role grants only: - -- The three read-only Elastic Beanstalk actions used by deploy, wait, and - rollback (`DescribeApplicationVersions`, `DescribeEnvironments`, and - `DescribeEvents`). These use `Resource: "*"` because Elastic Beanstalk - describe actions are not reliably constrained by resource ARN. -- `elasticbeanstalk:CreateApplicationVersion` on application `shoc-backend`. -- `elasticbeanstalk:UpdateEnvironment` on environment `shoc-backend-dev` only. -- `s3:ListBucket` and `s3:GetBucketLocation` on - `elasticbeanstalk-us-east-1-396287094661` (the official action's - ownership-safe bucket checks), `s3:GetBucketPolicy` for the policy inspection - observed in attempt 11 of run `30448885838`, plus `s3:CreateBucket` and - `s3:PutBucketOwnershipControls` on the same bucket-level ARN. Under the - `shoc-backend/` object prefix only: - `s3:PutObject`, `s3:GetObject`, and `s3:GetObjectVersion`, which the pinned - official deployment action requires to validate the - `CreateApplicationVersion` source bundle after upload. -- `s3:PutObject`, `s3:GetObject`, `s3:GetObjectVersionAcl`, - `s3:PutObjectVersionAcl`, and `s3:DeleteObject` on only - `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_versions/shoc-backend/*`. - Elastic Beanstalk copies each uploaded source bundle into this - environment-specific runtime prefix during `UpdateEnvironment`, verifies it - with `HeadObject` (authorized by `s3:GetObject`), and removes the temporary - copy after the version is registered. Attempts 1 through 4 of run - `30448885838` exposed the exact source, destination, cleanup, and verification - operations after the earlier ACL denial was resolved. CloudTrail recorded - the exact `s3:GetObject` denial on attempt 4; attempt 6 then exposed the - version-specific ACL read performed on the copied object; attempt 7 exposed - the matching version-ACL write. The grant does not cover another - environment, another application, source bundles, object content versions, - non-version ACL mutation, tags, or retention. -- `s3:PutObject` on only the two embedded-extension prefixes - `elasticbeanstalk-us-east-1-396287094661/resources/_runtime/_embedded_extensions/shoc-backend/*` - and - `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/_embedded_extensions/shoc-backend/*`. - After the runtime bundle copy and version-ACL operations succeeded, attempt 8 - of run `30448885838` showed Elastic Beanstalk materializing the application's - embedded-extension manifest at the application-specific shared prefix. - Attempt 9 then showed the matching write into the exact dev-environment - prefix. CloudTrail recorded both denied actions and object ARNs. The grant - does not include reads, deletes, ACL mutation, another application, - another environment, or another bucket. -- `s3:GetObject` on only the environment-specific embedded-extension prefix - above. Attempt 10 showed that Elastic Beanstalk verifies the materialized - environment copy with `HeadObject`, which S3 authorizes through - `s3:GetObject`. The shared embedded-extension prefix remains write-only. -- `s3:GetObject` and `s3:PutObject` on only - `elasticbeanstalk-us-east-1-396287094661/resources/environments/e-hehnrqjjrt/_runtime/versions/*`. - Attempt 12 showed Elastic Beanstalk reading the previous environment version - manifest and writing its replacement under this exact dev-environment - runtime prefix. The grant excludes deletes, ACL mutation, other environments, - and application bundle content. -- `s3:GetObjectAcl` on objects under the service-wide - `arn:aws:s3:::elasticbeanstalk-*/*` namespace. AWS Support case - `178526484500047` confirmed that `UpdateEnvironment` uses the initiating - role to inspect objects in AWS-owned Elastic Beanstalk buckets, not only the - account-owned source-bundle bucket. The wildcard is limited to one read-only - ACL action and the Elastic Beanstalk bucket namespace; it grants no object - content read, write, delete, bucket-management, IAM, or `PassRole` - capability. - - `s3:CreateBucket` is part of the pinned - `aws-actions/aws-elastic-beanstalk-deploy@cfad3e5e...` (v1.0.6) IAM - contract even though the workflow sets - `create-s3-bucket-if-not-exists: "false"`. That input prevents the - action's explicit bucket-creation helper; it does not remove the permission - required by the subsequent Elastic Beanstalk update path. A live deployment - confirmed this boundary: `CreateApplicationVersion` succeeded, then - `UpdateEnvironment` was denied because the caller lacked - `s3:CreateBucket` on the service bucket. The permission is scoped to that - exact bucket-level ARN only (no object prefix, no wildcard resource), so it - cannot create any other bucket. - - `s3:PutBucketOwnershipControls` was added after a second live deployment - (run 30375409934) failed at `UpdateEnvironment` with `AccessDenied` for - `s3:PutBucketOwnershipControls` on the same service bucket. That call is - emitted by Elastic Beanstalk's `UpdateEnvironment` path after the source - bundle upload succeeds; AWS classifies it as a bucket-level permission, so - it is scoped to the same exact bucket-level ARN (no object prefix, no - wildcard resource). It does not widen object-prefix permissions, does not - grant `PutBucketPolicy`, `PutBucketPublicAccessBlock`, or any object-level write, - and does not change `create-s3-bucket-if-not-exists: "false"`. - - `s3:GetBucketLocation` was added after CloudTrail showed that run - `30375409934` attempt 4 invoked it as - `githubdeploy-shoc-backend-dev/GitHubActions` and was denied. It is scoped to - the exact bucket-level ARN and grants no object access. - -- The six CloudFormation discovery calls observed across the failed OIDC and - successful administrator deployments (`DescribeStackEvents`, - `DescribeStackResource`, `DescribeStackResources`, `DescribeStacks`, - `GetTemplate`, and `ListStackResources`) on the Elastic Beanstalk-managed - stack `awseb-e-hehnrqjjrt-stack`, scoped to - `arn:aws:cloudformation:us-east-1:396287094661:stack/awseb-e-hehnrqjjrt-stack/*`. - These read-only calls are emitted by Elastic Beanstalk's - `UpdateEnvironment` path under the GitHub deploy role. `GetTemplate` was - added after run `30375409934` attempt 2 advanced past the S3 - ownership-controls step and was denied on the EB-managed stack instance - `awseb-e-hehnrqjjrt-stack/112f77c0-7718-11f1-a1a9-0e48750aef13`. - CloudTrail then showed attempt 4 denied `DescribeStackResources` and - `ListStackResources` on that same stack instance. - CloudFormation stack ARNs carry a random GUID instance suffix, so the - permission is scoped to that one stack-name prefix (`/*`) rather than a - single instance ARN. The statement grants no CloudFormation mutation, no - `Resource: "*"`, and no access to any other stack. CDK does not own or - mutate that stack; it is owned by Elastic Beanstalk and referenced by - identifier only. - -- `ec2:DescribeAvailabilityZones`, `ec2:DescribeImages`, and - `ec2:DescribeSubnets` as read-only account-level discovery queries. - CloudTrail identified the GitHub deploy role as the caller during run - `30375409934`; attempt 5 confirmed the first two denials after - `DescribeSubnets` was allowed. EC2 does not support resource-level - constraints for these Describe actions, so IAM requires `Resource: "*"`. - No EC2 mutation action is granted. -- The Auto Scaling discovery calls `DescribeAutoScalingGroups` and - `DescribeScalingActivities` on `Resource: "*"` plus - `PutNotificationConfiguration`, `ResumeProcesses`, and `SuspendProcesses` - on only Auto Scaling groups whose name starts with - `awseb-e-hehnrqjjrt-stack-`. These are the exact calls recorded during the - successful administrator deployment. AWS supports resource-level - constraints for all three mutations, so replacement ASGs remain covered - without granting access to another environment. -It grants **no** IAM mutation or `PassRole`, **no** RDS / Secrets Manager -access, no EC2 mutation, and **no** administrator policy. The only non-EB/S3 -mutations are the three deployment-process Auto Scaling calls, restricted to -this environment's ASG name pattern. There are no wildcard mutation surfaces; -the only service-wide object grant is read-only ACL metadata. - -## Prerequisites - -- Node >= 22.22.1 and npm. -- AWS credentials authorized to create/inspect CloudFormation, IAM roles, and - trust policies in account `396287094661`. -- The GitHub OIDC provider - `arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com` - must already exist in the account (created once, outside this stack). - -## Commands - -```bash -npm ci # install pinned dependencies -npm run build # type-check / compile to dist/ -npm run synth # synthesize the CloudFormation template -npm run diff # diff deployed stack vs local (requires AWS) -npm run deploy # deploy the stack (requires AWS) -``` - -All commands run from `infra/cdk/`. - -## Terraform ownership transfer - -`ManageGithubDeployRole` deliberately has no default. Every CDK deployment must -state the intended ownership phase: - -```bash -# Before the controlled Terraform apply: install Retain on the role and policy. -npx cdk deploy shoc-backend-deploy-dev \ - --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=true - -# After Terraform succeeds and live verification passes: relinquish ownership. -npx cdk deploy shoc-backend-deploy-dev \ - --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=false -``` - -Both deployments must use the same reviewed SHA. The first keeps the role and -generated inline policy under CloudFormation while adding retention metadata. -The second removes both resources from CloudFormation ownership while retaining -them live for Terraform. After the second deployment succeeds, -`ManageGithubDeployRole=true` must never be used again. - -Omitting the parameter fails closed before deployment. If the `true` deployment -rolls back, inspect the stack resources and live role/policy before retrying; -retained resources can outlive a failed update and must not be cleaned up -automatically. - -## CI integration - -`npm run synth` is the deterministic local/CI validation. After synth, inspect -`cdk.out/shoc-backend-deploy-dev.template.json` and verify the synthesized -`AWS::IAM::Role`: - -- Trust policy `StringEquals` matches the exact audience and subject above. -- The role, generated `AWS::IAM::Policy`, and role ARN output share the - `ManageGithubDeployRoleCondition`; both resources use `DeletionPolicy` and - `UpdateReplacePolicy` set to `Retain`. -- The inline policy contains no `Resource: "*"` mutation action and no service - outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` / - `elasticloadbalancing` / `autoscaling`. CloudFormation discovery and - mutations are limited to the single EB-managed stack prefix. EC2, Elastic - Load Balancing, and Auto Scaling discovery use `Resource: "*"` only where the - IAM resource model requires it; Auto Scaling mutations are limited to this - environment's ASG name pattern. - -The workflow's `AWS_DEPLOY_ROLE_ARN` repository secret (environment `dev`) must -hold the ARN output by this stack (`GithubDeployRoleArn`). - -The previous OIDC deployment remained fail-closed after Elastic Beanstalk -reported a generic `s3:GetObjectAcl` denial outside the account-owned source -prefix. AWS Support case `178526484500047` subsequently confirmed that -`UpdateEnvironment` checks objects in AWS-owned Elastic Beanstalk buckets -using the initiating role and requires the service-wide -`elasticbeanstalk-*` bucket/object namespaces. - -The July 30 deployment of backend PR #41 then reached `UpdateEnvironment` and -failed on `ec2:DescribeVpcs`. Elastic Beanstalk performs this read-only network -discovery using the initiating role, so the CDK policy includes that action -alongside the existing EC2 describe permissions. It remains resource `*` -because `DescribeVpcs` does not support resource-level permissions. - -Successive exact reruns then reached S3 cleanup, the delegated CloudFormation -update, and the CloudFormation template fetch. The observed failures were -`s3:DeleteObject`, `cloudformation:UpdateStack`, and finally an opaque -CloudFormation `S3 error: Access Denied` after narrower object reads had been -added. Because AWS does not expose the AWS-owned bucket/key or exact internal -S3 read in that final error, the CDK now uses AWS Support's authoritative -UpdateEnvironment S3 set: - -- `s3:Delete*`, `s3:Get*`, and `s3:Put*` on - `arn:aws:s3:::elasticbeanstalk-*/*`. -- `s3:GetBucket*`, `s3:ListBucket`, `s3:PutBucketPolicy`, - `s3:PutBucketPublicAccessBlock`, and `s3:PutBucketOwnershipControls` on - `arn:aws:s3:::elasticbeanstalk-*`. - -`s3:CreateBucket` remains excluded because this workflow targets an existing -application/environment and explicitly disables bucket creation. No S3 access -is granted to non-Elastic-Beanstalk bucket names. The CloudFormation mutation -remains limited to the single existing `shoc-backend-dev` managed stack ARN; it -cannot create stacks or update another stack. - -The next rerun cleared S3 and then required the read-only -`elasticloadbalancing:DescribeLoadBalancers` discovery action. Its failed -managed-stack update also required `cloudformation:CancelUpdateStack`; the -cancel action is scoped to the same single stack ARN as `UpdateStack`. - -The subsequent rerun progressed into Auto Scaling and required -`autoscaling:DescribeLaunchConfigurations`. Because Elastic Beanstalk's -managed update workflow performs variable resource discovery, the role follows -the documented read-only discovery families for EC2, Elastic Load Balancing, -and Auto Scaling (`Describe*`). These grants expose metadata across the account -but do not authorize any mutation; write actions remain separately scoped. - -The pinned deployment action can return success after Elastic Beanstalk emits a -fatal deployment event. The following workflow step therefore verifies that -the exact immutable version label is active and healthy before smoke testing. -Any mismatch fails and invokes rollback. This guard prevents false success; it -does not make the unresolved OIDC deployment path release-ready. - -The GitHub `dev` environment is an external release control and must restrict -deployments to the `dev` branch. Required reviewers should be configured when -the repository plan supports environment reviewers. The workflow also checks -the exact branch before requesting an OIDC token. - -## Migration and recovery contract - -The deployment bundle applies pending EF Core migrations before the new -application starts. Migrations must therefore use an expand/contract sequence: - -- Expand changes must remain backward compatible with the previously deployed - application version. -- Destructive contract changes are deployed only after all application versions - relying on the old schema have been retired. -- A failed deployment restores the previous **application version only**. - Database schema is not downgraded, and schema rollback is not claimed. - -This contract preserves the usefulness of application-version recovery without -misrepresenting it as a tested database downgrade. diff --git a/infra/cdk/app.ts b/infra/cdk/app.ts deleted file mode 100644 index 2031dfc..0000000 --- a/infra/cdk/app.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as cdk from 'aws-cdk-lib'; -import { DeployDevStack } from './deploy-dev-stack.js'; - -const app = new cdk.App(); - -new DeployDevStack(app, 'shoc-backend-deploy-dev', { - env: { - account: '396287094661', - region: 'us-east-1', - }, - terminationProtection: true, - tags: { - Project: 'shoc-backend', - Environment: 'dev', - ManagedBy: 'cdk', - Component: 'deploy-role', - }, -}); - -app.synth(); diff --git a/infra/cdk/cdk.json b/infra/cdk/cdk.json deleted file mode 100644 index 9732ab2..0000000 --- a/infra/cdk/cdk.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "app": "node dist/app.js", - "versionReporting": false, - "context": { - "@aws-cdk/aws-iam:minimizePolicies": true, - "@aws-cdk/core:checkSecretUsage": true - } -} diff --git a/infra/cdk/deploy-dev-stack.ts b/infra/cdk/deploy-dev-stack.ts deleted file mode 100644 index 87bad48..0000000 --- a/infra/cdk/deploy-dev-stack.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as cdk from 'aws-cdk-lib'; -import * as iam from 'aws-cdk-lib/aws-iam'; -import { Construct } from 'constructs'; - -const ACCOUNT_ID = '396287094661'; -const REGION = 'us-east-1'; -const APPLICATION_NAME = 'shoc-backend'; -const ENVIRONMENT_NAME = 'shoc-backend-dev'; -const ENVIRONMENT_ID = 'e-hehnrqjjrt'; -const ENVIRONMENT_STACK_NAME = `awseb-${ENVIRONMENT_ID}-stack`; -const REPO = 'Sea-Haven-Industries/shoc-backend'; - -export class DeployDevStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { - super(scope, id, props); - - const manageGithubDeployRole = new cdk.CfnParameter( - this, - 'ManageGithubDeployRole', - { - type: 'String', - allowedValues: ['true', 'false'], - description: - 'Set true only before Terraform adoption. After ownership transfer, always reuse false.', - }, - ); - const manageGithubDeployRoleCondition = new cdk.CfnCondition( - this, - 'ManageGithubDeployRoleCondition', - { - expression: cdk.Fn.conditionEquals( - manageGithubDeployRole.valueAsString, - 'true', - ), - }, - ); - - const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; - const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; - const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; - - const deployRole = new iam.Role(this, 'GithubDeployRole', { - roleName: 'githubdeploy-shoc-backend-dev', - description: - 'Least-privilege GitHub OIDC deploy role for shoc-backend dev. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk.', - assumedBy: new iam.FederatedPrincipal( - oidcProviderArn, - { - StringEquals: { - 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', - 'token.actions.githubusercontent.com:sub': `repo:${REPO}:environment:dev`, - }, - }, - 'sts:AssumeRoleWithWebIdentity', - ), - }); - - deployRole.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - const cfnRole = deployRole.node.defaultChild as iam.CfnRole; - cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; - cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; - cfnRole.cfnOptions.condition = manageGithubDeployRoleCondition; - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'autoscaling:Describe*', - 'ec2:Describe*', - 'elasticbeanstalk:DescribeEnvironments', - 'elasticbeanstalk:DescribeApplicationVersions', - 'elasticbeanstalk:DescribeEvents', - 'elasticloadbalancing:Describe*', - ], - resources: ['*'], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['elasticbeanstalk:CreateApplicationVersion'], - resources: [ - applicationArn, - `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:applicationversion/${APPLICATION_NAME}/*`, - ], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['elasticbeanstalk:UpdateEnvironment'], - resources: [environmentArn], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'cloudformation:DescribeStackEvents', - 'cloudformation:DescribeStackResource', - 'cloudformation:GetTemplate', - 'cloudformation:DescribeStackResources', - 'cloudformation:DescribeStacks', - 'cloudformation:ListStackResources', - 'cloudformation:CancelUpdateStack', - 'cloudformation:UpdateStack', - ], - resources: [ - `arn:aws:cloudformation:${REGION}:${ACCOUNT_ID}:stack/${ENVIRONMENT_STACK_NAME}/*`, - ], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'autoscaling:PutNotificationConfiguration', - 'autoscaling:ResumeProcesses', - 'autoscaling:SuspendProcesses', - ], - resources: [ - `arn:aws:autoscaling:${REGION}:${ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/${ENVIRONMENT_STACK_NAME}-*`, - ], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['s3:Delete*', 's3:Get*', 's3:Put*'], - // AWS Support case 178526484500047 confirmed that UpdateEnvironment - // reads, writes, versions, ACL-checks, and removes objects in both the - // account bucket and AWS-owned Elastic Beanstalk service buckets. - resources: ['arn:aws:s3:::elasticbeanstalk-*/*'], - }), - ); - - deployRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 's3:GetBucket*', - 's3:ListBucket', - 's3:PutBucketOwnershipControls', - 's3:PutBucketPolicy', - 's3:PutBucketPublicAccessBlock', - ], - // This is AWS Support's bucket-level UpdateEnvironment set, excluding - // CreateBucket because the workflow deploys only to an existing - // application/environment and disables bucket creation. - resources: ['arn:aws:s3:::elasticbeanstalk-*'], - }), - ); - - const defaultPolicy = deployRole.node.findChild( - 'DefaultPolicy', - ) as iam.Policy; - defaultPolicy.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); - const cfnDefaultPolicy = defaultPolicy.node.defaultChild as iam.CfnPolicy; - cfnDefaultPolicy.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; - cfnDefaultPolicy.cfnOptions.updateReplacePolicy = - cdk.CfnDeletionPolicy.RETAIN; - cfnDefaultPolicy.cfnOptions.condition = manageGithubDeployRoleCondition; - - const githubDeployRoleArn = new cdk.CfnOutput( - this, - 'GithubDeployRoleArn', - { - value: deployRole.roleArn, - description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', - exportName: 'shoc-backend-deploy-dev-role-arn', - }, - ); - githubDeployRoleArn.condition = manageGithubDeployRoleCondition; - } -} diff --git a/infra/cdk/package-lock.json b/infra/cdk/package-lock.json deleted file mode 100644 index f09bb8f..0000000 --- a/infra/cdk/package-lock.json +++ /dev/null @@ -1,694 +0,0 @@ -{ - "name": "shoc-backend-cdk", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "shoc-backend-cdk", - "version": "0.1.0", - "dependencies": { - "aws-cdk-lib": "2.266.0", - "constructs": "10.8.1" - }, - "devDependencies": { - "@types/node": "26.2.0", - "aws-cdk": "2.1138.0", - "typescript": "7.0.2" - }, - "engines": { - "node": ">=22.22.1" - } - }, - "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.292", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.292.tgz", - "integrity": "sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.2.tgz", - "integrity": "sha512-pDiuqH+qY3zM9lhhLjbKJ1tnKOHzQ2V4Wr/3qsxyKeKAkuPMI/BVGvZG1PbrikUw949cGVTfVEt4ETKKYnrj0Q==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "54.14.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.14.0.tgz", - "integrity": "sha512-JCZCzgp3SuXQVljaKqXnttHzcezEHt9Ag/YipK0XwUFD+Iz2T4jY7gUc3pA25Uq6pzY2n9DvO/nEU++dPXW4Rw==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.5" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.8.5", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/aws-cdk": { - "version": "2.1138.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1138.0.tgz", - "integrity": "sha512-gZ5F8rmh+qc7ZNWsbaXYoV+p7jSYynRRg70s7FAn3VmzRaSkTE31ijpQHYroxCbDEtKSzgN63ORR/WuZmvXAwA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/aws-cdk-lib": { - "version": "2.266.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.266.0.tgz", - "integrity": "sha512-sBQU42pEc9ud3yeVU2En2euQRUhCg63eaJIPEVpBtE5aPhJjN3d9MkzQ9eYGLVXP3fnohUE54BiVOdUrkEDUbg==", - "bundleDependencies": [ - "@aws/cloudformation-validate", - "@balena/dockerignore", - "@aws-cdk/cloud-assembly-api", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "yaml", - "mime-types" - ], - "license": "Apache-2.0", - "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.292", - "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2", - "@aws-cdk/cloud-assembly-api": "^2.2.6", - "@aws-cdk/cloud-assembly-schema": "^54.11.0", - "@aws/cloudformation-validate": "1.7.0-beta", - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^11.3.6", - "ignore": "^5.3.2", - "jsonschema": "^1.5.0", - "mime-types": "^2.1.35", - "minimatch": "^10.2.5", - "punycode": "^2.3.1", - "semver": "^7.8.5", - "yaml": "1.10.3" - }, - "engines": { - "node": ">= 20.0.0" - }, - "peerDependencies": { - "constructs": "^10.5.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.6", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "^1.5.0", - "semver": "^7.8.4" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=54.5.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": { - "version": "1.7.0-beta", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "4.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.9", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.8.5", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/constructs": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.8.1.tgz", - "integrity": "sha512-98yGXYyhePqPYh3cYu8nzBERmAhC0DONe3UD03okK0nehZ7hYP4wgZuf02a04+uOWxnTJ5Rpp5m0GRNpwyLGGA==", - "license": "Apache-2.0" - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/infra/cdk/package.json b/infra/cdk/package.json deleted file mode 100644 index 7e07795..0000000 --- a/infra/cdk/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "shoc-backend-cdk", - "version": "0.1.0", - "private": true, - "description": "CDK ownership boundary for the shoc-backend dev deployment IAM role.", - "engines": { - "node": ">=22.22.1" - }, - "scripts": { - "build": "tsc", - "synth": "npm run build && cdk synth", - "diff": "npm run build && cdk diff", - "deploy": "npm run build && cdk deploy" - }, - "dependencies": { - "aws-cdk-lib": "2.266.0", - "constructs": "10.8.1" - }, - "devDependencies": { - "@types/node": "26.2.0", - "aws-cdk": "2.1138.0", - "typescript": "7.0.2" - } -} diff --git a/infra/cdk/tsconfig.json b/infra/cdk/tsconfig.json deleted file mode 100644 index ca87d8f..0000000 --- a/infra/cdk/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "lib": ["ES2022"], - "moduleResolution": "Node16", - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": false, - "sourceMap": true, - "outDir": "dist" - }, - "include": ["*.ts"], - "exclude": ["node_modules", "dist", "cdk.out"] -} diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py new file mode 100644 index 0000000..50ada68 --- /dev/null +++ b/scripts/check-terraform-release-plan.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Reject HCP Terraform plans that are not a version-only Elastic Beanstalk update. + +This script may read a local plan JSON file or download plan JSON from the +documented HashiCorp endpoint: + + GET https://app.terraform.io/api/v2/plans/:id/json-output + +The download follows exactly one redirect, and only to archivist.terraform.io. +It does not create, apply, discard, or poll runs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + + +RELEASE_ADDRESS = "module.environment.aws_elastic_beanstalk_environment.this" +API_HOST = "app.terraform.io" +ARCHIVE_HOST = "archivist.terraform.io" +PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$") +VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$") +IGNORED_ACTIONS = {"no-op", "read"} +UNSAFE_ACTIONS = {"create", "delete"} + +UrlOpen = Callable[..., Any] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument( + "plan_json", + type=Path, + nargs="?", + help="Local Terraform plan JSON. Mutually exclusive with --plan-id.", + ) + source.add_argument( + "--plan-id", + help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.", + ) + parser.add_argument( + "--expected-version-label", + required=True, + help="Immutable application version the plan must apply.", + ) + parser.add_argument( + "--evidence-out", + type=Path, + help="Write machine-readable proof after every assertion passes.", + ) + return parser.parse_args() + + +def download_plan_json( + plan_id: str, + token: str, + *, + urlopen: UrlOpen | None = None, +) -> dict[str, Any]: + if not PLAN_ID_RE.fullmatch(plan_id): + raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id") + if not token: + raise ValueError("TF_API_TOKEN is required to download plan JSON") + + opener = urlopen or urllib.request.urlopen + api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output" + request = urllib.request.Request( + api_url, + method="GET", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/json", + }, + ) + first = _open_pinned(opener, request, allowed_host=API_HOST) + try: + if first.status == 204: + raise ValueError( + "plan JSON is not ready; refusing to poll the plans endpoint" + ) + if first.status not in {301, 302, 303, 307, 308}: + raise ValueError( + f"expected a redirect from {API_HOST}, got HTTP {first.status}" + ) + location = first.headers.get("Location") + if not location: + raise ValueError(f"{API_HOST} redirect is missing a Location header") + archive = urlparse(location) + if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST: + raise ValueError( + "refusing redirect that is not https://" + f"{ARCHIVE_HOST}/" + ) + archive_request = urllib.request.Request(location, method="GET") + second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST) + try: + if second.status in {301, 302, 303, 307, 308}: + raise ValueError( + f"refusing a second redirect from {ARCHIVE_HOST}" + ) + if second.status != 200: + raise ValueError( + f"plan JSON download from {ARCHIVE_HOST} returned " + f"HTTP {second.status}" + ) + payload = second.read() + finally: + second.close() + finally: + first.close() + + plan = json.loads(payload.decode("utf-8")) + if not isinstance(plan, dict): + raise ValueError("plan JSON must be an object") + return plan + + +def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str): + parsed = urlparse(request.full_url) + if parsed.scheme != "https" or parsed.hostname != allowed_host: + raise ValueError( + f"refusing to contact {parsed.scheme}://{parsed.hostname} " + f"(pinned host is {allowed_host})" + ) + context = ssl.create_default_context() + try: + return urlopen(request, context=context, timeout=30) + except TypeError: + return urlopen(request, timeout=30) + + +def changed_attributes(change: dict[str, Any]) -> set[str]: + before = change.get("before") or {} + after = change.get("after") or {} + unknown = change.get("after_unknown") or {} + keys = set(before) | set(after) + changed: set[str] = set() + for key in keys: + unknown_value = unknown.get(key) + if unknown_value is True or ( + isinstance(unknown_value, (dict, list)) and unknown_value + ): + continue + if before.get(key) != after.get(key): + changed.add(key) + return changed + + +def validate_plan(plan: dict[str, Any], expected_label: str) -> list[str]: + violations: list[str] = [] + if not VERSION_LABEL_RE.fullmatch(expected_label): + violations.append( + "expected version label must be --" + ) + return violations + + updates: list[dict[str, Any]] = [] + for resource in plan.get("resource_changes", []): + if resource.get("mode", "managed") != "managed": + continue + address = resource.get("address", "") + change = resource.get("change") or {} + actions = list(change.get("actions") or []) + action_set = set(actions) + if action_set <= IGNORED_ACTIONS: + continue + + if change.get("importing"): + violations.append(f"{address}: import actions are not allowed") + + unsafe = sorted(action_set & UNSAFE_ACTIONS) + if unsafe: + violations.append(f"{address}: unsafe actions {unsafe}") + if "replace" in action_set or actions in ( + ["delete", "create"], + ["create", "delete"], + ): + violations.append(f"{address}: replacement is not allowed") + + if "update" in action_set: + updates.append(resource) + if action_set != {"update"}: + violations.append( + f"{address}: update must be the only action, got {actions}" + ) + + if address != RELEASE_ADDRESS and action_set - IGNORED_ACTIONS: + violations.append( + f"{address}: managed address is outside the version-only release" + ) + + if len(updates) != 1: + violations.append( + f"expected exactly one managed update, found {len(updates)}" + ) + return violations + + resource = updates[0] + address = resource.get("address", "") + if address != RELEASE_ADDRESS: + violations.append( + f"{address}: expected update address {RELEASE_ADDRESS}" + ) + return violations + + change = resource.get("change") or {} + changed = changed_attributes(change) + if changed != {"version_label"}: + violations.append( + f"{address}: expected only version_label to change, found " + f"{sorted(changed) if changed else 'no attribute changes'}" + ) + + after = change.get("after") or {} + actual = after.get("version_label") + if actual != expected_label: + violations.append( + f"{address}: after version_label {actual!r} does not match " + f"{expected_label!r}" + ) + + unknown = change.get("after_unknown") or {} + if unknown.get("version_label") is True: + violations.append(f"{address}: version_label after value is unknown") + + return violations + + +def main() -> int: + args = parse_args() + if args.plan_id: + try: + plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", "")) + except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc: + print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr) + return 1 + else: + if args.plan_json is None: + print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr) + return 1 + plan = json.loads(args.plan_json.read_text(encoding="utf-8")) + + violations = validate_plan(plan, args.expected_version_label) + if violations: + print("FAIL: Terraform plan is not a version-only release", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + if args.evidence_out: + evidence = { + "address": RELEASE_ADDRESS, + "expected_version_label": args.expected_version_label, + "managed_updates": 1, + "changed_attributes": ["version_label"], + "creates": 0, + "deletes": 0, + "replacements": 0, + } + args.evidence_out.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print( + "PASS: version-only plan updates " + f"{RELEASE_ADDRESS} version_label to {args.expected_version_label}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/governance-check.sh b/scripts/governance-check.sh index 503ef32..a3b8416 100755 --- a/scripts/governance-check.sh +++ b/scripts/governance-check.sh @@ -83,4 +83,8 @@ log "G10: Terraform import plan safety" python scripts/test-terraform-import-plan-check.py ok "G10: Terraform import plan safety" +log "G12: Terraform release plan safety" +python scripts/test-terraform-release-plan-check.py +ok "G12: Terraform release 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 99672fa..00c94aa 100755 --- a/scripts/package-elastic-beanstalk.sh +++ b/scripts/package-elastic-beanstalk.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # -# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source -# bundle for the shoc-backend .NET 8 application. +# package-elastic-beanstalk.sh — build a normalized Elastic Beanstalk source +# bundle for the shoc-backend .NET 8 application. Generated .NET/EF binaries +# are not guaranteed to be byte-reproducible between separate builds. # # Layout of the resulting ZIP (the Beanstalk application root): # ./ published Api.SeaHavenIndustries (self-contained, linux-x64) @@ -123,8 +124,8 @@ log "assemble source bundle (contents, not the containing directory)" if [[ "$ARCHIVER" == "zip" ]]; then ( cd "$STAGING_DIR" - # ZIP stores file mtimes. Normalize them so identical source/build inputs - # produce byte-identical source bundles. + # ZIP stores file mtimes. Normalize archive metadata; release immutability + # comes from uploading this one build under a unique version label. find . -type f -exec touch -t 198001010000 {} + find . -type f -print | LC_ALL=C sort \ | zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP" diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py new file mode 100644 index 0000000..0dac5f4 --- /dev/null +++ b/scripts/test-terraform-release-plan-check.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Deterministic tests for check-terraform-release-plan.py.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path +from urllib.request import Request + +SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py") +FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans" +EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" +PLAN_ID = "plan-8F5JFydVYAmtTjET" + + +def run_case( + fixture_name: str, + *, + expected_label: str = EXPECTED_LABEL, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + str(FIXTURES / fixture_name), + "--expected-version-label", + expected_label, + ], + check=False, + capture_output=True, + text=True, + ) + + +class FakeResponse: + def __init__( + self, + *, + url: str, + status: int, + headers: dict[str, str] | None = None, + body: bytes = b"", + ) -> None: + self.url = url + self.status = status + self.headers = headers or {} + self._body = body + + def read(self) -> bytes: + return self._body + + def close(self) -> None: + return None + + +def load_check_module(): + spec = importlib.util.spec_from_file_location("check_terraform_release_plan", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_download_pinning() -> list[str]: + module = load_check_module() + fixture = (FIXTURES / "version-only.json").read_bytes() + archive_url = "https://archivist.terraform.io/v1/object/example" + calls: list[str] = [] + + def fake_urlopen(request: Request, **_kwargs): + url = request.full_url + calls.append(url) + host = request.host if hasattr(request, "host") else "" + if url.startswith("https://app.terraform.io/api/v2/plans/"): + if request.get_header("Authorization") != "Bearer test-token": + raise AssertionError("API request is missing the bearer token") + if "/runs" in url or "/apply" in url or "/discard" in url: + raise AssertionError(f"download contacted a run-control path: {url}") + return FakeResponse( + url=url, + status=307, + headers={"Location": archive_url}, + ) + if url == archive_url: + if request.get_header("Authorization"): + raise AssertionError("archivist request must not send TF_API_TOKEN") + return FakeResponse(url=url, status=200, body=fixture) + raise AssertionError(f"unexpected URL {url} host={host}") + + plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen) + failures: list[str] = [] + if plan["resource_changes"][1]["address"] != ( + "module.environment.aws_elastic_beanstalk_environment.this" + ): + failures.append("download did not return the version-only fixture") + if calls != [ + f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output", + archive_url, + ]: + failures.append(f"download URLs were {calls}") + + try: + module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen) + failures.append("invalid plan id was accepted") + except ValueError: + pass + + def redirect_elsewhere(request: Request, **_kwargs): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://evil.example/plan.json"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere) + failures.append("redirect to a non-archivist host was accepted") + except ValueError: + pass + + def double_redirect(request: Request, **_kwargs): + if request.full_url.startswith("https://app.terraform.io/"): + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": archive_url}, + ) + return FakeResponse( + url=request.full_url, + status=307, + headers={"Location": "https://archivist.terraform.io/v1/object/other"}, + ) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect) + failures.append("second archivist redirect was accepted") + except ValueError: + pass + + def not_ready(request: Request, **_kwargs): + return FakeResponse(url=request.full_url, status=204) + + try: + module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready) + failures.append("HTTP 204 was polled or accepted") + except ValueError as exc: + if "poll" not in str(exc): + failures.append(f"HTTP 204 error was {exc}") + + source = SCRIPT.read_text(encoding="utf-8") + for banned in ("/apply", "/discard", "/runs"): + if banned in source: + failures.append(f"download client contains run-control path {banned}") + + return failures + + +def main() -> int: + cases = [ + ("version-only", run_case("version-only.json"), 0), + ("wrong-label", run_case("wrong-label.json"), 1), + ("eb-setting-change", run_case("eb-setting-change.json"), 1), + ("iam-update", run_case("iam-update.json"), 1), + ("dns-update", run_case("dns-update.json"), 1), + ("create", run_case("create.json"), 1), + ("delete", run_case("delete.json"), 1), + ("replace", run_case("replace.json"), 1), + ("multiple-updates", run_case("multiple-updates.json"), 1), + ("empty", run_case("empty.json"), 1), + ] + failures = [ + (name, result, expected) + for name, result, expected in cases + if result.returncode != expected + ] + download_failures = test_download_pinning() + if failures or download_failures: + if failures: + print( + "FAIL: release plan-check cases failed: " + + ", ".join(name for name, _, _ in failures), + file=sys.stderr, + ) + for name, result, expected in failures: + print( + f"{name}: expected {expected}, got {result.returncode}\n" + f"{result.stdout}{result.stderr}", + file=sys.stderr, + ) + for item in download_failures: + print(f"FAIL: {item}", file=sys.stderr) + return 1 + print("PASS: Terraform release plan safety checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/testdata/terraform-release-plans/create.json b/scripts/testdata/terraform-release-plans/create.json new file mode 100644 index 0000000..8ea3979 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/create.json @@ -0,0 +1,16 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["create"], + "before": null, + "after": { + "version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1" + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/delete.json b/scripts/testdata/terraform-release-plans/delete.json new file mode 100644 index 0000000..958c2f6 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/delete.json @@ -0,0 +1,16 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["delete"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1" + }, + "after": null + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/dns-update.json b/scripts/testdata/terraform-release-plans/dns-update.json new file mode 100644 index 0000000..5b2f27c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/dns-update.json @@ -0,0 +1,28 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_route53_record.api_alias[0]", + "mode": "managed", + "type": "aws_route53_record", + "change": { + "actions": ["update"], + "before": { + "alias": [ + { + "name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com", + "zone_id": "Z35SXDOTRQ7X7K" + } + ] + }, + "after": { + "alias": [ + { + "name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com", + "zone_id": "Z117KPS5GTRQ2G" + } + ] + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/eb-setting-change.json b/scripts/testdata/terraform-release-plans/eb-setting-change.json new file mode 100644 index 0000000..a0a2ecf --- /dev/null +++ b/scripts/testdata/terraform-release-plans/eb-setting-change.json @@ -0,0 +1,34 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["update"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "setting": [ + { + "namespace": "aws:elasticbeanstalk:application:environment", + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Production" + } + ], + "tags": { "env": "dev" } + }, + "after": { + "version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "setting": [ + { + "namespace": "aws:elasticbeanstalk:application:environment", + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Development" + } + ], + "tags": { "env": "dev" } + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/empty.json b/scripts/testdata/terraform-release-plans/empty.json new file mode 100644 index 0000000..360110a --- /dev/null +++ b/scripts/testdata/terraform-release-plans/empty.json @@ -0,0 +1,3 @@ +{ + "resource_changes": [] +} diff --git a/scripts/testdata/terraform-release-plans/iam-update.json b/scripts/testdata/terraform-release-plans/iam-update.json new file mode 100644 index 0000000..aee8aec --- /dev/null +++ b/scripts/testdata/terraform-release-plans/iam-update.json @@ -0,0 +1,18 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_iam_role.github_deploy", + "mode": "managed", + "type": "aws_iam_role", + "change": { + "actions": ["update"], + "before": { + "permissions_boundary": "arn:aws:iam::396287094661:policy/shoc-backend-dev-deploy-boundary" + }, + "after": { + "permissions_boundary": null + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/multiple-updates.json b/scripts/testdata/terraform-release-plans/multiple-updates.json new file mode 100644 index 0000000..a4c4e0c --- /dev/null +++ b/scripts/testdata/terraform-release-plans/multiple-updates.json @@ -0,0 +1,32 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["update"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "setting": [], + "tags": { "env": "dev" } + }, + "after": { + "version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "setting": [], + "tags": { "env": "dev" } + } + } + }, + { + "address": "module.environment.aws_iam_role.github_deploy", + "mode": "managed", + "type": "aws_iam_role", + "change": { + "actions": ["update"], + "before": { "description": "old" }, + "after": { "description": "new" } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/replace.json b/scripts/testdata/terraform-release-plans/replace.json new file mode 100644 index 0000000..73b8030 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/replace.json @@ -0,0 +1,20 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["delete", "create"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "name": "shoc-backend-dev" + }, + "after": { + "version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "name": "shoc-backend-dev" + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/version-only.json b/scripts/testdata/terraform-release-plans/version-only.json new file mode 100644 index 0000000..143a924 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/version-only.json @@ -0,0 +1,48 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_iam_role.runtime", + "mode": "managed", + "type": "aws_iam_role", + "change": { + "actions": ["no-op"], + "before": { "name": "shoc-backend-dev" }, + "after": { "name": "shoc-backend-dev" } + } + }, + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["update"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "setting": [ + { + "namespace": "aws:elasticbeanstalk:environment", + "name": "EnvironmentType", + "value": "LoadBalanced" + } + ], + "tags": { "env": "dev", "project": "shoc" } + }, + "after": { + "version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1", + "setting": [ + { + "namespace": "aws:elasticbeanstalk:environment", + "name": "EnvironmentType", + "value": "LoadBalanced" + } + ], + "tags": { "env": "dev", "project": "shoc" } + }, + "after_unknown": { + "instances": true, + "load_balancers": true + } + } + } + ] +} diff --git a/scripts/testdata/terraform-release-plans/wrong-label.json b/scripts/testdata/terraform-release-plans/wrong-label.json new file mode 100644 index 0000000..bd1c671 --- /dev/null +++ b/scripts/testdata/terraform-release-plans/wrong-label.json @@ -0,0 +1,22 @@ +{ + "resource_changes": [ + { + "address": "module.environment.aws_elastic_beanstalk_environment.this", + "mode": "managed", + "type": "aws_elastic_beanstalk_environment", + "change": { + "actions": ["update"], + "before": { + "version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1", + "setting": [], + "tags": { "env": "dev" } + }, + "after": { + "version_label": "cccccccccccccccccccccccccccccccccccccccc-9-9", + "setting": [], + "tags": { "env": "dev" } + } + } + } + ] +} diff --git a/scripts/validate-elastic-beanstalk-bundle.sh b/scripts/validate-elastic-beanstalk-bundle.sh new file mode 100755 index 0000000..139eb6b --- /dev/null +++ b/scripts/validate-elastic-beanstalk-bundle.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Validate the exact Elastic Beanstalk bundle that a release will upload. +set -euo pipefail + +BUNDLE="${1:-.artifacts/elastic-beanstalk/site.zip}" + +die() { + printf 'ERR %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$BUNDLE" ]] || die "bundle does not exist: $BUNDLE" +[[ "$BUNDLE" == *.zip ]] || die "bundle must be a .zip file" + +contents_file="$(mktemp)" +webhook_file="$(mktemp)" +trap 'rm -f "$contents_file" "$webhook_file"' EXIT + +unzip -tq "$BUNDLE" +unzip -Z1 "$BUNDLE" > "$contents_file" +grep -Fxq "efbundle" "$contents_file" +grep -Fxq ".ebextensions/01_migrations.config" "$contents_file" +grep -Fxq ".ebextensions/02_webhook_config.config" "$contents_file" + +unzip -p "$BUNDLE" .ebextensions/02_webhook_config.config > "$webhook_file" +grep -Fxq ' WorkOrderWebhook__Enabled: "true"' "$webhook_file" +grep -Fxq ' WorkOrderWebhook__Region: us-east-1' "$webhook_file" +grep -Fxq \ + ' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \ + "$webhook_file" + +printf 'PASS: Elastic Beanstalk bundle contract (%s bytes)\n' \ + "$(wc -c < "$BUNDLE" | tr -d ' ')" diff --git a/terraform/README.md b/terraform/README.md index d807250..7f81595 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -45,4 +45,5 @@ 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 +python scripts/test-terraform-release-plan-check.py ``` diff --git a/terraform/live/README.md b/terraform/live/README.md index c6e4880..f2ba645 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -113,24 +113,61 @@ tf-poc rehearsal has completed both phases and therefore pins plan contains no create, delete, or replacement action. The dev direct ALB alias remains pinned during this phase and must not update. -The same reviewed change prepares the legacy dev CDK stack for ownership -transfer. Before the Terraform apply, deploy `shoc-backend-deploy-dev` with -`ManageGithubDeployRole=true` so both the role and generated inline-policy -resource carry `Retain`. After Terraform succeeds and live verification passes, -deploy the same reviewed SHA with `ManageGithubDeployRole=false`. This removes -both resources from CloudFormation ownership without deleting them. Never use -`ManageGithubDeployRole=true` again after that transfer. +The dev deploy role and generated inline policy completed their retained +CloudFormation-to-Terraform transfer before the legacy backend CDK source was +removed. Do not reintroduce that ownership path. The reviewed `adoption_complete=true` change updates ownership tags on IAM -roles, instance profiles, and app-config secrets. Dev retains the proven GitHub -Elastic Beanstalk release policy until application CD is migrated in a separate -reviewed change; infrastructure adoption must not silently break the current -manual release path. Elastic Beanstalk environment tags remain at their imported -values. Terraform manages the declared EB settings. Secret values remain -out-of-band even after the secret shell receives `ManagedBy=terraform`. -Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain -unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not -support resource-level permissions. +roles, instance profiles, and app-config secrets. Elastic Beanstalk +environment tags remain at their imported values. Terraform manages the +declared EB settings. Secret values remain out-of-band even after the secret +shell receives `ManagedBy=terraform`. Deploy-role descriptions and immutable +`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain +`Resource = "*"` only where AWS does not support resource-level permissions. + +The measured self-contained .NET/EF bundle is approximately 199.5 MB and +separate builds are not byte-identical. Each deploy job therefore validates the +exact bundle it uploads; bundle bytes never enter Terraform plans or state. + +## Dev application CD + +GitHub compiles, validates, and uploads the bundle, then creates the immutable +Elastic Beanstalk application version. HCP Terraform is the only caller of +`UpdateEnvironment`, by setting `version_label` on +`module.environment.aws_elastic_beanstalk_environment.this`. GitHub then +health-checks, smokes, and requests one guarded Terraform rollback. Terraform +does not manage `aws_elastic_beanstalk_application_version`; retained versions +are the rollback inventory. + +`release_version_label` is a nullable root and module variable. Null VCS plans +leave the live version unchanged. Application-CD runs pass the immutable +`--` label only as a run-specific +`TF_VAR_release_version_label` HCL string. Do not set this variable on the +workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new +configuration version on application releases; `create-run` reuses the +workspace's last applied VCS config. Global auto-apply stays off. GitHub +applies only after `plan-output` counts are `0/1/0` and +`scripts/check-terraform-release-plan.py` accepts a version-only plan JSON. + +Staging keeps today's direct Elastic Beanstalk deploy path until staging +adoption. + +### Credentials and enablement + +Store a dedicated HCP team token only as the GitHub `dev` environment secret +`TF_API_TOKEN`. Scope it to workspace `shoc-backend-dev`. Plan JSON download +requires workspace admin on that one workspace. Do not grant project admin, +workspace create/move/delete, or staging access. Rotate at least every 90 days. + +Repository variable `TERRAFORM_APP_CD_ENABLED` starts unset/false so pushes to +`dev` do not deploy. `workflow_dispatch` on `dev` still runs a release for the +first manual proof. Set the variable to `true` only after that proof confirms +the exact version, a version-only plan, apply, `efbundle`, Ready/Green, smokes, +and a retained previous version. + +This change is the allowed exception that mixes deployable application CD with +the Terraform variable that application CD needs. Later PRs must not mix +deployable application changes with Terraform or CDK changes. ## POC retained identifiers diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index 64f2aeb..32985d7 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -66,6 +66,7 @@ module "environment" { github_deploy_role_name = "githubdeploy-shoc-backend-dev" github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1" legacy_dev_s3_policy = true + release_version_label = var.release_version_label hosted_zone_id = "Z07671212N75U4YLPWZR8" api_domain = local.api_domain api_record_type = "A" diff --git a/terraform/live/dev/variables.tf b/terraform/live/dev/variables.tf new file mode 100644 index 0000000..3f21d9b --- /dev/null +++ b/terraform/live/dev/variables.tf @@ -0,0 +1,15 @@ +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged." + + validation { + condition = ( + var.release_version_label == null || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be --." + } +} diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index 3a2522e..d53380c 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -458,11 +458,16 @@ locals { } 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 + # Null VCS plans omit this Optional+Computed argument, so the provider + # refreshes the live label without reverting releases. Application-CD runs + # pass an immutable -- value as a run-specific + # TF_VAR_release_version_label. + name = var.eb_environment_name + application = var.eb_application_name + platform_arn = var.platform_arn + version_label = var.release_version_label + tier = "WebServer" + cname_prefix = var.eb_environment_name dynamic "setting" { for_each = var.manage_eb_settings ? local.managed_eb_settings : [] diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index ecad6eb..6ed6aa4 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -195,6 +195,22 @@ variable "legacy_dev_s3_policy" { default = false } +variable "release_version_label" { + type = string + default = null + nullable = true + + description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged." + + validation { + condition = ( + var.release_version_label == null || + can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label)) + ) + error_message = "release_version_label must be --." + } +} + variable "hosted_zone_id" { type = string }