diff --git a/.github/workflows/deploy-tag.yaml b/.github/workflows/deploy-tag.yaml index 581fe97..00f9389 100644 --- a/.github/workflows/deploy-tag.yaml +++ b/.github/workflows/deploy-tag.yaml @@ -10,6 +10,8 @@ on: permissions: contents: read + checks: read + id-token: write jobs: target: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index ea2d3ef..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,1238 +0,0 @@ -name: Validate and deploy - -on: - pull_request: - branches: [dev, staging, main] - push: - branches: [dev, staging] - workflow_dispatch: - -permissions: - contents: read - -jobs: - validate: - name: Validate deployable source bundle - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: "8.0.x" - - - name: Repository quality gate - env: - BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/dev' }} - HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} - run: bash scripts/governance-check.sh - - - 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 - 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" - - - 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: Discard blocking VCS run before GitHub CD - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-dev" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/dev/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - 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 - continue-on-error: true - 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: Treat already-applied release run as success - env: - APPLY_OUTCOME: ${{ steps.release-apply.outcome }} - RUN_ID: ${{ steps.release-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - 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" ]; then - echo "Environment became Ready on version $current, not the expected $expected." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Expected application version is Ready and healthy." - exit 0 - fi - # The expected version IS active. Elastic Beanstalk reports Ready as - # soon as the rollout finishes, before enhanced health has converged, - # so deciding on the first Ready poll fails a good release on a health - # value that was always going to change. Keep polling; an environment - # that is genuinely unhealthy still fails when the window runs out. - echo "Expected version is active; waiting for health to leave $health." - fi - sleep 15 - done - - echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&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: Discard blocking VCS run before GitHub rollback - id: rollback-discard-vcs - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-dev" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/dev/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - name: Create Terraform rollback run - id: rollback-run - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' - 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' - continue-on-error: true - 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: Treat already-applied rollback run as success - id: rollback-apply-result - if: failure() && steps.rollback-apply.outcome != 'skipped' - env: - APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }} - RUN_ID: ${{ steps.rollback-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - name: Verify previous application version is active - if: failure() && steps.rollback-apply-result.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" ]; then - echo "Rollback reached Ready on version $current, not the previous $prev." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Application version restore complete; previous code is Ready and healthy." - exit 0 - fi - # Same convergence gap as the release check above: the previous version - # is back, health has not settled yet, and reporting a failed rollback - # here hides the fact that the restore itself worked. - echo "Previous version is active; waiting for health to leave $health." - fi - sleep 15 - done - echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 - - deploy-staging: - name: Deploy shoc-backend-staging through Terraform - if: > - (github.event_name == 'push' && github.ref == 'refs/heads/staging') || - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging') - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 180 - permissions: - contents: read - id-token: write - environment: - name: staging - concurrency: - group: deploy-staging - 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-staging - SMOKE_URL: https://api.staging.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 - 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" - - - 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/staging/${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: Discard blocking VCS run before GitHub CD - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-staging" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-staging auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/staging/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - 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-staging - 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 - continue-on-error: true - 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: Treat already-applied release run as success - env: - APPLY_OUTCOME: ${{ steps.release-apply.outcome }} - RUN_ID: ${{ steps.release-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - 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" ]; then - echo "Environment became Ready on version $current, not the expected $expected." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Expected application version is Ready and healthy." - exit 0 - fi - # The expected version IS active. Elastic Beanstalk reports Ready as - # soon as the rollout finishes, before enhanced health has converged, - # so deciding on the first Ready poll fails a good release on a health - # value that was always going to change. Keep polling; an environment - # that is genuinely unhealthy still fails when the window runs out. - echo "Expected version is active; waiting for health to leave $health." - fi - sleep 15 - done - - echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&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: Discard blocking VCS run before GitHub rollback - id: rollback-discard-vcs - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' - run: | - set -euo pipefail - python3 << 'PY' - import json, os, urllib.error, urllib.request - - token = os.environ["TF_API_TOKEN"] - workspace = "shoc-backend-staging" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - } - - def get(url): - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req) as resp: - return json.load(resp) - - def post(url, payload): - data = json.dumps(payload).encode() - req = urllib.request.Request( - url, data=data, method="POST", headers=headers - ) - try: - with urllib.request.urlopen(req) as resp: - return resp.status - except urllib.error.HTTPError as exc: - if exc.code in (409, 404): - body = exc.read().decode("utf-8", "replace") - print(f"discard returned HTTP {exc.code}: {body}") - return exc.code - raise - - ws = get( - f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}" - )["data"] - attrs = ws["attributes"] - if attrs.get("auto-apply") is True: - raise SystemExit("shoc-backend-staging auto-apply is on; refuse to continue") - if not attrs.get("speculative-enabled"): - raise SystemExit("speculative plans are off; refuse to continue") - if (attrs.get("vcs-repo") or {}).get("tags-regex"): - raise SystemExit("tag-based VCS triggering is set; refuse to continue") - expected_patterns = [ - "terraform/live/staging/**", - "terraform/live/modules/**", - ] - if attrs.get("trigger-patterns") != expected_patterns: - raise SystemExit( - "trigger-patterns must be " - f"{expected_patterns}; got {attrs.get('trigger-patterns')}" - ) - if not attrs.get("locked"): - print("workspace is unlocked") - raise SystemExit(0) - - current = ( - ws.get("relationships", {}) - .get("current-run", {}) - .get("data") - ) - if not current: - raise SystemExit("workspace is locked without a current run") - run_id = current["id"] - run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"] - run_attrs = run["attributes"] - status = run_attrs.get("status") - plan_only = run_attrs.get("plan-only") - print(f"current run {run_id} status={status} plan-only={plan_only}") - if plan_only: - print("speculative run does not block GitHub CD") - raise SystemExit(0) - if status in {"applying", "apply_queued"}: - raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply") - discardable = { - "pending", "planned", "cost_estimated", "policy_checked", "policy_override" - } - if status not in discardable: - raise SystemExit(f"{run_id} status {status} is not discardable") - code = post( - f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard", - {"comment": "Discarded so GitHub CD can create the version-only applyable run"}, - ) - print(f"discarded {run_id} http={code}") - PY - - - name: Create Terraform rollback run - id: rollback-run - if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success' - 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-staging - 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' - continue-on-error: true - 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: Treat already-applied rollback run as success - id: rollback-apply-result - if: failure() && steps.rollback-apply.outcome != 'skipped' - env: - APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }} - RUN_ID: ${{ steps.rollback-run.outputs.run_id }} - run: | - set -euo pipefail - if [ "$APPLY_OUTCOME" = "success" ]; then - echo "Apply succeeded." - exit 0 - fi - python3 << 'PY' - import json, os, urllib.request - run_id = os.environ["RUN_ID"] - token = os.environ["TF_API_TOKEN"] - req = urllib.request.Request( - f"https://app.terraform.io/api/v2/runs/{run_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/vnd.api+json", - }, - ) - with urllib.request.urlopen(req) as resp: - status = json.load(resp)["data"]["attributes"]["status"] - print(f"HCP run {run_id} status={status}") - if status == "applied": - raise SystemExit(0) - raise SystemExit( - f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} " - f"HCP status={status}" - ) - PY - - - name: Verify previous application version is active - if: failure() && steps.rollback-apply-result.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" ]; then - echo "Rollback reached Ready on version $current, not the previous $prev." >&2 - exit 1 - fi - if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then - echo "Application version restore complete; previous code is Ready and healthy." - exit 0 - fi - # Same convergence gap as the release check above: the previous version - # is back, health has not settled yet, and reporting a failed rollback - # here hides the fact that the restore itself worked. - echo "Previous version is active; waiting for health to leave $health." - fi - sleep 15 - done - echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2 - exit 1 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 777a11c..e5830a5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,6 +24,7 @@ on: permissions: contents: write checks: read + id-token: write jobs: cut: diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index 9d6609e..c1a83a6 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -73,8 +73,6 @@ remains in the matrix. HCP plan/apply roles stay in org-baseline; this repository never manages `hcptf-*` roles. The former G12 version-only HCP apply guard is not part of the repository gate. -`scripts/check-terraform-release-plan.py` remains only while -`.github/workflows/deploy.yml` is still present. G13 fails when the same diff contains both `terraform/` and deployable application files. Workflow, documentation, and gate-script changes may share diff --git a/scripts/check-terraform-release-plan.py b/scripts/check-terraform-release-plan.py deleted file mode 100644 index e0f8a88..0000000 --- a/scripts/check-terraform-release-plan.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/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"} -# Wholly unknown computed attributes may be ignored. Nested unknowns on any -# other attribute are treated as changes so the version-only guard fails closed. -COMPUTED_UNKNOWN_ATTRIBUTES = frozenset({"instances", "load_balancers"}) -REDIRECT_STATUSES = {301, 302, 303, 307, 308} - -UrlOpen = Callable[..., Any] - - -class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): - """Return the redirect response instead of following it.""" - - def http_error_301(self, req, fp, code, msg, headers): - return self._capture(req, fp, code, headers) - - http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301 - - @staticmethod - def _capture(req, fp, code, headers): - response = urllib.response.addinfourl(fp, headers, req.full_url, code=code) - response.msg = "Redirect" - return response - - -def _urlopen_without_redirects( - *handlers: urllib.request.BaseHandler, -) -> UrlOpen: - context = ssl.create_default_context() - opener = urllib.request.build_opener( - urllib.request.HTTPSHandler(context=context), - _NoRedirectHandler, - *handlers, - ) - return opener.open - - -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, - handlers: tuple[urllib.request.BaseHandler, ...] = (), -) -> 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 _urlopen_without_redirects(*handlers) - 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 REDIRECT_STATUSES: - 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 REDIRECT_STATUSES: - 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 _is_nested_unknown(value: Any) -> bool: - if isinstance(value, dict): - return any(item is True or _is_nested_unknown(item) for item in value.values()) - if isinstance(value, list): - return any(item is True or _is_nested_unknown(item) for item in value) - return False - - -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) | set(unknown) - changed: set[str] = set() - for key in keys: - unknown_value = unknown.get(key) - if unknown_value is True: - if key in COMPUTED_UNKNOWN_ATTRIBUTES: - continue - changed.add(key) - continue - if _is_nested_unknown(unknown_value): - changed.add(key) - 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/terraform_import_plan_resources.py b/scripts/terraform_import_plan_resources.py index dc66365..2feb00d 100644 --- a/scripts/terraform_import_plan_resources.py +++ b/scripts/terraform_import_plan_resources.py @@ -9,6 +9,10 @@ COMMON_RESOURCES = { "module.environment.aws_iam_role_policy.runtime_app_config": "aws_iam_role_policy", "module.environment.aws_iam_role_policy_attachment.web_tier": "aws_iam_role_policy_attachment", "module.environment.aws_secretsmanager_secret.app_config": "aws_secretsmanager_secret", + "module.environment.aws_ssm_parameter.deploy_application_name": "aws_ssm_parameter", + "module.environment.aws_ssm_parameter.deploy_artifacts_bucket": "aws_ssm_parameter", + "module.environment.aws_ssm_parameter.deploy_environment_name": "aws_ssm_parameter", + "module.environment.aws_ssm_parameter.deploy_smoke_url": "aws_ssm_parameter", } REQUIRED_RESOURCES = { @@ -52,6 +56,18 @@ DEV_IMPORT_IDS = { "module.environment.aws_route53_record.api_alias[0]": ( "Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A" ), + "module.environment.aws_ssm_parameter.deploy_application_name": ( + "/shoc-backend/dev/deploy/application-name" + ), + "module.environment.aws_ssm_parameter.deploy_artifacts_bucket": ( + "/shoc-backend/dev/deploy/artifacts-bucket" + ), + "module.environment.aws_ssm_parameter.deploy_environment_name": ( + "/shoc-backend/dev/deploy/environment-name" + ), + "module.environment.aws_ssm_parameter.deploy_smoke_url": ( + "/shoc-backend/dev/deploy/smoke-url" + ), } DEV_IMPORT_BASELINE = { @@ -92,6 +108,18 @@ STAGING_IMPORT_IDS = { "module.environment.aws_route53_record.api_cname[0]": ( "Z02602739VQWBWCAGXP4_api.staging.seahaven.com_CNAME" ), + "module.environment.aws_ssm_parameter.deploy_application_name": ( + "/shoc-backend/staging/deploy/application-name" + ), + "module.environment.aws_ssm_parameter.deploy_artifacts_bucket": ( + "/shoc-backend/staging/deploy/artifacts-bucket" + ), + "module.environment.aws_ssm_parameter.deploy_environment_name": ( + "/shoc-backend/staging/deploy/environment-name" + ), + "module.environment.aws_ssm_parameter.deploy_smoke_url": ( + "/shoc-backend/staging/deploy/smoke-url" + ), } STAGING_IMPORT_BASELINE = { diff --git a/scripts/test-terraform-release-plan-check.py b/scripts/test-terraform-release-plan-check.py deleted file mode 100644 index 5967d6f..0000000 --- a/scripts/test-terraform-release-plan-check.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -"""Deterministic tests for check-terraform-release-plan.py.""" - -from __future__ import annotations - -import importlib.util -import io -import subprocess -import sys -import urllib.request -from email.message import EmailMessage -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 _scripted_https_handler(fixture: bytes, archive_url: str): - calls: list[str] = [] - api_prefix = "https://app.terraform.io/api/v2/plans/" - - class ScriptedHTTPSHandler(urllib.request.BaseHandler): - handler_order = 100 - - def https_open(self, req: Request): - url = req.full_url - calls.append(url) - headers = EmailMessage() - if url.startswith(api_prefix): - headers["Location"] = archive_url - body = b"" - status = 307 - msg = "Temporary Redirect" - elif url == archive_url: - body = fixture - status = 200 - msg = "OK" - else: - raise AssertionError(f"unexpected URL {url}") - response = urllib.response.addinfourl( - io.BytesIO(body), - headers, - url, - code=status, - ) - response.msg = msg - return response - - return ScriptedHTTPSHandler(), calls - - -def test_download_standard_opener_redirect() -> list[str]: - """urllib follows the HCP 307; the guard must still inspect that first hop.""" - module = load_check_module() - fixture = (FIXTURES / "version-only.json").read_bytes() - archive_url = "https://archivist.terraform.io/v1/object/example" - api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output" - failures: list[str] = [] - - following_handler, following_calls = _scripted_https_handler(fixture, archive_url) - followed = urllib.request.build_opener(following_handler).open(api_url) - try: - if followed.status != 200: - failures.append( - f"standard opener first status was {followed.status}, not 200" - ) - if following_calls != [api_url, archive_url]: - failures.append(f"standard opener URLs were {following_calls}") - finally: - followed.close() - - guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url) - try: - plan = module.download_plan_json( - PLAN_ID, - "test-token", - handlers=(guard_handler,), - ) - except ValueError as exc: - failures.append(f"no-redirect download failed: {exc}") - return failures - - if plan["resource_changes"][1]["address"] != ( - "module.environment.aws_elastic_beanstalk_environment.this" - ): - failures.append("no-redirect download did not return the version-only fixture") - if guard_calls != [api_url, archive_url]: - failures.append(f"no-redirect download URLs were {guard_calls}") - - following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url) - following_urlopen = urllib.request.build_opener(following_urlopen_handler).open - try: - module.download_plan_json( - PLAN_ID, - "test-token", - urlopen=following_urlopen, - ) - failures.append("redirect-following urlopen was accepted as the first hop") - except ValueError as exc: - if "expected a redirect" not in str(exc): - failures.append(f"following urlopen error was {exc}") - - 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), - ("nested-unknown-tags", run_case("nested-unknown-tags.json"), 1), - ("unknown-only-description", run_case("unknown-only-description.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() - redirect_failures = test_download_standard_opener_redirect() - download_failures.extend(redirect_failures) - 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 deleted file mode 100644 index 8ea3979..0000000 --- a/scripts/testdata/terraform-release-plans/create.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 958c2f6..0000000 --- a/scripts/testdata/terraform-release-plans/delete.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 5b2f27c..0000000 --- a/scripts/testdata/terraform-release-plans/dns-update.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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 deleted file mode 100644 index a0a2ecf..0000000 --- a/scripts/testdata/terraform-release-plans/eb-setting-change.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 360110a..0000000 --- a/scripts/testdata/terraform-release-plans/empty.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "resource_changes": [] -} diff --git a/scripts/testdata/terraform-release-plans/iam-update.json b/scripts/testdata/terraform-release-plans/iam-update.json deleted file mode 100644 index aee8aec..0000000 --- a/scripts/testdata/terraform-release-plans/iam-update.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index a4c4e0c..0000000 --- a/scripts/testdata/terraform-release-plans/multiple-updates.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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/nested-unknown-tags.json b/scripts/testdata/terraform-release-plans/nested-unknown-tags.json deleted file mode 100644 index a9f2f43..0000000 --- a/scripts/testdata/terraform-release-plans/nested-unknown-tags.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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: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": "prod", "project": "shoc" } - }, - "after_unknown": { - "instances": true, - "load_balancers": true, - "tags": { "env": true } - } - } - } - ] -} diff --git a/scripts/testdata/terraform-release-plans/replace.json b/scripts/testdata/terraform-release-plans/replace.json deleted file mode 100644 index 73b8030..0000000 --- a/scripts/testdata/terraform-release-plans/replace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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/unknown-only-description.json b/scripts/testdata/terraform-release-plans/unknown-only-description.json deleted file mode 100644 index e1dd3bc..0000000 --- a/scripts/testdata/terraform-release-plans/unknown-only-description.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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: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, - "description": true - } - } - } - ] -} diff --git a/scripts/testdata/terraform-release-plans/version-only.json b/scripts/testdata/terraform-release-plans/version-only.json deleted file mode 100644 index 143a924..0000000 --- a/scripts/testdata/terraform-release-plans/version-only.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "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 deleted file mode 100644 index bd1c671..0000000 --- a/scripts/testdata/terraform-release-plans/wrong-label.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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/terraform/README.md b/terraform/README.md index d4d2023..d11938b 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -37,8 +37,8 @@ only explicitly allowlisted ownership metadata and the narrowed dev deploy S3 policy. The GitHub Environment **variable** `DEPLOY_ROLE_ARN` is the OIDC role used -by application CD after cutover. Adoption may still have the older -`AWS_DEPLOY_ROLE_ARN` secret until that cutover. +by application CD. Environment secrets `TF_API_TOKEN` and +`AWS_DEPLOY_ROLE_ARN` were removed at cutover. ## Local validation diff --git a/terraform/live/README.md b/terraform/live/README.md index a1bbdf4..e4af673 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -15,7 +15,10 @@ shared or Elastic Beanstalk-generated infrastructure. The shared `shoc-backend` Elastic Beanstalk application and `shoc-sqlserver-shared` RDS instance, VPC, subnets, EB service role, shared certificate, shared RDS security group, and EB-generated SG/ALB/ASG/CloudFormation -resources must never enter an environment state. +resources must never enter an environment state. Both roots leave +`instance_security_group_id` null: the AWS provider reports the EB-generated +`awseb-*-AWSEBSecurityGroup` as an empty `SecurityGroups` setting, so pinning it +produces a permanent update diff. Secret values are not Terraform resources, variables, outputs, or managed EB settings. Terraform manages the app-config secret shell and maps approved JSON @@ -132,7 +135,10 @@ exact bundle it uploads; bundle bytes never enter Terraform plans or state. GitHub Actions owns application versions. It compiles the bundle, uploads it, creates the Elastic Beanstalk application version, and calls `UpdateEnvironment`. Terraform ignores `version_label` so those deploys are not -drift. If health, smoke, or the webhook probe fails after that update, the job +drift. The non-legacy deploy policy writes bundles only under +`shoc-backend/releases//*`; the Elastic Beanstalk staging +prefixes (`resources/_runtime/_embedded_extensions/shoc-backend/*` and +`resources/environments//*`) keep the full object and ACL action set. If health, smoke, or the webhook probe fails after that update, the job restores the previous Elastic Beanstalk version label. Database migrations already applied by the failed bundle are not reverted. Deploy parameters are read from `/shoc-backend//deploy/*` SSM parameters this module writes. @@ -142,8 +148,9 @@ cut from **Actions → Release** (`environment`, `bump`, `message`). That workflow waits for CI, tags `vX.Y.Z-staging` from main HEAD with `GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave `PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment. -Staging remains `adoption_complete=false` with a pinned API CNAME until its -import apply is proven. +Staging import is proven after the first GitHub-owned zip +(`v0.0.1-staging`). Terraform now manages the declared Elastic Beanstalk +settings. The API CNAME stays pinned to the imported ALB target. HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative plans on every PR are the infra gate. Do not point `TFC_AWS_*` at @@ -153,10 +160,6 @@ Terraform changes stay in separate PRs so a merge cannot race an HCP apply against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only tags skip HCP when trigger patterns do not match. -Until cutover, `.github/workflows/deploy.yml` still uses `TF_API_TOKEN` and -`TERRAFORM_APP_CD_ENABLED`. Keep those secrets and the version-only plan guard -on that leftover path only. - ### Credentials Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`, @@ -164,23 +167,18 @@ Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`, `repo:Sea-Haven-Industries/shoc-backend:environment:` plus `job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main` and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM -change. After cutover, drop `TF_API_TOKEN` from GitHub Environments. The new -CD path does not use it. +change. The new CD path does not use `TF_API_TOKEN`. GitHub Environment deployment branch and tag policies are repository -settings, not this diff. Update them before the first merge to `main` and -the first staging cut. The policy matches `GITHUB_REF` of the workflow run. +settings, not this diff. The policy matches `GITHUB_REF` of the workflow run. Branch patterns never match tag refs; adding `v*` as a branch pattern fails the same way as an empty allowlist. -1. `dev` — allow branch `main`. Keep `dev` allowed while leftover - `.github/workflows/deploy.yml` still deploys from that branch. -2. `staging` — add a **tag-type** policy matching `v*.*.*-staging` for +1. `dev` — allow branch `main`. +2. `staging` — **tag-type** policy matching `v*.*.*-staging` for `deploy-tag.yaml`. Allow branch `main` because Actions → Release is `workflow_dispatch` on `main` and then calls `deploy.yaml` - (`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Keep - `staging` allowed while leftover `deploy.yml` still deploys from that - branch. + (`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`). Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED` unset. Until the `prod` environment exists with reviewers, do not run diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index cc01dc4..713f7fa 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -289,20 +289,62 @@ data "aws_iam_policy_document" "deploy" { } } + # deploy.yaml uploads each bundle to + # /releases////site.zip and Elastic Beanstalk + # reads it back from there. The deploy role never writes another + # environment's release prefix. dynamic "statement" { for_each = local.use_legacy_s3_policy ? [] : [1] content { - effect = "Allow" - actions = ["s3:PutObject"] - resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"] + sid = "UploadReleaseBundle" + effect = "Allow" + actions = [ + "s3:PutObject", + "s3:GetObject", + ] + resources = [ + "arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/releases/${var.environment}/*", + ] + } + } + + # Elastic Beanstalk stages the processed version, embedded extensions, + # and manifests under environment-scoped prefixes and requires object ACLs + # on this BucketOwnerPreferred bucket. + dynamic "statement" { + for_each = local.use_legacy_s3_policy ? [] : [1] + content { + sid = "ManageEnvironmentArtifacts" + effect = "Allow" + actions = [ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:PutObjectVersionAcl", + "s3:GetObject", + "s3:GetObjectAcl", + "s3:GetObjectVersion", + "s3:GetObjectVersionAcl", + "s3:DeleteObject", + ] + resources = [ + "arn:aws:s3:::${local.eb_bucket_name}/resources/_runtime/_embedded_extensions/${var.eb_application_name}/*", + "arn:aws:s3:::${local.eb_bucket_name}/resources/environments/${var.eb_environment_id}/*", + ] } } dynamic "statement" { for_each = local.use_legacy_s3_policy ? [] : [1] content { - effect = "Allow" - actions = ["s3:GetBucketLocation", "s3:ListBucket"] + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + "s3:GetBucketPolicy", + "s3:GetBucketAcl", + "s3:GetBucketVersioning", + "s3:GetBucketOwnershipControls", + ] resources = ["arn:aws:s3:::${local.eb_bucket_name}"] } } diff --git a/terraform/live/staging/main.tf b/terraform/live/staging/main.tf index 0368da2..1964ad5 100644 --- a/terraform/live/staging/main.tf +++ b/terraform/live/staging/main.tf @@ -26,8 +26,8 @@ module "environment" { aws_account_id = local.aws_account_id aws_region = local.aws_region environment = "staging" - adoption_complete = false - manage_eb_settings = false + adoption_complete = true + manage_eb_settings = true eb_application_name = local.eb_application_name eb_environment_name = local.eb_environment_name eb_environment_id = local.eb_environment_id @@ -35,7 +35,7 @@ module "environment" { vpc_id = "vpc-0d16336143f3da25e" instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"] load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"] - instance_security_group_id = "sg-02ea36a6719217fa2" + instance_security_group_id = null eb_service_role_name = "shoc-eb-service-role" shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" runtime_role_name = "shoc-backend-staging"