mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +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",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"enabledManagers": ["nuget", "npm", "github-actions", "terraform"],
|
"enabledManagers": ["nuget", "github-actions", "terraform"],
|
||||||
"minimumReleaseAge": "3 days",
|
"minimumReleaseAge": "3 days",
|
||||||
"internalChecksFilter": "strict",
|
"internalChecksFilter": "strict",
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
|
|
@ -56,12 +56,6 @@
|
||||||
"matchPackageNames": ["FluentValidation{/,}**"],
|
"matchPackageNames": ["FluentValidation{/,}**"],
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"groupName": "fluentvalidation"
|
"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
|
- name: Terraform import plan guard tests
|
||||||
run: python scripts/test-terraform-import-plan-check.py
|
run: python scripts/test-terraform-import-plan-check.py
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Terraform release plan guard tests
|
||||||
if: github.base_ref == 'dev'
|
run: python scripts/test-terraform-release-plan-check.py
|
||||||
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
|
|
||||||
|
|
|
||||||
6
.github/workflows/dependency-review.yml
vendored
6
.github/workflows/dependency-review.yml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
||||||
with:
|
|
||||||
allow-ghsas: GHSA-mh99-v99m-4gvg
|
|
||||||
|
|
|
||||||
410
.github/workflows/deploy.yml
vendored
410
.github/workflows/deploy.yml
vendored
|
|
@ -3,6 +3,8 @@ name: Validate and deploy
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [dev, staging, main]
|
branches: [dev, staging, main]
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -23,60 +25,374 @@ jobs:
|
||||||
with:
|
with:
|
||||||
dotnet-version: "8.0.x"
|
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
|
- name: Repository quality gate
|
||||||
run: bash scripts/governance-check.sh
|
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
|
- name: Build Elastic Beanstalk source bundle
|
||||||
run: bash scripts/package-elastic-beanstalk.sh
|
run: bash scripts/package-elastic-beanstalk.sh
|
||||||
|
|
||||||
- name: Inspect source bundle contract
|
- 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
unzip -t .artifacts/elastic-beanstalk/site.zip
|
prev="$(aws elasticbeanstalk describe-environments \
|
||||||
unzip -Z1 .artifacts/elastic-beanstalk/site.zip \
|
--environment-names "${EB_ENVIRONMENT_NAME}" \
|
||||||
> .artifacts/elastic-beanstalk/zip-contents.txt
|
--region us-east-1 \
|
||||||
grep -Fxq "efbundle" .artifacts/elastic-beanstalk/zip-contents.txt
|
--query 'Environments[0].VersionLabel' \
|
||||||
grep -Fxq ".ebextensions/01_migrations.config" \
|
--output text)"
|
||||||
.artifacts/elastic-beanstalk/zip-contents.txt
|
echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt
|
||||||
grep -Fxq ".ebextensions/02_webhook_config.config" \
|
echo "Previous version label: $prev"
|
||||||
.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
|
|
||||||
|
|
||||||
deploy:
|
- name: Assign immutable release identity
|
||||||
name: Deploy shoc-backend to Elastic Beanstalk
|
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: >
|
if: >
|
||||||
github.event_name == 'workflow_dispatch' &&
|
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging'
|
||||||
contains(fromJSON('["refs/heads/dev","refs/heads/staging"]'), github.ref)
|
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
environment:
|
environment:
|
||||||
name: ${{ github.ref_name }}
|
name: staging
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-${{ github.ref_name }}
|
group: deploy-staging
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -86,22 +402,9 @@ jobs:
|
||||||
id: target
|
id: target
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
case "${GITHUB_REF_NAME}" in
|
application=shoc-backend
|
||||||
dev)
|
environment=shoc-backend-staging
|
||||||
application=shoc-backend
|
smoke_url=https://api.staging.seahaven.com
|
||||||
environment=shoc-backend-dev
|
|
||||||
smoke_url=https://api.dev.seahaven.com
|
|
||||||
;;
|
|
||||||
staging)
|
|
||||||
application=shoc-backend
|
|
||||||
environment=shoc-backend-staging
|
|
||||||
smoke_url=https://api.staging.seahaven.com
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported ref ${GITHUB_REF_NAME}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
{
|
{
|
||||||
echo "application=${application}"
|
echo "application=${application}"
|
||||||
echo "environment=${environment}"
|
echo "environment=${environment}"
|
||||||
|
|
@ -121,6 +424,9 @@ jobs:
|
||||||
- name: Build Elastic Beanstalk source bundle
|
- name: Build Elastic Beanstalk source bundle
|
||||||
run: bash scripts/package-elastic-beanstalk.sh
|
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)
|
- name: Configure AWS credentials (OIDC)
|
||||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -366,12 +366,6 @@ FodyWeavers.xsd
|
||||||
appsettings.Development.json
|
appsettings.Development.json
|
||||||
.DS_Store
|
.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
|
# Deployment packaging artifacts
|
||||||
.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` |
|
| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` |
|
||||||
| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced |
|
| 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` |
|
| 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
|
## How to run locally
|
||||||
|
|
||||||
|
|
@ -49,6 +50,8 @@ The script:
|
||||||
5. runs the complete solution test suite in Release with no rebuild (G5).
|
5. runs the complete solution test suite in Release with no rebuild (G5).
|
||||||
6. verifies that the Terraform plan guard rejects create, delete, replacement,
|
6. verifies that the Terraform plan guard rejects create, delete, replacement,
|
||||||
unmanaged resource types, and updates not allowlisted by exact address (G10).
|
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
|
G10 permits only exact approved resource address/type pairs for the
|
||||||
environment-owned boundary: Elastic
|
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.
|
`--environment tf-poc`; an empty or incomplete environment plan fails.
|
||||||
|
|
||||||
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
|
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
|
||||||
and `terraform validate`. PRs to `dev` validate `live/tf-poc` and `live/dev`,
|
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
|
PRs to `staging` validate only `live/staging`. Org-baseline CloudFormation owns
|
||||||
`live/staging`. Org-baseline CloudFormation owns the HCP role substrate, so no
|
the HCP role substrate, and Terraform owns the dev deploy role, so no backend
|
||||||
backend bootstrap root remains in the matrix.
|
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)
|
## Migration gates (G6)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ Suppressions are single-diagnostic and cite the ADR — **no wildcard
|
||||||
suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity
|
suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity
|
||||||
sweeps, no `#pragma` swaths). See architecture §10.
|
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)
|
## 8. PR description contract (minimal)
|
||||||
|
|
||||||
- **Summary** — what changed and why, in plain language.
|
- **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
|
python scripts/test-terraform-import-plan-check.py
|
||||||
ok "G10: Terraform import plan safety"
|
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"
|
log "governance-check: all required repository gates passed"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source
|
# package-elastic-beanstalk.sh — build a normalized Elastic Beanstalk source
|
||||||
# bundle for the shoc-backend .NET 8 application.
|
# 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):
|
# Layout of the resulting ZIP (the Beanstalk application root):
|
||||||
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
|
||||||
|
|
@ -123,8 +124,8 @@ log "assemble source bundle (contents, not the containing directory)"
|
||||||
if [[ "$ARCHIVER" == "zip" ]]; then
|
if [[ "$ARCHIVER" == "zip" ]]; then
|
||||||
(
|
(
|
||||||
cd "$STAGING_DIR"
|
cd "$STAGING_DIR"
|
||||||
# ZIP stores file mtimes. Normalize them so identical source/build inputs
|
# ZIP stores file mtimes. Normalize archive metadata; release immutability
|
||||||
# produce byte-identical source bundles.
|
# comes from uploading this one build under a unique version label.
|
||||||
find . -type f -exec touch -t 198001010000 {} +
|
find . -type f -exec touch -t 198001010000 {} +
|
||||||
find . -type f -print | LC_ALL=C sort \
|
find . -type f -print | LC_ALL=C sort \
|
||||||
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"
|
| 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 init -backend=false
|
||||||
terraform -chdir=terraform/live/tf-poc validate
|
terraform -chdir=terraform/live/tf-poc validate
|
||||||
python scripts/test-terraform-import-plan-check.py
|
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
|
plan contains no create, delete, or replacement action. The dev direct ALB
|
||||||
alias remains pinned during this phase and must not update.
|
alias remains pinned during this phase and must not update.
|
||||||
|
|
||||||
The same reviewed change prepares the legacy dev CDK stack for ownership
|
The dev deploy role and generated inline policy completed their retained
|
||||||
transfer. Before the Terraform apply, deploy `shoc-backend-deploy-dev` with
|
CloudFormation-to-Terraform transfer before the legacy backend CDK source was
|
||||||
`ManageGithubDeployRole=true` so both the role and generated inline-policy
|
removed. Do not reintroduce that ownership path.
|
||||||
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 reviewed `adoption_complete=true` change updates ownership tags on IAM
|
The reviewed `adoption_complete=true` change updates ownership tags on IAM
|
||||||
roles, instance profiles, and app-config secrets. Dev retains the proven GitHub
|
roles, instance profiles, and app-config secrets. Elastic Beanstalk
|
||||||
Elastic Beanstalk release policy until application CD is migrated in a separate
|
environment tags remain at their imported values. Terraform manages the
|
||||||
reviewed change; infrastructure adoption must not silently break the current
|
declared EB settings. Secret values remain out-of-band even after the secret
|
||||||
manual release path. Elastic Beanstalk environment tags remain at their imported
|
shell receives `ManagedBy=terraform`. Deploy-role descriptions and immutable
|
||||||
values. Terraform manages the declared EB settings. Secret values remain
|
`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain
|
||||||
out-of-band even after the secret shell receives `ManagedBy=terraform`.
|
`Resource = "*"` only where AWS does not support resource-level permissions.
|
||||||
Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain
|
|
||||||
unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not
|
The measured self-contained .NET/EF bundle is approximately 199.5 MB and
|
||||||
support resource-level permissions.
|
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
|
## POC retained identifiers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ module "environment" {
|
||||||
vpc_id = "vpc-0d16336143f3da25e"
|
vpc_id = "vpc-0d16336143f3da25e"
|
||||||
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
|
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
|
||||||
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
|
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"
|
eb_service_role_name = "shoc-eb-service-role"
|
||||||
shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||||
runtime_role_name = "shoc-backend-dev"
|
runtime_role_name = "shoc-backend-dev"
|
||||||
|
|
@ -66,6 +66,7 @@ module "environment" {
|
||||||
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
|
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
|
||||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||||
legacy_dev_s3_policy = true
|
legacy_dev_s3_policy = true
|
||||||
|
release_version_label = var.release_version_label
|
||||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||||
api_domain = local.api_domain
|
api_domain = local.api_domain
|
||||||
api_record_type = "A"
|
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" {
|
resource "aws_elastic_beanstalk_environment" "this" {
|
||||||
name = var.eb_environment_name
|
# Null VCS plans omit this Optional+Computed argument, so the provider
|
||||||
application = var.eb_application_name
|
# refreshes the live label without reverting releases. Application-CD runs
|
||||||
platform_arn = var.platform_arn
|
# pass an immutable <full-sha>-<run-id>-<attempt> value as a run-specific
|
||||||
tier = "WebServer"
|
# TF_VAR_release_version_label.
|
||||||
cname_prefix = var.eb_environment_name
|
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" {
|
dynamic "setting" {
|
||||||
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
|
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,22 @@ variable "legacy_dev_s3_policy" {
|
||||||
default = false
|
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" {
|
variable "hosted_zone_id" {
|
||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue