Merge branch 'dev' into feat/sh-169-board-create-lifecycle

This commit is contained in:
Arthur Bassi 2026-09-04 10:24:52 -03:00 • committed by GitHub
commit 31b3c2bbd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1575 additions and 1758 deletions

10
.github/renovate.json vendored
View file

@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["nuget", "npm", "github-actions", "terraform"],
"enabledManagers": ["nuget", "github-actions", "terraform"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"packageRules": [
@ -56,12 +56,6 @@
"matchPackageNames": ["FluentValidation{/,}**"],
"matchUpdateTypes": ["major"],
"groupName": "fluentvalidation"
},
{
"description": ["Keep aws-cdk and aws-cdk-lib together"],
"matchPackageNames": ["aws-cdk", "aws-cdk-lib"],
"matchUpdateTypes": ["major"],
"groupName": "aws cdk"
}
]
}
}

View file

@ -45,7 +45,7 @@ jobs:
directories=()
case "${{ github.base_ref }}" in
dev)
directories+=(terraform/live/tf-poc terraform/live/dev)
directories+=(terraform/live/dev)
;;
staging)
directories+=(terraform/live/staging)
@ -61,15 +61,5 @@ jobs:
- name: Terraform import plan guard tests
run: python scripts/test-terraform-import-plan-check.py
- name: Set up Node.js
if: github.base_ref == 'dev'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
- name: Validate CDK deployment infrastructure
if: github.base_ref == 'dev'
working-directory: infra/cdk
run: |
npm ci
npm run synth
- name: Terraform release plan guard tests
run: python scripts/test-terraform-release-plan-check.py

View file

@ -1,8 +1,8 @@
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main
with:
allow-ghsas: GHSA-mh99-v99m-4gvg
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10

View file

