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