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/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/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/live/README.md b/terraform/live/README.md index a1bbdf4..e7630cd 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -143,7 +143,7 @@ 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. +import apply is proven after the first `vX.Y.Z-staging` GitHub-owned zip. 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 +153,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 +160,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