@ -3,6 +3,8 @@ name: Validate and deploy
on:
pull_request:
branches: [dev, staging, main]
push:
branches: [dev]
workflow_dispatch:
permissions:
@ -23,60 +25,374 @@ jobs:
with:
dotnet-version: "8.0.x"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22.22.1"
cache: npm
cache-dependency-path: infra/cdk/package-lock.json
- name: Repository quality gate
run: bash scripts/governance-check.sh
- name: Validate CDK deployment infrastructure
run: |
npm ci --prefix infra/cdk
npm run synth --prefix infra/cdk
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
- name: Inspect source bundle contract
run: bash scripts/validate-elastic-beanstalk-bundle.sh
deploy-dev:
name: Deploy shoc-backend-dev through Terraform
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
vars.TERRAFORM_APP_CD_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
contents: read
id-token: write
environment:
name: dev
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
TF_CLOUD_ORGANIZATION: seahaven
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
EB_APPLICATION_NAME: shoc-backend
EB_ENVIRONMENT_NAME: shoc-backend-dev
SMOKE_URL: https://api.dev.seahaven.com
EB_BUCKET: elasticbeanstalk-us-east-1-396287094661
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "8.0.x"
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
- name: Validate exact release bundle
run: bash scripts/validate-elastic-beanstalk-bundle.sh
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Capture current environment version
run: |
set -euo pipefail
unzip -t .artifacts/elastic-beanstalk/site.zip
unzip -Z1 .artifacts/elastic-beanstalk/site.zip \
> .artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq "efbundle" .artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq ".ebextensions/01_migrations.config" \
.artifacts/elastic-beanstalk/zip-contents.txt
grep -Fxq ".ebextensions/02_webhook_config.config" \
.artifacts/elastic-beanstalk/zip-contents.txt
unzip -p .artifacts/elastic-beanstalk/site.zip \
.ebextensions/02_webhook_config.config \
> .artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq ' WorkOrderWebhook__Enabled: "true"' \
.artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq ' WorkOrderWebhook__Region: us-east-1' \
.artifacts/elastic-beanstalk/webhook-config.txt
grep -Fxq \
' WorkOrderWebhook__SecretId: arn:aws:secretsmanager:us-east-1:011934824531:secret:workorder-ingest/shoc-webhook-hmac-puYTcB' \
.artifacts/elastic-beanstalk/webhook-config.txt
prev="$(aws elasticbeanstalk describe-environments \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].VersionLabel' \
--output text)"
echo "$prev" > .artifacts/elastic-beanstalk/previous-version.txt
echo "Previous version label: $prev"
deploy:
name: Deploy shoc-backend to Elastic Beanstalk
- name: Assign immutable release identity
id: release
run: |
set -euo pipefail
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
s3_key="shoc-backend/releases/dev/${GITHUB_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip"
{
echo "version_label=${version_label}"
echo "s3_key=${s3_key}"
} >> "${GITHUB_OUTPUT}"
- name: Upload immutable bundle
run: |
set -euo pipefail
aws s3 cp .artifacts/elastic-beanstalk/site.zip \
"s3://${EB_BUCKET}/${{ steps.release.outputs.s3_key }}" \
--region us-east-1
- name: Create Elastic Beanstalk application version
run: |
set -euo pipefail
aws elasticbeanstalk create-application-version \
--application-name "${EB_APPLICATION_NAME}" \
--version-label "${{ steps.release.outputs.version_label }}" \
--description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}" \
--source-bundle "S3Bucket=${EB_BUCKET},S3Key=${{ steps.release.outputs.s3_key }}" \
--process \
--region us-east-1
status="UNPROCESSED"
for _ in $(seq 1 36); do
status="$(aws elasticbeanstalk describe-application-versions \
--application-name "${EB_APPLICATION_NAME}" \
--version-labels "${{ steps.release.outputs.version_label }}" \
--region us-east-1 \
--query 'ApplicationVersions[0].Status' \
--output text)"
echo "application version status: $status"
if [ "$status" = "PROCESSED" ]; then
exit 0
fi
if [ "$status" = "FAILED" ]; then
echo "Elastic Beanstalk failed to process ${{ steps.release.outputs.version_label }}." >&2
exit 1
fi
sleep 5
done
echo "Application version did not become PROCESSED." >&2
exit 1
- name: Create Terraform release run
id: release-run
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
with:
workspace: shoc-backend-dev
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
- name: Read Terraform release plan counts
id: release-plan
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.release-run.outputs.plan_id }}
- name: Reject non-version-only resource counts
env:
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2
exit 1
fi
- name: Guard version-only Terraform plan
run: |
set -euo pipefail
python scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.release.outputs.version_label }}"
- name: Discard release run when the guard fails
if: failure() && steps.release-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Rejected by the version-only plan guard from GitHub Actions
- name: Apply Terraform release run
id: release-apply
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Apply version-only release from GitHub Actions ${{ github.sha }}
- name: Verify exact application version is active
run: |
set -euo pipefail
expected="${{ steps.release.outputs.version_label }}"
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$expected" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
echo "Expected application version is Ready and healthy."
exit 0
fi
echo "Environment became Ready without activating expected version $expected." >&2
exit 1
fi
sleep 15
done
echo "Expected application version did not become Ready within the deployment window." >&2
exit 1
- name: Post-deploy smoke
run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}"
- name: Verify webhook secret source is operational
run: |
set -euo pipefail
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
status="$(curl --silent --show-error \
--output "$response_file" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header "X-SH-Timestamp: $(date +%s)" \
--header 'X-SH-Key-Id: deployment-smoke-invalid-key' \
--header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \
--data '{}' \
"${SMOKE_URL}/api/webhooks/work-orders")"
if [ "$status" != "401" ]; then
echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2
sed -n '1,20p' "$response_file" >&2
exit 1
fi
- name: Restore previous application version on failure (schema is not reverted)
if: failure()
run: |
set -euo pipefail
prev_file=".artifacts/elastic-beanstalk/previous-version.txt"
if [ ! -f "$prev_file" ]; then
echo "No previous version captured; nothing to roll back." >&2
exit 0
fi
prev="$(cat "$prev_file")"
if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then
echo "No previous version recorded; nothing to roll back." >&2
exit 0
fi
echo "Waiting for any in-flight environment update to settle..."
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
break
fi
sleep 15
done
if [ "$status" != "Ready" ]; then
echo "Environment did not settle before rollback." >&2
exit 1
fi
if [ "$current" = "$prev" ]; then
echo "Environment is already on previous version $prev."
exit 0
fi
if [[ ! "$prev" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
echo "Previous version $prev is not a Terraform-managed release label; cannot roll back through HCP." >&2
exit 1
fi
echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}"
id: rollback-prepare
- name: Create Terraform rollback run
id: rollback-run
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
with:
workspace: shoc-backend-dev
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
- name: Read Terraform rollback plan counts
id: rollback-plan
if: failure() && steps.rollback-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.rollback-run.outputs.plan_id }}
- name: Reject non-version-only rollback counts
id: rollback-count-guard
if: failure() && steps.rollback-plan.outcome == 'success'
env:
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "1" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/1/0." >&2
exit 1
fi
- name: Guard version-only Terraform rollback plan
id: rollback-json-guard
if: failure() && steps.rollback-count-guard.outcome == 'success'
run: |
set -euo pipefail
python scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}"
- name: Discard rollback run when the guard fails
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Rejected by the version-only rollback plan guard from GitHub Actions
- name: Apply Terraform rollback run
id: rollback-apply
if: failure() && steps.rollback-json-guard.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Apply version-only rollback from GitHub Actions ${{ github.sha }}
- name: Verify previous application version is active
if: failure() && steps.rollback-apply.outcome == 'success'
run: |
set -euo pipefail
prev="${{ steps.rollback-prepare.outputs.rollback_label }}"
echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy."
status="Unknown"
current="Unknown"
health="Unknown"
for _ in $(seq 1 80); do
read -r status current health < <(
aws elasticbeanstalk describe-environments \
--environment-names "${EB_ENVIRONMENT_NAME}" \
--region us-east-1 \
--query 'Environments[0].[Status,VersionLabel,Health]' \
--output text
)
echo "environment status: $status; version: $current; health: $health"
if [ "$status" = "Ready" ]; then
if [ "$current" = "$prev" ] && { [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; }; then
echo "Application version restore complete; previous code is Ready and healthy."
exit 0
fi
echo "Rollback reached Ready in an unexpected version/health state." >&2
exit 1
fi
sleep 15
done
echo "Environment did not return to Ready within rollback window." >&2
exit 1
deploy-staging:
name: Deploy shoc-backend-staging to Elastic Beanstalk
if: >
github.event_name == 'workflow_dispatch' &&
contains(fromJSON('["refs/heads/dev","refs/heads/staging"]'), github.ref)
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/staging'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: ${{ github.ref_name }}
name: staging
concurrency:
group: deploy-${{ github.ref_name }}
group: deploy-staging
cancel-in-progress: false
steps:
- name: Checkout
@ -86,22 +402,9 @@ jobs:
id: target
run: |
set -euo pipefail
case "${GITHUB_REF_NAME}" in
dev)
application=shoc-backend
environment=shoc-backend-dev
smoke_url=https://api.dev.seahaven.com
;;
staging)
application=shoc-backend
environment=shoc-backend-staging
smoke_url=https://api.staging.seahaven.com
;;
*)
echo "Unsupported ref ${GITHUB_REF_NAME}" >&2
exit 1
;;
esac
application=shoc-backend
environment=shoc-backend-staging
smoke_url=https://api.staging.seahaven.com
{
echo "application=${application}"
echo "environment=${environment}"
@ -121,6 +424,9 @@ jobs:
- name: Build Elastic Beanstalk source bundle
run: bash scripts/package-elastic-beanstalk.sh
- name: Validate exact release bundle
run: bash scripts/validate-elastic-beanstalk-bundle.sh
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:

6
.gitignore vendored
View file

@ -366,12 +366,6 @@ FodyWeavers.xsd
appsettings.Development.json
.DS_Store
# CDK (infra/cdk) generated artifacts
infra/cdk/node_modules/
infra/cdk/dist/
infra/cdk/cdk.out/
infra/cdk/.cdk.staging/
# Deployment packaging artifacts
.artifacts/

View file

@ -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"
}
]
}

View file

@ -25,7 +25,8 @@
| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` |
| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced |
| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` |
| G11 | Terraform/CDK static validation | import configuration integrity | commands below | `ci` on the matching PR base |
| G11 | Terraform static validation | import configuration integrity | commands below | `ci` on the matching PR base |
| G12 | Terraform release plan safety | dev application CD version_label | `python scripts/test-terraform-release-plan-check.py` | `architecture-quality` → `governance-check.sh` |
## How to run locally
@ -49,21 +50,30 @@ The script:
5. runs the complete solution test suite in Release with no rebuild (G5).
6. verifies that the Terraform plan guard rejects create, delete, replacement,
unmanaged resource types, and updates not allowlisted by exact address (G10).
7. verifies that the release plan guard accepts only a version-only update of
`module.environment.aws_elastic_beanstalk_environment.this` (G12).
G10 permits only exact approved resource address/type pairs for the
environment-owned boundary: Elastic
Beanstalk environment, IAM role/inline policy/managed-policy attachment/
instance profile, Secrets Manager secret metadata, Route 53 zone/record, and
ACM certificate. Initial mode permits no update. Controlled mode requires one
instance profile, Secrets Manager secret metadata, and Route 53 record.
Initial mode permits no update. Controlled mode requires one
`--allow-update-address` argument per reviewed in-place update. Every invocation
also requires `--environment dev`, `--environment staging`, or
`--environment tf-poc`; an empty or incomplete environment plan fails.
also requires `--environment dev` or `--environment staging`; an empty or
incomplete environment plan fails.
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
and `terraform validate`. PRs to `dev` validate `live/tf-poc` and `live/dev`,
plus `npm ci && npm run synth` in `infra/cdk`. PRs to `staging` validate only
`live/staging`. Org-baseline CloudFormation owns the HCP role substrate, so no
backend bootstrap root remains in the matrix.
and `terraform validate`. PRs to `dev` validate `live/dev`.
PRs to `staging` validate only `live/staging`. Org-baseline CloudFormation owns
the HCP role substrate, and Terraform owns the dev deploy role, so no backend
CDK or bootstrap root remains in the matrix.
G12 accepts only a local or downloaded plan JSON whose sole managed update is
`module.environment.aws_elastic_beanstalk_environment.this` with
`version_label` as the only changed attribute. Counts of `0` add / `1` change /
`0` destroy are not a substitute. The optional download uses
`GET /api/v2/plans/:id/json-output` on `app.terraform.io` with one redirect to
`archivist.terraform.io` and does not create, apply, discard, or poll runs.
## Migration gates (G6)

View file

@ -109,6 +109,10 @@ Suppressions are single-diagnostic and cite the ADR — **no wildcard
suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity
sweeps, no `#pragma` swaths). See architecture §10.
Do not mix deployable application changes with Terraform or CDK changes. The
first Terraform-owned application-CD change is the allowed exception because it
introduces `release_version_label`. Later PRs must keep those diffs separate.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.

View file

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
@ -32,6 +33,10 @@ namespace SeaHaven.DataServices.Implementation
(w.ScheduledDate != null
&& w.ScheduledDate >= weekStartDate
&& w.ScheduledDate < weekEndExclusive)
|| (w.ScheduledDate != null
&& w.ScheduledDate < weekStartDate
&& w.LifecycleStatus != LifecycleStatus.Completed
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
var scheduledTotal = await scheduledBase.CountAsync(cancellationToken);
@ -46,7 +51,8 @@ namespace SeaHaven.DataServices.Implementation
isUnscheduled: false,
cancellationToken);
// Weekly board is scheduled-in-week only. Undated rows belong on GET /board/search.
// Weekly board is in-week scheduled rows plus unresolved prior-week carry-over.
// Undated rows belong on GET /board/search.
return new WorkOrderBoardQueryResult(
scheduledRows,
Array.Empty<WorkOrderBoardRawRow>(),

View file

@ -117,11 +117,77 @@ public class WorkOrderBoardDataServiceTests
weekB, weekB.AddDays(4), null, false, null, false, null, null));
Assert.Equal(1, resultA.ScheduledRows[0].Id);
Assert.Equal(2, resultB.ScheduledRows[0].Id);
Assert.Equal(2, resultB.ScheduledRows.Count);
Assert.Contains(resultB.ScheduledRows, r => r.Id == 1);
Assert.Contains(resultB.ScheduledRows, r => r.Id == 2);
Assert.Empty(resultA.UnscheduledRows);
Assert.Empty(resultB.UnscheduledRows);
Assert.Equal(1, resultA.ScheduledTotalBeforeSearch);
Assert.Equal(1, resultB.ScheduledTotalBeforeSearch);
Assert.Equal(2, resultB.ScheduledTotalBeforeSearch);
}
[Fact]
public async Task GetBoardRows_IncludesPriorWeekUnresolvedAndKeepsScheduledDate()
{
await using var context = CreateContext();
var viewedWeek = new DateOnly(2026, 5, 18);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 5, 13),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 2,
OriginalDate = new DateOnly(2026, 5, 13),
OriginalWeek = new DateOnly(2026, 5, 11)
},
new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
ScheduledDate = new DateTime(2026, 5, 13),
LifecycleStatus = LifecycleStatus.Completed
},
new WorkOrder
{
Id = 3,
InternalWONumber = "10000000003",
ScheduledDate = new DateTime(2026, 5, 13),
LifecycleStatus = LifecycleStatus.Canceled
},
new WorkOrder
{
Id = 4,
InternalWONumber = "10000000004",
ScheduledDate = new DateTime(2026, 5, 20),
LifecycleStatus = LifecycleStatus.Scheduled
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
viewedWeek,
viewedWeek.AddDays(4),
null,
false,
null,
false,
null,
null));
Assert.Equal(2, result.ScheduledRows.Count);
var carried = Assert.Single(result.ScheduledRows, r => r.Id == 1);
Assert.Equal(new DateTime(2026, 5, 13), carried.ScheduledDate);
Assert.Equal(2, carried.CarriedOver);
Assert.Equal(new DateOnly(2026, 5, 11), carried.OriginalWeek);
Assert.Equal(new DateOnly(2026, 5, 13), carried.OriginalDate);
Assert.Contains(result.ScheduledRows, r => r.Id == 4);
Assert.DoesNotContain(result.ScheduledRows, r => r.Id == 2);
Assert.DoesNotContain(result.ScheduledRows, r => r.Id == 3);
Assert.Equal(2, result.ScheduledTotalBeforeSearch);
}
[Fact]

View file

@ -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.

View file

@ -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();

View file

@ -1,8 +0,0 @@
{
"app": "node dist/app.js",
"versionReporting": false,
"context": {
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:checkSecretUsage": true
}
}

View file

@ -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;
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}

View file

@ -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"]
}

View 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())

View file

@ -83,4 +83,8 @@ log "G10: Terraform import plan safety"
python scripts/test-terraform-import-plan-check.py
ok "G10: Terraform import plan safety"
log "G12: Terraform release plan safety"
python scripts/test-terraform-release-plan-check.py
ok "G12: Terraform release plan safety"
log "governance-check: all required repository gates passed"

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bash
#
# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source
# bundle for the shoc-backend .NET 8 application.
# package-elastic-beanstalk.sh — build a normalized Elastic Beanstalk source
# bundle for the shoc-backend .NET 8 application. Generated .NET/EF binaries
# are not guaranteed to be byte-reproducible between separate builds.
#
# Layout of the resulting ZIP (the Beanstalk application root):
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
@ -123,8 +124,8 @@ log "assemble source bundle (contents, not the containing directory)"
if [[ "$ARCHIVER" == "zip" ]]; then
(
cd "$STAGING_DIR"
# ZIP stores file mtimes. Normalize them so identical source/build inputs
# produce byte-identical source bundles.
# ZIP stores file mtimes. Normalize archive metadata; release immutability
# comes from uploading this one build under a unique version label.
find . -type f -exec touch -t 198001010000 {} +
find . -type f -print | LC_ALL=C sort \
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"

View file

@ -23,12 +23,6 @@ REQUIRED_RESOURCES = {
"module.environment.aws_iam_role_policy.runtime_webhook[0]": "aws_iam_role_policy",
"module.environment.aws_route53_record.api_cname[0]": "aws_route53_record",
},
"tf-poc": {
**COMMON_RESOURCES,
"aws_acm_certificate.poc": "aws_acm_certificate",
"aws_route53_zone.poc": "aws_route53_zone",
"module.environment.aws_route53_record.api_cname[0]": "aws_route53_record",
},
}
DEV_IMPORT_IDS = {

View file

@ -186,15 +186,6 @@ def main() -> int:
),
0,
),
(
"tf-poc controlled update",
run_case(
"tf-poc",
actions_by_address={controlled_address: ["update"]},
allowed_updates=(controlled_address,),
),
0,
),
(
"wrong controlled address",
run_case(
@ -255,12 +246,12 @@ def main() -> int:
1,
),
(
"live webhook policy in tf-poc",
"staging resource in a dev plan",
run_case(
"tf-poc",
"dev",
extra_resource=(
"module.environment.aws_iam_role_policy.runtime_webhook[0]",
"aws_iam_role_policy",
"module.environment.aws_route53_record.api_cname[0]",
"aws_route53_record",
["no-op"],
),
),

View 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())

View 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"
}
}
}
]
}

View 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
}
}
]
}

View 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"
}
]
}
}
}
]
}

View 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" }
}
}
}
]
}

View file

@ -0,0 +1,3 @@
{
"resource_changes": []
}

View 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
}
}
}
]
}

View 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" }
}
}
]
}

View 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 }
}
}
}
]
}

View 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"
}
}
}
]
}

View 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
}
}
}
]
}

View 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
}
}
}
]
}

View 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" }
}
}
}
]
}

View 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 ' ')"

View file

@ -7,8 +7,6 @@ keeping shared and Elastic Beanstalk-generated resources outside state.
- `live/dev/` imports the existing dev environment-owned resources.
- `live/staging/` imports the existing staging environment-owned resources.
- `live/tf-poc/` manages the retained import-rehearsal environment after its
completed transfer from CloudFormation.
Shared RDS, application, VPC, subnet, service-role, shared-certificate, and
Elastic Beanstalk-generated inventory remains data-only or provider-managed.
@ -42,7 +40,6 @@ terraform -chdir=terraform/live/dev init -backend=false
terraform -chdir=terraform/live/dev validate
terraform -chdir=terraform/live/staging init -backend=false
terraform -chdir=terraform/live/staging validate
terraform -chdir=terraform/live/tf-poc init -backend=false
terraform -chdir=terraform/live/tf-poc validate
python scripts/test-terraform-import-plan-check.py
python scripts/test-terraform-release-plan-check.py
```

View file

@ -8,10 +8,6 @@ shared or Elastic Beanstalk-generated infrastructure.
- `dev/` and `staging/` import the existing EB environment, runtime
role/profile/policies, deploy role/policy, app-config secret metadata, and API
record.
- `tf-poc/` manages the retained rehearsal environment after its completed
CloudFormation-to-Terraform transfer, excluding the live-only webhook and
Dynamo policies. It also owns the child zone and DNS-validated ACM
certificate.
- `modules/environment-inventory/` reads and pins only shared resources.
- Org-baseline CloudFormation owns the narrowly scoped HCP Terraform plan/apply
roles.
@ -19,8 +15,7 @@ shared or Elastic Beanstalk-generated infrastructure.
The shared `shoc-backend` Elastic Beanstalk application and
`shoc-sqlserver-shared` RDS instance, VPC, subnets, EB service role, shared
certificate, shared RDS security group, and EB-generated SG/ALB/ASG/CloudFormation
resources must never enter an environment state. The `shoc_tf_poc` SQL catalog
is out of band.
resources must never enter an environment state.
Secret values are not Terraform resources, variables, outputs, or managed EB
settings. Terraform manages the app-config secret shell and maps approved JSON
@ -78,9 +73,7 @@ Terraform does not perform this pre-import mutation.
## Two-phase adoption
Each dev/staging root pins `adoption_complete=false` in reviewed code until its
initial import is proven. It is not an HCP workspace variable. The retained
tf-poc rehearsal has completed both phases and therefore pins
`adoption_complete=true`.
initial import is proven. It is not an HCP workspace variable.
1. Create the HCP workspace and configure dynamic credentials.
2. Run the declarative imports.
@ -113,37 +106,61 @@ tf-poc rehearsal has completed both phases and therefore pins
plan contains no create, delete, or replacement action. The dev direct ALB
alias remains pinned during this phase and must not update.
The same reviewed change prepares the legacy dev CDK stack for ownership
transfer. Before the Terraform apply, deploy `shoc-backend-deploy-dev` with
`ManageGithubDeployRole=true` so both the role and generated inline-policy
resource carry `Retain`. After Terraform succeeds and live verification passes,
deploy the same reviewed SHA with `ManageGithubDeployRole=false`. This removes
both resources from CloudFormation ownership without deleting them. Never use
`ManageGithubDeployRole=true` again after that transfer.
The dev deploy role and generated inline policy completed their retained
CloudFormation-to-Terraform transfer before the legacy backend CDK source was
removed. Do not reintroduce that ownership path.
The reviewed `adoption_complete=true` change updates ownership tags on IAM
roles, instance profiles, and app-config secrets. Dev retains the proven GitHub
Elastic Beanstalk release policy until application CD is migrated in a separate
reviewed change; infrastructure adoption must not silently break the current
manual release path. Elastic Beanstalk environment tags remain at their imported
values. Terraform manages the declared EB settings. Secret values remain
out-of-band even after the secret shell receives `ManagedBy=terraform`.
Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain
unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not
support resource-level permissions.
roles, instance profiles, and app-config secrets. Elastic Beanstalk
environment tags remain at their imported values. Terraform manages the
declared EB settings. Secret values remain out-of-band even after the secret
shell receives `ManagedBy=terraform`. Deploy-role descriptions and immutable
`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain
`Resource = "*"` only where AWS does not support resource-level permissions.
## POC retained identifiers
The measured self-contained .NET/EF bundle is approximately 199.5 MB and
separate builds are not byte-identical. Each deploy job therefore validates the
exact bundle it uploads; bundle bytes never enter Terraform plans or state.
The tf-poc HCP workspace stores the exact retained environment ID, app-config
secret ARN, child-zone ID, and certificate ARN declared in
`tf-poc/variables.tf`. The declarative import blocks consumed those identifiers
during the completed transfer. Do not guess or replace them, and do not put
credentials or secret values in HCP variables.
## Dev application CD
ACM DNS validation remains part of the Terraform-owned certificate resource;
its generated validation record is not a separate ownership target. The public
delegation of `tf-poc.seahaven.com` from `seahaven.com` remains outside this
Terraform state.
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.
## Pinned live identities
@ -161,5 +178,3 @@ identifiers make accidental cross-environment reuse fail review and planning.
- Auto-apply remains off.
- Org baseline owns final HCP plan/apply permissions and manager tags.
- Every imported Terraform resource has `prevent_destroy`.
- The tf-poc CloudFormation creator path was removed after its no-op import,
controlled update, and retained-resource ownership transfer completed.

View file

@ -35,7 +35,7 @@ module "environment" {
vpc_id = "vpc-0d16336143f3da25e"
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
instance_security_group_id = "sg-0c8bb7cf2c193de57"
instance_security_group_id = null
eb_service_role_name = "shoc-eb-service-role"
shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
runtime_role_name = "shoc-backend-dev"
@ -66,6 +66,7 @@ module "environment" {
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
legacy_dev_s3_policy = true
release_version_label = var.release_version_label
hosted_zone_id = "Z07671212N75U4YLPWZR8"
api_domain = local.api_domain
api_record_type = "A"

View 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>."
}
}

View file

@ -196,7 +196,6 @@ resource "aws_iam_role" "github_deploy" {
data "aws_iam_policy_document" "deploy" {
statement {
sid = var.environment == "tf-poc" ? "DescribeDeploymentResources" : null
effect = "Allow"
actions = [
"autoscaling:Describe*",
@ -210,7 +209,6 @@ data "aws_iam_policy_document" "deploy" {
}
statement {
sid = var.environment == "tf-poc" ? "CreateApplicationVersion" : null
effect = "Allow"
actions = ["elasticbeanstalk:CreateApplicationVersion"]
resources = [
@ -220,45 +218,38 @@ data "aws_iam_policy_document" "deploy" {
}
statement {
sid = var.environment == "tf-poc" ? "UpdatePocEnvironment" : null
effect = "Allow"
actions = ["elasticbeanstalk:UpdateEnvironment"]
resources = [local.environment_arn]
}
dynamic "statement" {
for_each = var.environment != "tf-poc" ? [1] : []
content {
effect = "Allow"
actions = [
"cloudformation:CancelUpdateStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:UpdateStack",
]
resources = [
"arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*",
]
}
statement {
effect = "Allow"
actions = [
"cloudformation:CancelUpdateStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:UpdateStack",
]
resources = [
"arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*",
]
}
dynamic "statement" {
for_each = var.environment != "tf-poc" ? [1] : []
content {
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [
"arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*",
]
}
statement {
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [
"arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*",
]
}
dynamic "statement" {
@ -288,7 +279,6 @@ data "aws_iam_policy_document" "deploy" {
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
sid = var.environment == "tf-poc" ? "UploadApplicationVersion" : null
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
@ -298,25 +288,11 @@ data "aws_iam_policy_document" "deploy" {
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
sid = var.environment == "tf-poc" ? "UseBeanstalkBucket" : null
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
}
}
dynamic "statement" {
for_each = var.environment == "tf-poc" ? [1] : []
content {
sid = "DenyLiveEnvironments"
effect = "Deny"
actions = ["elasticbeanstalk:*"]
resources = [
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-dev",
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-staging",
]
}
}
}
resource "aws_iam_role_policy" "github_deploy" {
@ -458,11 +434,16 @@ locals {
}
resource "aws_elastic_beanstalk_environment" "this" {
name = var.eb_environment_name
application = var.eb_application_name
platform_arn = var.platform_arn
tier = "WebServer"
cname_prefix = var.eb_environment_name
# Null VCS plans omit this Optional+Computed argument, so the provider
# refreshes the live label without reverting releases. Application-CD runs
# pass an immutable <full-sha>-<run-id>-<attempt> value as a run-specific
# TF_VAR_release_version_label.
name = var.eb_environment_name
application = var.eb_application_name
platform_arn = var.platform_arn
version_label = var.release_version_label
tier = "WebServer"
cname_prefix = var.eb_environment_name
dynamic "setting" {
for_each = var.manage_eb_settings ? local.managed_eb_settings : []

View file

@ -10,8 +10,8 @@ variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "tf-poc"], var.environment)
error_message = "environment must be dev, staging, or tf-poc."
condition = contains(["dev", "staging"], var.environment)
error_message = "environment must be dev or staging."
}
}
@ -37,7 +37,7 @@ variable "eb_environment_name" {
variable "eb_environment_id" {
type = string
description = "Existing environment ID. Empty only before the CDK POC has been provisioned."
description = "Existing Elastic Beanstalk environment ID."
}
variable "platform_arn" {
@ -68,7 +68,7 @@ variable "eb_service_role_name" {
variable "shared_certificate_arn" {
type = string
description = "Existing shared certificate for dev/staging, or the POC certificate ARN."
description = "Existing shared certificate for dev/staging"
}
variable "runtime_role_name" {
@ -143,7 +143,7 @@ variable "webhook_decrypt_policy_sid" {
variable "dynamo_reader_role_arn" {
type = string
default = null
description = "Dev-only cross-account role. Null for staging and tf-poc."
description = "Dev-only cross-account role. Null for staging."
}
variable "dynamo_policy_sid" {
@ -195,6 +195,22 @@ variable "legacy_dev_s3_policy" {
default = false
}
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
validation {
condition = (
var.release_version_label == null ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
}
}
variable "hosted_zone_id" {
type = string
}

View file

@ -1,26 +0,0 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.62.0"
constraints = "~> 6.57"
hashes = [
"h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=",
"zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5",
"zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd",
"zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010",
"zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3",
"zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df",
"zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844",
"zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090",
"zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2",
"zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7",
"zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f",
"zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba",
"zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913",
"zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14",
"zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02",
]
}

View file

@ -1,54 +0,0 @@
import {
to = aws_route53_zone.poc
id = var.poc_hosted_zone_id
}
import {
to = aws_acm_certificate.poc
id = var.poc_certificate_arn
}
import {
to = module.environment.aws_elastic_beanstalk_environment.this
id = var.poc_environment_id
}
import {
to = module.environment.aws_iam_role.runtime
id = "shoc-backend-tf-poc"
}
import {
to = module.environment.aws_iam_instance_profile.runtime
id = "shoc-backend-tf-poc"
}
import {
to = module.environment.aws_iam_role_policy_attachment.web_tier
id = "shoc-backend-tf-poc/arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
}
import {
to = module.environment.aws_iam_role_policy.runtime_app_config
id = "shoc-backend-tf-poc:shoc-tf-poc-secrets-read"
}
import {
to = module.environment.aws_iam_role.github_deploy
id = "githubdeploy-shoc-backend-tf-poc"
}
import {
to = module.environment.aws_iam_role_policy.github_deploy
id = "githubdeploy-shoc-backend-tf-poc:githubdeploy-shoc-backend-tf-poc-eb"
}
import {
to = module.environment.aws_secretsmanager_secret.app_config
id = var.poc_app_config_secret_arn
}
import {
to = module.environment.aws_route53_record.api_cname[0]
id = "${var.poc_hosted_zone_id}_api.tf-poc.seahaven.com_CNAME"
}

View file

@ -1,115 +0,0 @@
data "aws_caller_identity" "current" {}
data "aws_vpc" "shared" {
id = "vpc-0d16336143f3da25e"
}
data "aws_db_instance" "shared" {
db_instance_identifier = "shoc-sqlserver-shared"
}
data "aws_iam_role" "eb_service" {
name = "shoc-eb-service-role"
}
check "account" {
assert {
condition = data.aws_caller_identity.current.account_id == "396287094661"
error_message = "Refusing to inspect or adopt the POC outside account 396287094661."
}
}
resource "aws_route53_zone" "poc" {
name = "tf-poc.seahaven.com"
comment = "Terraform import rehearsal child zone. Parent NS delegation is a separate approved operation."
force_destroy = false
tags = {
env = "tf-poc"
project = "shoc"
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_acm_certificate" "poc" {
domain_name = "*.tf-poc.seahaven.com"
validation_method = "DNS"
tags = {
Name = "shoc-backend-terraform-import-poc/Certificate"
env = "tf-poc"
project = "shoc"
}
lifecycle {
prevent_destroy = true
}
}
module "environment" {
source = "../modules/environment-owned"
aws_account_id = "396287094661"
aws_region = "us-east-1"
environment = "tf-poc"
adoption_complete = true
eb_application_name = "shoc-backend"
eb_environment_name = "shoc-backend-tf-poc"
eb_environment_id = var.poc_environment_id
platform_arn = "arn:aws:elasticbeanstalk:us-east-1::platform/.NET 8 running on 64bit Amazon Linux 2023/3.11.3"
vpc_id = data.aws_vpc.shared.id
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
instance_security_group_id = null
eb_service_role_name = data.aws_iam_role.eb_service.name
shared_certificate_arn = aws_acm_certificate.poc.arn
runtime_role_name = "shoc-backend-tf-poc"
runtime_app_config_policy_name = "shoc-tf-poc-secrets-read"
runtime_webhook_policy_name = null
permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-tf-poc-runtime-boundary"
github_deploy_permissions_boundary_arn = "arn:aws:iam::396287094661:policy/shoc-backend-tf-poc-deploy-boundary"
app_config_secret_name = "shoc/tf-poc/app-config"
app_config_json_keys = [
"ConnectionStrings__DefaultConnection",
"JWT__Secret",
"JWT__ValidAudience",
"JWT__ValidIssuer",
"SendGrid__ApiKey",
]
webhook_secret_arn = null
work_order_webhook_enabled = false
github_repo = "Sea-Haven-Industries/shoc-backend"
github_environment = "tf-poc"
github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc"
github_deploy_policy_name = "githubdeploy-shoc-backend-tf-poc-eb"
legacy_dev_s3_policy = false
hosted_zone_id = aws_route53_zone.poc.zone_id
api_domain = "api.tf-poc.seahaven.com"
api_record_type = "CNAME"
metadata_before_adoption = {
runtime_role_description = "SHOC backend tf-poc compute role (EB instance profile)"
runtime_role_tags = {
env = "tf-poc"
project = "shoc"
}
instance_profile_tags = {}
app_config_description = "SHOC tf-poc application config (conn string, JWT, SendGrid)"
app_config_tags = {
env = "tf-poc"
project = "shoc"
}
deploy_role_description = "GitHub OIDC deploy role for shoc-backend-tf-poc."
deploy_role_tags = {
HcpTerraformWorkspace = "shoc-backend-tf-poc"
env = "tf-poc"
project = "shoc"
}
environment_tags = {
env = "tf-poc"
project = "shoc"
}
}
}

View file

@ -1,25 +0,0 @@
output "environment_arn" {
value = module.environment.environment_arn
}
output "runtime_role_arn" {
value = module.environment.runtime_role_arn
}
output "github_deploy_role_arn" {
value = module.environment.github_deploy_role_arn
}
output "app_config_secret_arn" {
value = module.environment.app_config_secret_arn
}
output "child_zone_name_servers" {
description = "For a separate, explicitly approved parent-zone delegation operation."
value = aws_route53_zone.poc.name_servers
}
output "shared_rds_arn" {
description = "Data-only shared RDS instance. The shoc_tf_poc catalog remains out of band."
value = data.aws_db_instance.shared.db_instance_arn
}

View file

@ -1,3 +0,0 @@
provider "aws" {
region = "us-east-1"
}

View file

@ -1,30 +0,0 @@
variable "poc_environment_id" {
type = string
description = "Exact e-* ID of the retained POC environment."
validation {
condition = can(regex("^e-[a-z0-9]+$", var.poc_environment_id))
error_message = "poc_environment_id must be an Elastic Beanstalk e-* ID."
}
}
variable "poc_hosted_zone_id" {
type = string
description = "Exact Route 53 ID of the retained child zone."
validation {
condition = can(regex("^Z[A-Z0-9]+$", var.poc_hosted_zone_id))
error_message = "poc_hosted_zone_id must be a Route 53 hosted-zone ID."
}
}
variable "poc_certificate_arn" {
type = string
description = "Exact ARN of the retained ACM certificate."
}
variable "poc_app_config_secret_arn" {
type = string
description = "Exact ARN of the retained POC app-config secret."
}

View file

@ -1,19 +0,0 @@
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
cloud {
organization = "seahaven"
workspaces {
project = "seahaven-external-dev"
name = "shoc-backend-tf-poc"
}
}
}