mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge remote-tracking branch 'origin/dev' into fix/SH-220-wo-aap-alignment
This commit is contained in:
commit
8dfd3084ba
149 changed files with 10940 additions and 1684 deletions
|
|
@ -8,5 +8,13 @@
|
|||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
|
||||
# Leave blank to disable telemetry for that build.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# Sentry environment label (development, staging, production). Blank falls back
|
||||
# to the Vite build mode.
|
||||
#VITE_SENTRY_ENVIRONMENT=
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@
|
|||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
|
||||
# Public browser configuration; the DSN is public by design and baked into the
|
||||
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
|
||||
# and future prod jobs override them per environment.
|
||||
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
|
|
|||
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["npm", "custom.regex"],
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
|
|
@ -17,6 +17,12 @@
|
|||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": ["Group non-major Terraform provider updates"],
|
||||
"matchManagers": ["terraform"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "terraform minor and patch"
|
||||
},
|
||||
{
|
||||
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
|
||||
"matchUpdateTypes": ["major", "replacement"],
|
||||
|
|
|
|||
26
.github/workflows/ci.yaml
vendored
26
.github/workflows/ci.yaml
vendored
|
|
@ -23,9 +23,11 @@ jobs:
|
|||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
|
||||
# maintainability gate). If the reusable workflow is later confirmed to run
|
||||
# every gate, this job can be slimmed to `npm run governance`.
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan and
|
||||
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
|
||||
# and GitHub workflow shell). If the reusable workflow is later confirmed
|
||||
# to run every gate, this job can be slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
|
|
@ -53,10 +55,28 @@ jobs:
|
|||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
|
||||
# the remote run will see.
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: "1.7.12"
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o actionlint.tar.gz \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf actionlint.tar.gz actionlint
|
||||
sudo mv actionlint /usr/local/bin/actionlint
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
|
|
|
|||
14
.github/workflows/deploy-staging.yml
vendored
14
.github/workflows/deploy-staging.yml
vendored
|
|
@ -32,6 +32,8 @@ jobs:
|
|||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -68,15 +70,21 @@ jobs:
|
|||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
||||
# dev value committed in .env.production), syncs to the staging bucket,
|
||||
# and invalidates CloudFront.
|
||||
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||
# label (process env overrides the dev values committed in
|
||||
# .env.production), syncs to the staging bucket, and invalidates
|
||||
# CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
313
.github/workflows/deploy.yml
vendored
313
.github/workflows/deploy.yml
vendored
|
|
@ -1,38 +1,299 @@
|
|||
name: Deploy
|
||||
name: Deploy dev content
|
||||
|
||||
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
|
||||
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
|
||||
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
|
||||
# group, and invalidation. Push-to-dev stays off until
|
||||
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
|
||||
#
|
||||
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
|
||||
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
|
||||
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
|
||||
# CloudFront. Both run as the OIDC deploy role created by the stack.
|
||||
#
|
||||
# When staging/prod accounts exist, add jobs keyed to their branches and their
|
||||
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
|
||||
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
|
||||
# re-run those gates on pull requests, pushes, or workflow_dispatch.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
||||
# be granted to the reusable workflow unless the caller has it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
region: us-east-1
|
||||
cdk-dir: infra/cdk
|
||||
stack-name: shoc-frontend-dev
|
||||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
deploy-dev:
|
||||
name: Deploy shoc-frontend-new-dev through Terraform
|
||||
if: >
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
|
||||
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: deploy-dev
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
TF_CLOUD_ORGANIZATION: seahaven
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
SITE_URL: https://dev.seahaven.com
|
||||
VITE_API_URL: https://api.dev.seahaven.com/api
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Build SPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Rq "localhost:5141" dist/; then
|
||||
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Assign immutable release identity
|
||||
id: release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
prefix="releases/${version_label}"
|
||||
{
|
||||
echo "version_label=${version_label}"
|
||||
echo "prefix=${prefix}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE: ${{ steps.release.outputs.version_label }}
|
||||
|
||||
- name: Read previous release pointer
|
||||
id: pointer
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
|
||||
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
|
||||
|
||||
- name: Upload immutable release prefix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="${{ steps.release.outputs.prefix }}"
|
||||
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
|
||||
--cache-control "no-cache,no-store,must-revalidate" \
|
||||
--content-type "text/html"
|
||||
aws s3api head-object \
|
||||
--bucket "${SITE_BUCKET}" \
|
||||
--key "${prefix}/index.html"
|
||||
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
|
||||
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
|
||||
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
|
||||
|
||||
- name: Capture previous served hash
|
||||
id: previous-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
|
||||
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub CD
|
||||
id: discard-vcs
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- 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 }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-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-release 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" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Flags must match check-terraform-release-plan.py. Pointer `before`
|
||||
# and origin-ID-set stability are asserted from the plan JSON.
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.release.outputs.version_label }}" \
|
||||
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
|
||||
|
||||
- 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 pointer-and-origin-path plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform release run
|
||||
id: release-apply
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.release-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied release run as success
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.release-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.release-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront release
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
|
||||
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
|
||||
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
|
||||
run: bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Restore previous release on failure
|
||||
if: failure()
|
||||
id: rollback-prepare
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev="${{ steps.pointer.outputs.live_current }}"
|
||||
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
|
||||
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
|
||||
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Discard blocking VCS run before GitHub rollback
|
||||
id: rollback-discard-vcs
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
|
||||
|
||||
- name: Create Terraform rollback run
|
||||
id: rollback-run
|
||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
|
||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
env:
|
||||
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
|
||||
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
|
||||
with:
|
||||
workspace: shoc-frontend-new-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-release 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" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
|
||||
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard pointer-and-origin-path Terraform rollback plan
|
||||
id: rollback-json-guard
|
||||
if: failure() && steps.rollback-count-guard.outcome == 'success'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/check-terraform-release-plan.py \
|
||||
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
|
||||
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
|
||||
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
|
||||
|
||||
- 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 pointer-and-origin-path rollback plan guard from GitHub Actions
|
||||
|
||||
- name: Apply Terraform rollback run
|
||||
id: rollback-apply
|
||||
if: failure() && steps.rollback-json-guard.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||
with:
|
||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
|
||||
|
||||
- name: Treat already-applied rollback run as success
|
||||
id: rollback-apply-result
|
||||
if: failure() && steps.rollback-apply.outcome != 'skipped'
|
||||
env:
|
||||
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/hcp-run-guard.py reconcile-apply \
|
||||
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
|
||||
--apply-outcome "${{ steps.rollback-apply.outcome }}"
|
||||
|
||||
- name: Verify CloudFront rollback
|
||||
if: failure() && steps.rollback-apply-result.outcome == 'success'
|
||||
env:
|
||||
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
export EXPECTED_INDEX_SHA256="${expected_sha}"
|
||||
bash scripts/verify-cloudfront-release.sh
|
||||
|
||||
- name: Live-state summary
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: bash scripts/summarize-cloudfront-live-state.sh
|
||||
|
|
|
|||
43
.github/workflows/terraform-isolation.yaml
vendored
Normal file
43
.github/workflows/terraform-isolation.yaml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Terraform isolation
|
||||
|
||||
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
|
||||
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
|
||||
# would report required checks as success and could merge a failing SHA.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes Terraform infrastructure together with
|
||||
# deployable application code (scripts/check-terraform-isolation.mjs). A
|
||||
# merge that does both queues an HCP VCS run and a content release at the
|
||||
# same time, and the two race for the workspace lock. The
|
||||
# `terraform-isolation-override` label is the reviewed exception. This
|
||||
# job is unconditional so adding or removing that label always reads the
|
||||
# current label set; a previous green check does not survive removal.
|
||||
name: Terraform and application changes are isolated
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Check changed files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
|
||||
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -38,12 +38,14 @@ seed-data.sql
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# cdk (infra/cdk)
|
||||
infra/cdk/node_modules
|
||||
infra/cdk/cdk.out
|
||||
infra/cdk/cdk.context.json
|
||||
infra/cdk/*.d.ts
|
||||
infra/cdk/bin/*.d.ts
|
||||
infra/cdk/bin/*.js
|
||||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -7,22 +7,33 @@ npm run verify
|
|||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: Terraform import-plan and release-plan checkers, isolation tests,
|
||||
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
|
||||
workflow shell checks. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Gate | Command / rule source | Enforced by | Scope |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
|
||||
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
|
||||
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
|
||||
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
|
||||
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
|
||||
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
|
||||
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
|
||||
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
|
||||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
|
||||
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
|
||||
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
|
||||
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
|
||||
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
|
||||
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -36,6 +47,18 @@ npm run verify
|
|||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **Terraform gates never touch live state** — `terraform init -backend=false
|
||||
-lockfile=readonly` and `validate` run offline; the plan checker is tested
|
||||
against synthetic plan JSON. Real import and controlled-update plans from HCP
|
||||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`).
|
||||
- **The isolation gate re-evaluates on label changes** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
|
||||
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
|
||||
default pull-request types, so adding or removing the label re-checks
|
||||
the current labels without starting a new Frontend checks run. Removing
|
||||
the label fails a mixed PR that had previously passed with the override.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
|
|
@ -44,11 +67,17 @@ npm run verify
|
|||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow.
|
||||
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
|
||||
on pull requests, fails when Terraform infrastructure and application code
|
||||
change together. Label add/remove re-runs only this workflow.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.14.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
|
|
|
|||
142
README.md
142
README.md
|
|
@ -5,7 +5,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Vite + React SPA for Sea Haven facility management (SHOC): work orders, vendor
|
||||
portal, uplifts, and related admin features. This is the selective rebuild of
|
||||
|
|
@ -18,21 +18,25 @@ documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
|
|||
|
||||
## Architecture
|
||||
|
||||
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
|
||||
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
|
||||
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
|
||||
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
|
||||
Static SPA hosting on AWS, owned by HCP Terraform
|
||||
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
|
||||
`dist/` from a private S3 bucket using a current/previous origin group;
|
||||
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
|
||||
proxy at the CDN — the backend allows CORS).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
|
||||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
||||
GH[GitHub Actions] -->|OIDC upload releases/*| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
|
||||
```
|
||||
|
||||
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
|
||||
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||
|
|
@ -40,8 +44,8 @@ architecture plan for the keep/discard migration matrix).
|
|||
|
||||
## AWS Resources
|
||||
|
||||
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
|
||||
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
|
||||
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
|
||||
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -57,12 +61,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
||||
repo secret**:
|
||||
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
|
||||
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
|
||||
repo secret** is:
|
||||
|
||||
| Secret | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
||||
| Secret | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
|
||||
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
|
|
@ -77,8 +82,8 @@ repo secret**:
|
|||
build otherwise. See [`.env.example`](.env.example),
|
||||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
||||
Pinned hosting constants (domain, certificate ARN, hosted zone) live in
|
||||
[`terraform/live/dev/main.tf`](terraform/live/dev/main.tf).
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -95,17 +100,20 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
|||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CD guards) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform and content-CD gates.
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -123,66 +131,74 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
a green CI run and an approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
||||
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
||||
exists yet).
|
||||
- A PR that changes `terraform/**` may not also change application code (the
|
||||
`terraform-isolation` CI job); ship Terraform in its own PR.
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through
|
||||
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
## Deployment
|
||||
|
||||
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`, calls
|
||||
and PRs to `main`/`dev`/`staging`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
|
||||
guaranteed from this repository. Conventions and gates are documented under
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the content-CD guards) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation. Both run as the OIDC deploy role.
|
||||
- **Terraform isolation**
|
||||
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml))
|
||||
— fails a PR that mixes `terraform/**` with application code, so a Terraform
|
||||
merge never races a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev`, and push-to-`dev` when
|
||||
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
|
||||
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
|
||||
`.release/current`, both origin paths, and the invalidation action. Verify
|
||||
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
|
||||
a live-state summary.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run HCP Terraform workspace
|
||||
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
|
||||
Staging hosting stays on the existing CloudFormation stack until SH-287.
|
||||
|
||||
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
||||
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
||||
|
||||
Manual deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account; the normal path is push to `dev`):
|
||||
|
||||
```bash
|
||||
(cd infra/cdk && npx cdk deploy)
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
|
||||
content publisher only.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
|
||||
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
|
||||
directly and API calls succeed.
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
|
||||
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
route) loads directly and API calls succeed.
|
||||
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
|
||||
on this repo; deploys from other branches/repos are rejected by design.
|
||||
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
|
||||
still serves the previous `index.html` hash. Read the live-state summary
|
||||
before assuming the site is down.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
|
||||
on this repo; dispatching the workflow from another branch is rejected by
|
||||
design.
|
||||
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
||||
follow-up work).
|
||||
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
|
||||
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
|
||||
an HCP Terraform run that a human confirms or discards before the next
|
||||
content release (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
26
config/sentry-release.test.ts
Normal file
26
config/sentry-release.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
|
||||
|
||||
describe("resolveSentryRelease", () => {
|
||||
it("maps the exact commit to the frontend release", () => {
|
||||
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
expect(resolveSentryRelease(sha, true)).toEqual({
|
||||
commitSha: sha.toLowerCase(),
|
||||
release: `shoc-frontend@${sha.toLowerCase()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails CI when the commit identity is missing or malformed", () => {
|
||||
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
|
||||
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
|
||||
});
|
||||
|
||||
it("labels non-CI builds without a commit as local development", () => {
|
||||
expect(resolveSentryRelease(undefined, false)).toEqual({
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
config/sentry-release.ts
Normal file
32
config/sentry-release.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const SENTRY_SERVICE_NAME = "shoc-frontend";
|
||||
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function resolveSentryRelease(
|
||||
candidate: string | undefined,
|
||||
ci: boolean,
|
||||
): {
|
||||
commitSha: string;
|
||||
release: string;
|
||||
} {
|
||||
const commitSha = candidate?.trim();
|
||||
|
||||
if (commitSha && COMMIT_SHA.test(commitSha)) {
|
||||
return {
|
||||
commitSha: commitSha.toLowerCase(),
|
||||
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ci) {
|
||||
throw new Error(
|
||||
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
};
|
||||
}
|
||||
94
docs/adr/0002-sentry-observability.md
Normal file
94
docs/adr/0002-sentry-observability.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# 0002. Browser error and transaction telemetry with Sentry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA has no centralized browser error reporting or distributed
|
||||
transaction tracing. Operational failures therefore have to be reconstructed
|
||||
from user reports and isolated backend logs. We need browser navigation and API
|
||||
request transactions to join the backend trace while preserving the existing UI,
|
||||
routing, authentication, and server-state behavior. Every emitted transaction
|
||||
must also identify its exact build, trace, transaction, operation, function, and
|
||||
opaque actor without sending credentials, request bodies, query values, or
|
||||
personal data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
|
||||
router is created, and wrap `createBrowserRouter` with Sentry's current React
|
||||
Router compatibility API. Configure the browser tracing integration with a 100%
|
||||
transaction sample rate, and propagate trace headers only to the SPA origin and
|
||||
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
||||
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
|
||||
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
|
||||
and recoverable component errors as Sentry events.
|
||||
|
||||
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
|
||||
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
|
||||
They use normalized route templates and attach `code.function`, `operation.type`,
|
||||
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
|
||||
receive the same searchable trace identifiers in `beforeSendTransaction`.
|
||||
Authenticated sessions set only the application's opaque numeric/string user ID;
|
||||
logout and session expiry clear it.
|
||||
|
||||
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
|
||||
browser configuration because every `VITE_` value is embedded in the built
|
||||
JavaScript. `.env.production` commits the public DSN of the real
|
||||
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
|
||||
or blank DSN in any other build leaves telemetry inactive without preventing
|
||||
the application from starting. Default personally identifiable information
|
||||
collection remains disabled.
|
||||
|
||||
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
|
||||
non-blank, falling back to the Vite build mode otherwise. The committed
|
||||
`.env.production` labels dev deploys `development`, the staging deploy job
|
||||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
|
||||
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
|
||||
resolve that identity. Vite creates hidden source maps, and the dev/staging
|
||||
deployment workflows upload them to the private `shoc-frontend` Sentry project
|
||||
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
|
||||
excludes `*.map`; the upload credential is never available to the browser build.
|
||||
|
||||
Before-send processors reduce request data to normalized URL plus HTTP method,
|
||||
reduce users to opaque ID only, attach native trace/transaction identifiers as
|
||||
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
|
||||
URL metadata. Root and child span attributes use the same allowlist, retaining
|
||||
function, operation, actor type, normalized route, method/status, service host,
|
||||
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
|
||||
cookies, query strings/values, free-form breadcrumb messages, usernames, email
|
||||
addresses, IP addresses, tokens, and filenames are not intentionally sent.
|
||||
`sendDefaultPii` remains disabled.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Manual error and timing calls** — rejected because they would miss route
|
||||
transitions and distributed request context, and would require every feature
|
||||
to maintain its own instrumentation.
|
||||
- **Propagate tracing headers to every request** — rejected because third-party
|
||||
requests must not receive SeaHaven trace metadata and broad propagation can
|
||||
introduce cross-origin request failures.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser navigations and API requests are recorded as connected transactions
|
||||
when the deployment supplies a valid DSN.
|
||||
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
|
||||
deploys, `staging` on the staging job), falling back to the Vite build mode
|
||||
when unset.
|
||||
- Every transaction is sampled. This meets the current requirement but increases
|
||||
event volume and should be reviewed against Sentry quota and retention after
|
||||
real traffic is measured.
|
||||
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
|
||||
`baggage` request headers; the current API CORS policy allows request headers.
|
||||
- Deployed stack traces resolve against the exact commit's private source maps
|
||||
once the scoped upload secret is present and the deployment workflow runs.
|
||||
- Rollback is isolated: remove `@sentry/react`, the initialization module and
|
||||
import, restore the unwrapped router factory, and remove the Sentry env entries.
|
||||
2
e2e/vendors/vendors.visual.spec.ts
vendored
2
e2e/vendors/vendors.visual.spec.ts
vendored
|
|
@ -244,7 +244,9 @@ test.describe("Vendor deterministic pixel regression", () => {
|
|||
await openVendorPage(page, "error");
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
||||
/server error|vendor directory unavailable/i,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.getByRole("progressbar")).toHaveCount(0);
|
||||
await expectStableScreenshot(page, "vendor-error.png");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,18 +30,7 @@ const legacyIgnores = [
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"infra/cdk/cdk.out/**",
|
||||
"infra/cdk/bin/**/*.d.ts",
|
||||
"infra/cdk/bin/**/*.js",
|
||||
"infra/cdk/lib/**/*.d.ts",
|
||||
"infra/cdk/lib/**/*.js",
|
||||
...legacyIgnores,
|
||||
],
|
||||
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", ...legacyIgnores],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
||||
deployed through the org's **reusable** GitHub Actions workflow.
|
||||
|
||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
||||
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
|
||||
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
per-environment (see the note under "Adding staging / prod").
|
||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
|
||||
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
|
||||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
|
||||
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
|
||||
|
||||
```
|
||||
infra/cdk/
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yaml quality gates (lint / build / test / e2e)
|
||||
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
## What the stack creates
|
||||
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
|
||||
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
|
||||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
|
||||
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
|
||||
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
|
||||
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
|
||||
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
|
||||
singleton account resource — the stack only _imports_ it (created in step 2),
|
||||
so `cdk destroy` can't delete a resource shared by other roles.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (run by a human with admin AWS creds)
|
||||
|
||||
### 1. Authenticate to the AWS account
|
||||
|
||||
```bash
|
||||
aws configure # or: aws sso login --profile <admin>
|
||||
aws sts get-caller-identity # confirm the right account + region (us-east-1)
|
||||
```
|
||||
|
||||
### 2. Ensure the GitHub OIDC provider exists (once per account)
|
||||
|
||||
```bash
|
||||
aws iam list-open-id-connect-providers
|
||||
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
|
||||
# no longer required — AWS validates GitHub against its own trust store):
|
||||
aws iam create-open-id-connect-provider \
|
||||
--url https://token.actions.githubusercontent.com \
|
||||
--client-id-list sts.amazonaws.com
|
||||
```
|
||||
|
||||
### 3. CDK bootstrap (once per account/region)
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npm ci
|
||||
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
|
||||
```
|
||||
|
||||
### 4. Domain, cert, and API URL (already wired for dev)
|
||||
|
||||
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
|
||||
|
||||
| Context key | Value |
|
||||
| --------------------------------- | ------------------------------------------------------------ |
|
||||
| `domainNames` | `dev.seahaven.com` |
|
||||
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
|
||||
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
|
||||
|
||||
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
|
||||
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
|
||||
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
|
||||
baked into the build. Per-environment; override for staging/prod.
|
||||
|
||||
### 5. First deploy (locally, with admin creds)
|
||||
|
||||
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
|
||||
locally. This provisions infra + the role:
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy
|
||||
```
|
||||
|
||||
Note the `DeployRoleArn` output. Then push the first content (or just push to
|
||||
`dev` and let CI do everything from here on):
|
||||
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
### 6. Set the one GitHub secret
|
||||
|
||||
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
|
||||
|
||||
```bash
|
||||
REPO=Sea-Haven-Industries/shoc-frontend-new
|
||||
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
|
||||
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
|
||||
```
|
||||
|
||||
(Or **Settings → Secrets and variables → Actions → Secrets**.)
|
||||
|
||||
### 7. From now on: push to `dev`
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
|
||||
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
|
||||
the `SiteUrl` output.
|
||||
|
||||
> First-run verification: this first push is what actually exercises the role's
|
||||
> permissions and the OIDC trust through the reusable workflow (the local
|
||||
> bootstrap used admin creds and tested none of that). Watch for
|
||||
> credential/OIDC errors and a green post-deploy step.
|
||||
|
||||
---
|
||||
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||
reusable `cd-cdk.yaml`:
|
||||
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
environment subject
|
||||
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||
`environment: staging`, so only runs in that environment can assume the role.
|
||||
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
|
||||
unchanged.
|
||||
- **No secret:** the role ARN is static (the role name is deterministic), so
|
||||
the workflow pins
|
||||
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
|
||||
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
|
||||
- **Gates first:** the workflow runs the full `npm run verify` before assuming
|
||||
the staging role, then runs `scripts/deploy-web.sh` with
|
||||
`STACK_NAME=shoc-frontend-staging`,
|
||||
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
|
||||
CloudFront invalidation to complete.
|
||||
- **Application-only role:** the recurring staging workflow can describe only
|
||||
its exact stack, publish only to its exact bucket, and invalidate only its
|
||||
exact distribution. It cannot assume the shared CDK bootstrap roles or
|
||||
modify infrastructure. Staging infrastructure changes use the Administrator
|
||||
command below.
|
||||
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
|
||||
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
|
||||
contain the staging API URL and no dev API URL. (Not browser QA.)
|
||||
|
||||
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
|
||||
|
||||
1. **GitHub Admin — create the `staging` environment** (Settings →
|
||||
Environments → New environment → `staging`). Add protection rules as
|
||||
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
|
||||
the environment does not exist, GitHub creates it unprotected on first use.
|
||||
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
|
||||
OIDC provider and bootstrap already exist in this account):
|
||||
|
||||
```bash
|
||||
cd infra/cdk
|
||||
npx cdk deploy shoc-frontend-staging \
|
||||
-c envName=staging \
|
||||
-c deployBranch=staging \
|
||||
-c githubEnvironment=staging \
|
||||
-c domainNames=staging.seahaven.com \
|
||||
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
|
||||
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
|
||||
-c hostedZoneName=staging.seahaven.com
|
||||
```
|
||||
|
||||
The `DeployRoleArn` output must match the ARN pinned in
|
||||
`deploy-staging.yml` (it will — the role name is deterministic).
|
||||
|
||||
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
|
||||
allow the `https://staging.seahaven.com` origin.
|
||||
4. Push to `staging` — `ci.yaml` runs the quality gates and
|
||||
`deploy-staging.yml` deploys.
|
||||
|
||||
### Adding prod later
|
||||
|
||||
Same pattern: a prod account/stack with its own contexts and, ideally, its own
|
||||
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
|
||||
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
|
||||
for prod.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||
differs only in that its CD workflow also runs `npm run verify` itself
|
||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
||||
of enabling CICD.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
import { App, Tags } from "aws-cdk-lib";
|
||||
import { FrontendStack } from "../lib/frontend-stack";
|
||||
|
||||
const app = new App();
|
||||
|
||||
// Defaults match the dev setup; override via `-c key=value` on the CLI.
|
||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
||||
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
|
||||
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
|
||||
// branch-ref trust.
|
||||
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
|
||||
|
||||
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
|
||||
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
|
||||
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
|
||||
.split(",")
|
||||
.map((d: string) => d.trim())
|
||||
.filter((d: string) => d.length > 0);
|
||||
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
||||
|
||||
// Route 53 hosted zone (this account) for the custom-domain alias record.
|
||||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
const terminationProtection = envName !== "dev";
|
||||
|
||||
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment,
|
||||
terminationProtection,
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
},
|
||||
});
|
||||
|
||||
Tags.of(stack).add("Project", "shoc-frontend");
|
||||
Tags.of(stack).add("Environment", envName);
|
||||
Tags.of(stack).add("ManagedBy", "cdk");
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
|
||||
"watch": {
|
||||
"include": ["**"],
|
||||
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "node_modules", "cdk.out"]
|
||||
},
|
||||
"context": {
|
||||
"@aws-cdk/aws-iam:minimizePolicies": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
|
||||
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
|
||||
|
||||
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
|
||||
"domainNames": "dev.seahaven.com",
|
||||
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
|
||||
"hostedZoneId": "Z07671212N75U4YLPWZR8",
|
||||
"hostedZoneName": "dev.seahaven.com"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
readonly envName: string;
|
||||
/** GitHub repo in owner/name form, for OIDC trust scoping. */
|
||||
readonly githubRepo: string;
|
||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||
readonly deployBranch: string;
|
||||
/**
|
||||
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
|
||||
* trust uses the EXACT environment subject
|
||||
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
|
||||
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
|
||||
*/
|
||||
readonly githubEnvironment?: string;
|
||||
/**
|
||||
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
||||
* Empty = serve on the default *.cloudfront.net domain.
|
||||
*/
|
||||
readonly domainNames: string[];
|
||||
/**
|
||||
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
|
||||
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
|
||||
* use a certificate from another account, so for Option B the cert must live
|
||||
* in whichever account this stack deploys to.
|
||||
*/
|
||||
readonly certificateArn: string;
|
||||
/**
|
||||
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
|
||||
* record in. Empty = don't manage DNS (add the record manually). When set,
|
||||
* hostedZoneName must also be provided.
|
||||
*/
|
||||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SPA hosting for the Sea Haven SHOC frontend:
|
||||
* - private S3 bucket (no public access; CloudFront reads it via OAC)
|
||||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||
* - a GitHub Actions OIDC deploy role
|
||||
*
|
||||
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
||||
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
||||
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
||||
* stack only owns the infrastructure, and the deploy role carries the
|
||||
* permissions those post-deploy steps need.
|
||||
*/
|
||||
export class FrontendStack extends Stack {
|
||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const {
|
||||
envName,
|
||||
githubRepo,
|
||||
deployBranch,
|
||||
githubEnvironment = "",
|
||||
domainNames,
|
||||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
} = props;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
if (hasCustomDomain && !certificateArn) {
|
||||
throw new Error(
|
||||
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Origin bucket: private, encrypted, no public access ----------------
|
||||
const bucket = new s3.Bucket(this, "SiteBucket", {
|
||||
bucketName: `seahaven-shoc-frontend-${envName}`,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
enforceSSL: true,
|
||||
versioned: true,
|
||||
// dev artifacts are reproducible from the build — safe to tear down.
|
||||
removalPolicy: RemovalPolicy.DESTROY,
|
||||
autoDeleteObjects: true,
|
||||
});
|
||||
|
||||
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
|
||||
// to /index.html so deep links resolve. Done with a CloudFront Function
|
||||
// rather than customErrorResponses so real asset 404s stay 404s.
|
||||
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
|
||||
comment: "SPA routing: rewrite extensionless paths to /index.html",
|
||||
code: cloudfront.FunctionCode.fromInline(
|
||||
[
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
),
|
||||
});
|
||||
|
||||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||
defaultRootObject: "index.html",
|
||||
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
|
||||
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
||||
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
|
||||
// CloudFront uses its default *.cloudfront.net domain + certificate.
|
||||
domainNames: hasCustomDomain ? domainNames : undefined,
|
||||
certificate: hasCustomDomain
|
||||
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
|
||||
: undefined,
|
||||
minimumProtocolVersion: hasCustomDomain
|
||||
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
|
||||
: undefined,
|
||||
defaultBehavior: {
|
||||
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
||||
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||
compress: true,
|
||||
functionAssociations: [
|
||||
{
|
||||
function: spaRewrite,
|
||||
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// --- GitHub Actions OIDC deploy role -----------------------------------
|
||||
// The OIDC provider is a singleton account-global resource, created once
|
||||
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
|
||||
// lifecycle (including `cdk destroy`) never deletes a resource shared by
|
||||
// every role in the account.
|
||||
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
||||
this,
|
||||
"GitHubOidcProvider",
|
||||
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
||||
);
|
||||
|
||||
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Without one (dev): keep
|
||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
});
|
||||
|
||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||
// intentionally narrower: its recurring promotion workflow only publishes
|
||||
// application assets to this stack's bucket/distribution. Infrastructure
|
||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AssumeCdkBootstrapRoles",
|
||||
actions: ["sts:AssumeRole"],
|
||||
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
||||
}),
|
||||
);
|
||||
}
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "DescribeStack",
|
||||
actions: ["cloudformation:DescribeStacks"],
|
||||
resources: [
|
||||
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
bucket.grantReadWrite(deployRole);
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "InvalidateDistribution",
|
||||
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
|
||||
resources: [
|
||||
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
||||
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
||||
if (hostedZoneId && hasCustomDomain) {
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||
hostedZoneId,
|
||||
zoneName: hostedZoneName,
|
||||
});
|
||||
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
|
||||
// apex record when the domain equals the zone name.
|
||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||
|
||||
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
||||
}
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
});
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
});
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
});
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
});
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
|
||||
});
|
||||
}
|
||||
}
|
||||
514
infra/cdk/package-lock.json
generated
514
infra/cdk/package-lock.json
generated
|
|
@ -1,514 +0,0 @@
|
|||
{
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
||||
"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.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.9.0.tgz",
|
||||
"integrity": "sha512-gKfnU9IP6hYkz2VZHJxhW6fGVOPjf3Vq0zOsOis4CJHF2Li5LkBUubVkji1IOGniqCJK/NgxOcbCMxsgmFvaUw==",
|
||||
"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/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1130.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1130.0.tgz",
|
||||
"integrity": "sha512-LgSKHFTGhoT/lML48uiYIpdSHCwZLvUx/uZu5MqcZjh+OwWzM8nCxXY+OjKG3yASlx5JxeulXm4sRaUYo48qFQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cdk": "bin/cdk"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.261.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.261.0.tgz",
|
||||
"integrity": "sha512-e52e3Abjg0HkuRWlWwtSv5+ZiMW1rhCDdL9ff7lzWXInU8xdfLJpuoimfa0IJwjiNGyphppgg52Azx9M80OA0g==",
|
||||
"bundleDependencies": [
|
||||
"@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.282",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.5",
|
||||
"@aws-cdk/cloud-assembly-schema": "^54.0.0",
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"case": "1.6.3",
|
||||
"fs-extra": "^11.3.5",
|
||||
"ignore": "^5.3.2",
|
||||
"jsonschema": "^1.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minimatch": "^10.2.5",
|
||||
"punycode": "^2.3.1",
|
||||
"semver": "^7.8.1",
|
||||
"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.5",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-cdk/cloud-assembly-schema": ">=53.28.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.6",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 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.5",
|
||||
"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.1",
|
||||
"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.6.0",
|
||||
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
|
||||
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"name": "shoc-frontend-infra",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CDK app provisioning S3 + CloudFront hosting and the GitHub OIDC deploy role for the Sea Haven SHOC frontend.",
|
||||
"bin": {
|
||||
"app": "bin/app.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"synth": "cdk synth",
|
||||
"diff": "cdk diff",
|
||||
"deploy": "cdk deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"aws-cdk": "^2.1130.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "^2.261.0",
|
||||
"constructs": "^10.4.2"
|
||||
},
|
||||
"packageManager": "npm@11.16.0"
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["bin/**/*.ts", "lib/**/*.ts"],
|
||||
"exclude": ["node_modules", "cdk.out"]
|
||||
}
|
||||
308
package-lock.json
generated
308
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
@ -1974,6 +1976,295 @@
|
|||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz",
|
||||
"integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/feedback": "10.73.0",
|
||||
"@sentry/replay": "10.73.0",
|
||||
"@sentry/replay-canvas": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz",
|
||||
"integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz",
|
||||
"integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"progress": "^2.0.3",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"undici": "^6.22.0",
|
||||
"which": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-cli": "bin/sentry-cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "3.7.0",
|
||||
"@sentry/cli-linux-arm": "3.7.0",
|
||||
"@sentry/cli-linux-arm64": "3.7.0",
|
||||
"@sentry/cli-linux-i686": "3.7.0",
|
||||
"@sentry/cli-linux-x64": "3.7.0",
|
||||
"@sentry/cli-win32-arm64": "3.7.0",
|
||||
"@sentry/cli-win32-i686": "3.7.0",
|
||||
"@sentry/cli-win32-x64": "3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz",
|
||||
"integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==",
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz",
|
||||
"integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz",
|
||||
"integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz",
|
||||
"integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz",
|
||||
"integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz",
|
||||
"integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz",
|
||||
"integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/replay": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@simple-libs/child-process-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
|
||||
|
|
@ -5736,6 +6027,16 @@
|
|||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5753,6 +6054,13 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
|
||||
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
|
||||
"test:github-workflows": "bash scripts/check-github-workflows.sh",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
@ -55,6 +62,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -79,6 +87,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
|
|||
50
scripts/check-github-workflows.sh
Executable file
50
scripts/check-github-workflows.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# bash -n every shell script and every workflow `run:` block. actionlint when present.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
for script in scripts/*.sh; do
|
||||
bash -n "${script}"
|
||||
done
|
||||
|
||||
python3 - "${ROOT}" << 'PY'
|
||||
import pathlib, re, subprocess, sys, tempfile
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
failures = 0
|
||||
workflow_count = 0
|
||||
block_count = 0
|
||||
for workflow in sorted((root / ".github/workflows").glob("*.yml")) + sorted(
|
||||
(root / ".github/workflows").glob("*.yaml")
|
||||
):
|
||||
workflow_count += 1
|
||||
text = workflow.read_text(encoding="utf-8")
|
||||
blocks = []
|
||||
for match in re.finditer(r"^(\s+)run:\s*\|[^\n]*\n((?:\1 .*\n)+)", text, re.M):
|
||||
indent = len(match.group(1)) + 2
|
||||
body = []
|
||||
for line in match.group(2).splitlines():
|
||||
body.append(line[indent:] if len(line) >= indent else line.lstrip())
|
||||
blocks.append("\n".join(body) + "\n")
|
||||
block_count += len(blocks)
|
||||
for index, block in enumerate(blocks, start=1):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as handle:
|
||||
handle.write(block)
|
||||
name = handle.name
|
||||
result = subprocess.run(["bash", "-n", name], capture_output=True, text=True)
|
||||
pathlib.Path(name).unlink()
|
||||
if result.returncode != 0:
|
||||
failures += 1
|
||||
sys.stderr.write(f"{workflow.relative_to(root)} run block {index}: {result.stderr}")
|
||||
if failures:
|
||||
raise SystemExit(1)
|
||||
print(
|
||||
f"bash -n passed for scripts and {block_count} run blocks in {workflow_count} workflows"
|
||||
)
|
||||
PY
|
||||
|
||||
if command -v actionlint >/dev/null 2>&1; then
|
||||
actionlint -color
|
||||
else
|
||||
echo "actionlint not installed; skipped (CI installs it)"
|
||||
fi
|
||||
509
scripts/check-terraform-import-plan.py
Executable file
509
scripts/check-terraform-import-plan.py
Executable file
|
|
@ -0,0 +1,509 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||
DISTRIBUTION_ADDRESS = (
|
||||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY_ADDRESS}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--post-import-no-op",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Require all managed resources to be no-op after import and forbid "
|
||||
"import metadata."
|
||||
),
|
||||
)
|
||||
modes.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||
"Repeat for every expected update."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_plan(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("plan JSON root must be an object")
|
||||
if not isinstance(value.get("resource_changes"), list):
|
||||
raise ValueError("plan JSON must contain a resource_changes array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_import_metadata(
|
||||
*,
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
importing = change.get("importing")
|
||||
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
|
||||
|
||||
import_id = importing.get("id")
|
||||
if not isinstance(import_id, str) or not import_id.strip():
|
||||
return [f"{address}: import ID must be a non-empty string"]
|
||||
if import_id.startswith("REPLACE_WITH_"):
|
||||
return [f"{address}: import ID is still a placeholder"]
|
||||
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if expected is not None and import_id != expected:
|
||||
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
|
||||
|
||||
other_environment_ids = {
|
||||
imports[address]
|
||||
for name, imports in REQUIRED_IMPORT_IDS.items()
|
||||
if name != environment and imports[address] is not None
|
||||
}
|
||||
if import_id in other_environment_ids:
|
||||
return [f"{address}: import ID belongs to another environment"]
|
||||
return []
|
||||
|
||||
|
||||
def _contains_unknown(value: Any) -> bool:
|
||||
if value is True:
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _changed_leaf_paths(
|
||||
before: Any,
|
||||
after: Any,
|
||||
path: tuple[str, ...] = (),
|
||||
) -> set[tuple[str, ...]]:
|
||||
if isinstance(before, dict) and isinstance(after, dict):
|
||||
result: set[tuple[str, ...]] = set()
|
||||
for key in set(before) | set(after):
|
||||
result.update(
|
||||
_changed_leaf_paths(
|
||||
before.get(key),
|
||||
after.get(key),
|
||||
(*path, str(key)),
|
||||
)
|
||||
)
|
||||
return result
|
||||
if before != after:
|
||||
return {path}
|
||||
return set()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _canonical(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
items = [_canonical(item) for item in value]
|
||||
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return value
|
||||
|
||||
|
||||
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
|
||||
if not isinstance(value, str):
|
||||
return None, [f"{address}: {side} policy must be a JSON string"]
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None, [f"{address}: {side} policy is not valid JSON"]
|
||||
if not isinstance(document, dict):
|
||||
return None, [f"{address}: {side} policy must be a JSON object"]
|
||||
return _canonical(document), []
|
||||
|
||||
|
||||
def _distribution_id(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
) -> str | None:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
if isinstance(configured, str):
|
||||
return configured
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
|
||||
continue
|
||||
after = resource.get("change", {}).get("after")
|
||||
if isinstance(after, dict):
|
||||
identifier = after.get("id")
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
return identifier
|
||||
return None
|
||||
|
||||
|
||||
def _expected_pre_adoption_bucket_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_tag_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
invalid = {
|
||||
path
|
||||
for path in changed
|
||||
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||
}
|
||||
violations = [
|
||||
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||
if address == ROLE_ADDRESS:
|
||||
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||
"workspace_name"
|
||||
]
|
||||
if address == BUCKET_ADDRESS:
|
||||
expected["aws-cdk:auto-delete-objects"] = None
|
||||
expected_after = {
|
||||
key: value for key, value in expected.items() if value is not None
|
||||
}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
for path in sorted(changed - invalid):
|
||||
key = path[1]
|
||||
if key not in expected:
|
||||
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||
violations.append(
|
||||
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||
)
|
||||
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||
violations.append(
|
||||
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||
)
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: policy semantics did not change")
|
||||
if distribution_id is None:
|
||||
violations.append(
|
||||
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||
)
|
||||
return violations
|
||||
expected_before = _expected_pre_adoption_bucket_policy(
|
||||
environment, distribution_id
|
||||
)
|
||||
expected_after = _expected_bucket_policy(environment, distribution_id)
|
||||
if before_policy is not None and before_policy != expected_before:
|
||||
violations.append(f"{address}: pre-adoption policy semantics are not exact")
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
replace_paths = change.get("replace_paths", [])
|
||||
if replace_paths not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
if _contains_unknown(change.get("after_unknown", {})):
|
||||
violations.append(f"{address}: controlled update contains unknown values")
|
||||
before = change.get("before")
|
||||
after = change.get("after")
|
||||
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address == BUCKET_POLICY_ADDRESS:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
address,
|
||||
before,
|
||||
after,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
environment: str,
|
||||
mode: str,
|
||||
allowed_updates: set[str],
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||
for address in sorted(invalid_allowed):
|
||||
violations.append(
|
||||
f"{address}: address is not eligible for the controlled adoption update"
|
||||
)
|
||||
|
||||
distribution_id = _distribution_id(plan, environment)
|
||||
seen_addresses: set[str] = set()
|
||||
seen_updates: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[environment]
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict):
|
||||
violations.append("<unknown>: resource change must be an object")
|
||||
continue
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address")
|
||||
if not isinstance(address, str):
|
||||
violations.append("<unknown>: managed resource has no valid address")
|
||||
continue
|
||||
if address in seen_addresses:
|
||||
violations.append(f"{address}: duplicate managed resource change")
|
||||
seen_addresses.add(address)
|
||||
|
||||
expected_type = required_resources.get(address)
|
||||
if expected_type is None:
|
||||
violations.append(f"{address}: managed address is outside the ownership boundary")
|
||||
elif resource.get("type") != expected_type:
|
||||
violations.append(
|
||||
f"{address}: expected managed type {expected_type!r}, "
|
||||
f"got {resource.get('type')!r}"
|
||||
)
|
||||
|
||||
change = resource.get("change")
|
||||
if not isinstance(change, dict):
|
||||
violations.append(f"{address}: missing change object")
|
||||
continue
|
||||
actions = change.get("actions")
|
||||
if not isinstance(actions, list) or not all(
|
||||
isinstance(action, str) for action in actions
|
||||
):
|
||||
violations.append(f"{address}: actions must be a string array")
|
||||
continue
|
||||
|
||||
if change.get("replace_paths") not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
|
||||
if mode == "import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
elif mode == "post-import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: post-import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in post-import mode"
|
||||
)
|
||||
else:
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in controlled-update mode"
|
||||
)
|
||||
if actions == ["update"]:
|
||||
seen_updates.add(address)
|
||||
if address not in allowed_updates:
|
||||
violations.append(f"{address}: update is not explicitly allowlisted")
|
||||
else:
|
||||
violations.extend(
|
||||
_validate_controlled_update(
|
||||
address,
|
||||
change,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
elif actions != ["no-op"]:
|
||||
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||
|
||||
for missing in sorted(set(required_resources) - seen_addresses):
|
||||
violations.append(f"{missing}: required managed resource is absent")
|
||||
for unused in sorted(allowed_updates - seen_updates):
|
||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
plan = _load_plan(args.plan_json)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
allowed_updates = set(args.allow_update_address or [])
|
||||
if args.post_import_no_op:
|
||||
mode = "post-import"
|
||||
elif allowed_updates:
|
||||
mode = "controlled"
|
||||
else:
|
||||
mode = "import"
|
||||
violations = check_plan(
|
||||
plan,
|
||||
environment=args.environment,
|
||||
mode=mode,
|
||||
allowed_updates=allowed_updates,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
label = {
|
||||
"import": "zero-change import",
|
||||
"post-import": "post-import no-op",
|
||||
"controlled": "controlled update",
|
||||
}[mode]
|
||||
print(
|
||||
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
|
||||
f"managed resources and {len(allowed_updates)} exact updates"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
137
scripts/check-terraform-isolation.mjs
Normal file
137
scripts/check-terraform-isolation.mjs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Terraform/application change isolation gate.
|
||||
//
|
||||
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
|
||||
// on the workspace. If the same merge also changes deployable application
|
||||
// code, the content release and the VCS run race for the workspace lock
|
||||
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
|
||||
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
|
||||
// or discarded by a human before the next content release.
|
||||
//
|
||||
// Files that may accompany a Terraform change without triggering a release:
|
||||
// the Terraform tree itself, its plan-guard tooling, and documentation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
|
||||
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
|
||||
//
|
||||
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
|
||||
// it only when the PR carries the `terraform-isolation-override` label, which
|
||||
// reviewers grant to the rare change that must introduce Terraform variables
|
||||
// together with the workflow that consumes them. The checker has no memory of
|
||||
// a previous pass: the same mixed diff fails again as soon as the override
|
||||
// env is unset (label removal).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const OVERRIDE_LABEL = "terraform-isolation-override";
|
||||
|
||||
export function isTerraformPath(file) {
|
||||
return file.startsWith("terraform/");
|
||||
}
|
||||
|
||||
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
|
||||
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
|
||||
// Terraform change for the mixed-PR check.
|
||||
export function isTerraformInfrastructurePath(file) {
|
||||
return isTerraformPath(file) && !file.endsWith(".md");
|
||||
}
|
||||
|
||||
export function mayAccompanyTerraform(file) {
|
||||
if (isTerraformPath(file)) return true;
|
||||
if (file.endsWith(".md")) return true;
|
||||
if (file.startsWith("docs/")) return true;
|
||||
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
|
||||
if (
|
||||
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
|
||||
file,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (file.startsWith("scripts/testdata/terraform-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files changed paths relative to the repository root
|
||||
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
|
||||
const terraform = unique.filter(isTerraformInfrastructurePath);
|
||||
const application = unique.filter((file) => !mayAccompanyTerraform(file));
|
||||
return {
|
||||
terraform,
|
||||
application,
|
||||
mixed: terraform.length > 0 && application.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function changedFilesFromGit(base, head) {
|
||||
const mergeBase = execFileSync("git", ["merge-base", base, head], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { base: null, head: "HEAD", stdin: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--base") options.base = argv[++index];
|
||||
else if (argument === "--head") options.head = argv[++index];
|
||||
else if (argument === "--stdin") options.stdin = true;
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!options.stdin && !options.base) {
|
||||
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArgs(argv);
|
||||
const files = options.stdin
|
||||
? readFileSync(0, "utf8").split("\n")
|
||||
: changedFilesFromGit(options.base, options.head);
|
||||
const result = classifyChangedFiles(files);
|
||||
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
|
||||
);
|
||||
if (!result.mixed) {
|
||||
console.log(" PASS: Terraform and application changes are not mixed");
|
||||
return 0;
|
||||
}
|
||||
console.log(" Terraform files:");
|
||||
for (const file of result.terraform) console.log(` ${file}`);
|
||||
console.log(" Application files that cannot ship in the same PR:");
|
||||
for (const file of result.application) console.log(` ${file}`);
|
||||
if (override) {
|
||||
console.log(
|
||||
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
179
scripts/check-terraform-isolation.test.mjs
Normal file
179
scripts/check-terraform-isolation.test.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
isTerraformInfrastructurePath,
|
||||
mayAccompanyTerraform,
|
||||
} from "./check-terraform-isolation.mjs";
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"check-terraform-isolation.mjs",
|
||||
);
|
||||
|
||||
function runGate(files, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
|
||||
input: `${files.join("\n")}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
|
||||
for (const file of [
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
"terraform/README.md",
|
||||
"README.md",
|
||||
"docs/adr/0003-terraform.md",
|
||||
"scripts/check-terraform-import-plan.py",
|
||||
"scripts/terraform_import_plan_resources.py",
|
||||
"scripts/test-terraform-import-plan-check.py",
|
||||
"scripts/terraform-validate.mjs",
|
||||
"scripts/check-terraform-isolation.mjs",
|
||||
"scripts/check-terraform-release-plan.py",
|
||||
"scripts/hcp-run-guard.py",
|
||||
"scripts/test-hcp-run-guard.py",
|
||||
"scripts/verify-cloudfront-release.sh",
|
||||
"scripts/test-verify-cloudfront-release.sh",
|
||||
"scripts/summarize-cloudfront-live-state.sh",
|
||||
"scripts/check-github-workflows.sh",
|
||||
"scripts/read-release-pointer.py",
|
||||
"scripts/testdata/terraform-release-plans/version-only.json",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("terraform-only and application-only changes are not mixed", () => {
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(classifyChangedFiles([]).mixed, false);
|
||||
});
|
||||
|
||||
test("terraform documentation does not mix with application or workflow changes", () => {
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
|
||||
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
|
||||
false,
|
||||
);
|
||||
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
|
||||
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
|
||||
assert.match(docsOnly.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("terraform plus application is mixed and lists the offending files", () => {
|
||||
const result = classifyChangedFiles([
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/App.tsx",
|
||||
"README.md",
|
||||
" ",
|
||||
"src/App.tsx",
|
||||
]);
|
||||
assert.equal(result.mixed, true);
|
||||
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
|
||||
assert.deepEqual(result.application, ["src/App.tsx"]);
|
||||
});
|
||||
|
||||
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
|
||||
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
|
||||
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
||||
assert.match(mixed.stdout, /FAIL/);
|
||||
assert.match(mixed.stdout, /src\/App\.tsx/);
|
||||
|
||||
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
|
||||
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
|
||||
assert.match(isolated.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("CLI override downgrades a mixed change to a warning that names the label", () => {
|
||||
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /WARNING/);
|
||||
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
|
||||
|
||||
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "yes",
|
||||
});
|
||||
assert.equal(notTrue.status, 1);
|
||||
});
|
||||
|
||||
test("removing the override fails a mixed change that was previously green", () => {
|
||||
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
|
||||
const previouslyGreen = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
|
||||
assert.match(previouslyGreen.stdout, /WARNING/);
|
||||
|
||||
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
|
||||
// the string "false" after the label is removed. A stale green check must
|
||||
// not survive that.
|
||||
const afterLabelRemoved = runGate(files, {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "false",
|
||||
});
|
||||
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
|
||||
assert.match(afterLabelRemoved.stdout, /FAIL/);
|
||||
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
|
||||
});
|
||||
|
||||
test("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
||||
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
|
||||
const workflows = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
".github/workflows",
|
||||
);
|
||||
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
|
||||
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
|
||||
|
||||
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
|
||||
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
|
||||
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
|
||||
|
||||
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
|
||||
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
|
||||
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
|
||||
});
|
||||
533
scripts/check-terraform-release-plan.py
Executable file
533
scripts/check-terraform-release-plan.py
Executable file
|
|
@ -0,0 +1,533 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject HCP Terraform plans that are not a frontend content-release update.
|
||||
|
||||
Accepts exactly:
|
||||
- an update of the release pointer (content, plus computed etag/version_id)
|
||||
- an update of the distribution with only origin[*].origin_path changed
|
||||
(response_completion_timeout 0, null, and a missing key are equivalent)
|
||||
- exactly one action invocation for the CloudFront invalidation
|
||||
|
||||
after origin_path values must match the expected labels. before origin_path
|
||||
values must match the pointer's prior current/previous. 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
|
||||
|
||||
|
||||
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
|
||||
DISTRIBUTION_ADDRESS = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
ACTION_ADDRESS = (
|
||||
"module.environment_owned.action.aws_cloudfront_create_invalidation.release"
|
||||
)
|
||||
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"}
|
||||
POINTER_UNKNOWN_ATTRIBUTES = frozenset({"etag", "version_id"})
|
||||
DISTRIBUTION_UNKNOWN_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"etag",
|
||||
"last_modified_time",
|
||||
"status",
|
||||
"in_progress_validation_batches",
|
||||
}
|
||||
)
|
||||
# Not an after_unknown allowlist. AWS returns 0 when the timeout is unset;
|
||||
# the provider writes null on origin_path updates. Treat 0, null, and a
|
||||
# missing key as the same. Any other value still fails closed.
|
||||
ORIGIN_RESPONSE_COMPLETION_TIMEOUT = "response_completion_timeout"
|
||||
ORIGIN_TIMEOUT_UNSET = frozenset({0, None})
|
||||
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 current release the plan must apply. Empty string is the legacy root.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-previous-version-label",
|
||||
default="",
|
||||
help="Previous release label the origin group must fail over to.",
|
||||
)
|
||||
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],
|
||||
*,
|
||||
computed_unknown: frozenset[str],
|
||||
) -> 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:
|
||||
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 _label_ok(label: str) -> bool:
|
||||
return label == "" or bool(VERSION_LABEL_RE.fullmatch(label))
|
||||
|
||||
|
||||
def origin_path_for_label(label: str) -> str:
|
||||
return "" if label == "" else f"/releases/{label}"
|
||||
|
||||
|
||||
def _origin_map(origins: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(origins, list):
|
||||
return {}
|
||||
mapped: dict[str, dict[str, Any]] = {}
|
||||
for origin in origins:
|
||||
if not isinstance(origin, dict):
|
||||
continue
|
||||
origin_id = origin.get("origin_id")
|
||||
if not isinstance(origin_id, str) or not origin_id:
|
||||
continue
|
||||
mapped[origin_id] = origin
|
||||
return mapped
|
||||
|
||||
|
||||
def _origin_paths(origins: Any) -> dict[str, str]:
|
||||
return {
|
||||
origin_id: origin.get("origin_path") or ""
|
||||
for origin_id, origin in _origin_map(origins).items()
|
||||
}
|
||||
|
||||
|
||||
def _decode_pointer(content: Any) -> dict[str, str]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return {
|
||||
"current": payload.get("current") or "",
|
||||
"previous": payload.get("previous") or "",
|
||||
}
|
||||
|
||||
|
||||
def _validate_pointer(
|
||||
resource: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(change, computed_unknown=POINTER_UNKNOWN_ATTRIBUTES)
|
||||
if changed != {"content"}:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: expected only content to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
after = _decode_pointer((change.get("after") or {}).get("content"))
|
||||
if after.get("current") != expected_current:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after current {after.get('current')!r} does not match "
|
||||
f"{expected_current!r}"
|
||||
)
|
||||
if after.get("previous") != expected_previous:
|
||||
violations.append(
|
||||
f"{POINTER_ADDRESS}: after previous {after.get('previous')!r} does not match "
|
||||
f"{expected_previous!r}"
|
||||
)
|
||||
unknown = change.get("after_unknown") or {}
|
||||
if unknown.get("content") is True:
|
||||
violations.append(f"{POINTER_ADDRESS}: content after value is unknown")
|
||||
return violations
|
||||
|
||||
|
||||
def _origin_fields_for_compare(origin: dict[str, Any]) -> dict[str, Any]:
|
||||
rest = {key: value for key, value in origin.items() if key != "origin_path"}
|
||||
timeout = rest.get(ORIGIN_RESPONSE_COMPLETION_TIMEOUT)
|
||||
if timeout in ORIGIN_TIMEOUT_UNSET:
|
||||
rest.pop(ORIGIN_RESPONSE_COMPLETION_TIMEOUT, None)
|
||||
return rest
|
||||
|
||||
|
||||
def _origin_non_path_fields_changed(before: dict[str, Any], after: dict[str, Any]) -> bool:
|
||||
return _origin_fields_for_compare(before) != _origin_fields_for_compare(after)
|
||||
|
||||
|
||||
def _validate_distribution(
|
||||
resource: dict[str, Any],
|
||||
pointer_before: dict[str, str],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(
|
||||
change, computed_unknown=DISTRIBUTION_UNKNOWN_ATTRIBUTES
|
||||
)
|
||||
if changed != {"origin"}:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: expected only origin to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
return violations
|
||||
|
||||
before_origins = _origin_map((change.get("before") or {}).get("origin"))
|
||||
after_origins = _origin_map((change.get("after") or {}).get("origin"))
|
||||
if set(before_origins) != set(after_origins):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin IDs changed "
|
||||
f"from {sorted(before_origins)} to {sorted(after_origins)}"
|
||||
)
|
||||
return violations
|
||||
|
||||
for origin_id, before_origin in before_origins.items():
|
||||
if _origin_non_path_fields_changed(before_origin, after_origins[origin_id]):
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: origin {origin_id!r} changed a field other than origin_path"
|
||||
)
|
||||
|
||||
after_paths = sorted(_origin_paths((change.get("after") or {}).get("origin")).values())
|
||||
expected_after = sorted(
|
||||
[
|
||||
origin_path_for_label(expected_current),
|
||||
origin_path_for_label(expected_previous),
|
||||
]
|
||||
)
|
||||
if after_paths != expected_after:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: after origin_path {after_paths} does not match "
|
||||
f"{expected_after}"
|
||||
)
|
||||
|
||||
before_paths = sorted(_origin_paths((change.get("before") or {}).get("origin")).values())
|
||||
expected_before = sorted(
|
||||
[
|
||||
origin_path_for_label(pointer_before.get("current", "")),
|
||||
origin_path_for_label(pointer_before.get("previous", "")),
|
||||
]
|
||||
)
|
||||
if before_paths != expected_before:
|
||||
violations.append(
|
||||
f"{DISTRIBUTION_ADDRESS}: before origin_path {before_paths} does not match "
|
||||
f"pointer prior values {expected_before}"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_actions(plan: dict[str, Any]) -> list[str]:
|
||||
invocations = plan.get("action_invocations")
|
||||
if invocations is None:
|
||||
return ["plan is missing action_invocations"]
|
||||
if not isinstance(invocations, list):
|
||||
return ["action_invocations must be a list"]
|
||||
addresses = [
|
||||
item.get("address")
|
||||
for item in invocations
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if addresses != [ACTION_ADDRESS]:
|
||||
return [
|
||||
"expected exactly one action_invocations entry "
|
||||
f"{ACTION_ADDRESS}, found {addresses}"
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def validate_plan(
|
||||
plan: dict[str, Any],
|
||||
expected_current: str,
|
||||
expected_previous: str,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
if not _label_ok(expected_current):
|
||||
violations.append(
|
||||
"expected version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
if not _label_ok(expected_previous):
|
||||
violations.append(
|
||||
"expected previous version label must be empty or <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
|
||||
updates: dict[str, 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[address] = resource
|
||||
if action_set != {"update"}:
|
||||
violations.append(
|
||||
f"{address}: update must be the only action, got {actions}"
|
||||
)
|
||||
|
||||
if address not in {POINTER_ADDRESS, DISTRIBUTION_ADDRESS} and (
|
||||
action_set - IGNORED_ACTIONS
|
||||
):
|
||||
violations.append(
|
||||
f"{address}: managed address is outside the content-release update"
|
||||
)
|
||||
|
||||
if set(updates) != {POINTER_ADDRESS, DISTRIBUTION_ADDRESS}:
|
||||
violations.append(
|
||||
"expected exactly the pointer and distribution updates, found "
|
||||
f"{sorted(updates)}"
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
return violations
|
||||
|
||||
pointer_change = updates[POINTER_ADDRESS].get("change") or {}
|
||||
pointer_before = _decode_pointer((pointer_change.get("before") or {}).get("content"))
|
||||
violations.extend(
|
||||
_validate_pointer(updates[POINTER_ADDRESS], expected_current, expected_previous)
|
||||
)
|
||||
violations.extend(
|
||||
_validate_distribution(
|
||||
updates[DISTRIBUTION_ADDRESS],
|
||||
pointer_before,
|
||||
expected_current,
|
||||
expected_previous,
|
||||
)
|
||||
)
|
||||
violations.extend(_validate_actions(plan))
|
||||
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,
|
||||
args.expected_previous_version_label,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not a content-release update", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.evidence_out:
|
||||
evidence = {
|
||||
"pointer_address": POINTER_ADDRESS,
|
||||
"distribution_address": DISTRIBUTION_ADDRESS,
|
||||
"action_address": ACTION_ADDRESS,
|
||||
"expected_version_label": args.expected_version_label,
|
||||
"expected_previous_version_label": args.expected_previous_version_label,
|
||||
"managed_updates": 2,
|
||||
"action_invocations": 1,
|
||||
"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: content-release plan updates "
|
||||
f"{POINTER_ADDRESS} and {DISTRIBUTION_ADDRESS} to "
|
||||
f"{args.expected_version_label} (previous {args.expected_previous_version_label!r})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
|
||||
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
|
||||
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
|
||||
# the right cache headers, and invalidates CloudFront.
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
|
||||
# so it has no hardcoded resource IDs.
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
|
|
@ -16,24 +18,35 @@ REGION="${AWS_REGION:-us-east-1}"
|
|||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
|
|
@ -41,6 +54,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
|||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
|
|
|
|||
|
|
@ -17,6 +17,17 @@ const MAINTAINABILITY_RULES = [
|
|||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
// Repository-level gates that run after the source gates. Each is an npm
|
||||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform release-plan contract", "test:terraform-release-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["HCP run guard", "test:hcp-run-guard"],
|
||||
["CloudFront release verify", "test:cloudfront-release-verify"],
|
||||
["GitHub workflow shell", "test:github-workflows"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
|
|
@ -194,6 +205,20 @@ function plural(count, word) {
|
|||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function runRepositoryGate(label, script) {
|
||||
// Reuse the npm that launched us when available (matches its version and
|
||||
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
|
||||
const npmCli = process.env.npm_execpath;
|
||||
const executable = npmCli ? process.execPath : "npm";
|
||||
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
|
|
@ -281,6 +306,17 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [label, script] of REPOSITORY_GATES) {
|
||||
console.log("─".repeat(64));
|
||||
console.log(`${label}: npm run ${script}`);
|
||||
const gate = runRepositoryGate(label, script);
|
||||
if (gate.error) {
|
||||
failures.push(`${label}: could not start: ${gate.error.message}`);
|
||||
} else if (gate.status !== 0) {
|
||||
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
|
|
|
|||
207
scripts/hcp-run-guard.py
Executable file
207
scripts/hcp-run-guard.py
Executable file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Guard HCP Terraform runs used by GitHub content CD.
|
||||
|
||||
Subcommands:
|
||||
check-and-discard Refuse unsafe workspace settings. Discard a blocking
|
||||
non-speculative VCS run so GitHub CD can create-run.
|
||||
reconcile-apply Treat an HCP run whose status is already ``applied`` as
|
||||
success when the GitHub apply-run step reported failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
API = "https://app.terraform.io/api/v2"
|
||||
DEFAULT_WORKSPACE = "shoc-frontend-new-dev"
|
||||
EXPECTED_TRIGGER_PATTERNS = [
|
||||
"terraform/live/dev/**",
|
||||
"terraform/live/modules/**",
|
||||
]
|
||||
DISCARDABLE = {
|
||||
"pending",
|
||||
"planned",
|
||||
"cost_estimated",
|
||||
"policy_checked",
|
||||
"policy_override",
|
||||
}
|
||||
APPLYING = {"applying", "apply_queued"}
|
||||
|
||||
HttpGet = Callable[[str], dict[str, Any]]
|
||||
HttpPost = Callable[[str, dict[str, Any]], int]
|
||||
|
||||
|
||||
class GuardError(Exception):
|
||||
"""Refused to continue."""
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
}
|
||||
|
||||
|
||||
def default_get(token: str) -> HttpGet:
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(url, headers=_headers(token))
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def default_post(token: str) -> HttpPost:
|
||||
def post(url: str, payload: dict[str, Any]) -> int:
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method="POST", headers=_headers(token)
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return int(response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (409, 404):
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
print(f"discard returned HTTP {exc.code}: {body}")
|
||||
return exc.code
|
||||
raise
|
||||
|
||||
return post
|
||||
|
||||
|
||||
def require_token(token: str) -> str:
|
||||
if not token:
|
||||
raise GuardError("TF_API_TOKEN is required")
|
||||
return token
|
||||
|
||||
|
||||
def check_invariants(attrs: dict[str, Any], workspace: str) -> None:
|
||||
if attrs.get("auto-apply") is True:
|
||||
raise GuardError(f"{workspace} auto-apply is on; refuse to continue")
|
||||
if not attrs.get("speculative-enabled"):
|
||||
raise GuardError("speculative plans are off; refuse to continue")
|
||||
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
|
||||
raise GuardError("tag-based VCS triggering is set; refuse to continue")
|
||||
if attrs.get("trigger-patterns") != EXPECTED_TRIGGER_PATTERNS:
|
||||
raise GuardError(
|
||||
"trigger-patterns must be "
|
||||
f"{EXPECTED_TRIGGER_PATTERNS}; got {attrs.get('trigger-patterns')}"
|
||||
)
|
||||
|
||||
|
||||
def check_and_discard(
|
||||
*,
|
||||
workspace: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
post: HttpPost | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
get = get or default_get(token)
|
||||
post = post or default_post(token)
|
||||
workspace_payload = get(
|
||||
f"{API}/organizations/seahaven/workspaces/{workspace}"
|
||||
)["data"]
|
||||
attrs = workspace_payload["attributes"]
|
||||
check_invariants(attrs, workspace)
|
||||
if not attrs.get("locked"):
|
||||
print("workspace is unlocked")
|
||||
return 0
|
||||
|
||||
current = (
|
||||
workspace_payload.get("relationships", {})
|
||||
.get("current-run", {})
|
||||
.get("data")
|
||||
)
|
||||
if not current:
|
||||
raise GuardError("workspace is locked without a current run")
|
||||
run_id = current["id"]
|
||||
run = get(f"{API}/runs/{run_id}")["data"]
|
||||
run_attrs = run["attributes"]
|
||||
status = run_attrs.get("status")
|
||||
plan_only = run_attrs.get("plan-only")
|
||||
print(f"current run {run_id} status={status} plan-only={plan_only}")
|
||||
if plan_only:
|
||||
print("speculative run does not block GitHub CD")
|
||||
return 0
|
||||
if status in APPLYING:
|
||||
raise GuardError(f"{run_id} is {status}; wait, do not discard an apply")
|
||||
if status not in DISCARDABLE:
|
||||
raise GuardError(f"{run_id} status {status} is not discardable")
|
||||
code = post(
|
||||
f"{API}/runs/{run_id}/actions/discard",
|
||||
{
|
||||
"comment": (
|
||||
"Discarded so GitHub CD can create the content-release applyable run"
|
||||
)
|
||||
},
|
||||
)
|
||||
print(f"discarded {run_id} http={code}")
|
||||
return 0
|
||||
|
||||
|
||||
def reconcile_apply(
|
||||
*,
|
||||
run_id: str,
|
||||
apply_outcome: str,
|
||||
token: str,
|
||||
get: HttpGet | None = None,
|
||||
) -> int:
|
||||
token = require_token(token)
|
||||
if not run_id:
|
||||
raise GuardError("run id is required")
|
||||
if apply_outcome == "success":
|
||||
print("Apply succeeded.")
|
||||
return 0
|
||||
get = get or default_get(token)
|
||||
status = get(f"{API}/runs/{run_id}")["data"]["attributes"]["status"]
|
||||
print(f"HCP run {run_id} status={status}")
|
||||
if status == "applied":
|
||||
return 0
|
||||
raise GuardError(
|
||||
f"Apply failed: GitHub outcome={apply_outcome} HCP status={status}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
check = sub.add_parser("check-and-discard")
|
||||
check.add_argument("--workspace", default=DEFAULT_WORKSPACE)
|
||||
check.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
|
||||
reconcile = sub.add_parser("reconcile-apply")
|
||||
reconcile.add_argument("--run-id", required=True)
|
||||
reconcile.add_argument(
|
||||
"--apply-outcome",
|
||||
default=os.environ.get("APPLY_OUTCOME", ""),
|
||||
)
|
||||
reconcile.add_argument("--token", default=os.environ.get("TF_API_TOKEN", ""))
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
if args.command == "check-and-discard":
|
||||
return check_and_discard(workspace=args.workspace, token=args.token)
|
||||
return reconcile_apply(
|
||||
run_id=args.run_id,
|
||||
apply_outcome=args.apply_outcome,
|
||||
token=args.token,
|
||||
)
|
||||
except GuardError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
29
scripts/read-release-pointer.py
Executable file
29
scripts/read-release-pointer.py
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
data = json.loads(raw) if raw else {}
|
||||
current = data.get("current") or ""
|
||||
previous = data.get("previous") or ""
|
||||
output_path = os.environ["GITHUB_OUTPUT"]
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"live_current={current}\n")
|
||||
handle.write(f"live_previous={previous}\n")
|
||||
print(
|
||||
"Pointer live current="
|
||||
+ (current or "<empty>")
|
||||
+ " previous="
|
||||
+ (previous or "<empty>")
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
23
scripts/summarize-cloudfront-live-state.sh
Executable file
23
scripts/summarize-cloudfront-live-state.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# Print pointer body, origin paths, distribution status, and served index hash.
|
||||
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
|
||||
set -u
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
|
||||
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
|
||||
echo "=== CloudFront live state ==="
|
||||
echo "pointer:"
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
|
||||
import json, sys
|
||||
payload = json.load(sys.stdin)
|
||||
dist = payload.get("Distribution") or {}
|
||||
config = dist.get("DistributionConfig") or {}
|
||||
print("status:", dist.get("Status"))
|
||||
for origin in ((config.get("Origins") or {}).get("Items") or []):
|
||||
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
|
||||
'
|
||||
echo
|
||||
echo -n "served index sha256: "
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
|
||||
35
scripts/terraform-validate.mjs
Normal file
35
scripts/terraform-validate.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
|
||||
const ENVIRONMENTS = ["dev"];
|
||||
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
|
||||
|
||||
function run(args, cwd = ROOT) {
|
||||
const result = spawnSync(TERRAFORM, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`could not start Terraform: ${result.error.message}`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||
for (const root of ROOTS) {
|
||||
// -backend=false never touches HCP state; -lockfile=readonly refuses to
|
||||
// silently rewrite the committed provider lock.
|
||||
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
|
||||
run(["validate", "-no-color"], root);
|
||||
}
|
||||
|
||||
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
|
||||
130
scripts/terraform_import_plan_resources.py
Executable file
130
scripts/terraform_import_plan_resources.py
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps.
|
||||
|
||||
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
|
||||
are kept so the checker can prove that a dev plan carrying a staging identifier
|
||||
is rejected; they do not authorize a staging import.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
|
||||
"aws_s3_bucket_public_access_block"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
|
||||
"aws_s3_bucket_ownership_controls"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
|
||||
"aws_s3_bucket_server_side_encryption_configuration"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": (
|
||||
"aws_cloudfront_distribution"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"aws_cloudfront_origin_access_control"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"aws_cloudfront_function"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
environment: dict(COMMON_RESOURCES)
|
||||
for environment in ("dev", "staging")
|
||||
}
|
||||
|
||||
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||
{
|
||||
"module.environment_owned.aws_s3_bucket.site",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||
"module.environment_owned.aws_iam_role.github_deploy",
|
||||
}
|
||||
)
|
||||
|
||||
ENVIRONMENT_CONFIG = {
|
||||
"dev": {
|
||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-dev",
|
||||
"distribution_id": "E2CWLM1AFB964P",
|
||||
"workspace_name": "shoc-frontend-new-dev",
|
||||
},
|
||||
"staging": {
|
||||
"bucket_name": "seahaven-shoc-frontend-staging",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-staging",
|
||||
"distribution_id": "E2JDVEZ6EGD49J",
|
||||
"workspace_name": "shoc-frontend-new-staging",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bucket_imports(bucket_name: str) -> dict[str, str]:
|
||||
return {
|
||||
address: bucket_name
|
||||
for address in COMMON_RESOURCES
|
||||
if address.startswith("module.environment_owned.aws_s3_bucket")
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
||||
"dev": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-dev"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E30VSIK87N8H64"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
"staging": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E1PF5R6QQNBZAI"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
}
|
||||
243
scripts/test-hcp-run-guard.py
Executable file
243
scripts/test-hcp-run-guard.py
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for every hcp-run-guard refusal, exit-0, discard, and reconcile case."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT = Path(__file__).with_name("hcp-run-guard.py")
|
||||
WORKSPACE = "shoc-frontend-new-dev"
|
||||
PATTERNS = [
|
||||
"terraform/live/dev/**",
|
||||
"terraform/live/modules/**",
|
||||
]
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("hcp_run_guard", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def workspace_payload(
|
||||
*,
|
||||
auto_apply: bool = False,
|
||||
speculative: bool = True,
|
||||
tags_regex: str | None = None,
|
||||
trigger_patterns: list[str] | None = None,
|
||||
locked: bool = False,
|
||||
current_run: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": {
|
||||
"attributes": {
|
||||
"auto-apply": auto_apply,
|
||||
"speculative-enabled": speculative,
|
||||
"vcs-repo": {"tags-regex": tags_regex},
|
||||
"trigger-patterns": PATTERNS if trigger_patterns is None else trigger_patterns,
|
||||
"locked": locked,
|
||||
},
|
||||
"relationships": {
|
||||
"current-run": {"data": current_run},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def run_payload(*, status: str, plan_only: bool = False) -> dict[str, Any]:
|
||||
return {"data": {"attributes": {"status": status, "plan-only": plan_only}}}
|
||||
|
||||
|
||||
def check(module, payloads: dict[str, Any], posts: list | None = None):
|
||||
calls: list[str] = []
|
||||
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
calls.append(url)
|
||||
if url not in payloads:
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
return payloads[url]
|
||||
|
||||
recorded: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def post(url: str, payload: dict[str, Any]) -> int:
|
||||
recorded.append((url, payload))
|
||||
if posts:
|
||||
return posts.pop(0)
|
||||
return 202
|
||||
|
||||
try:
|
||||
code = module.check_and_discard(
|
||||
workspace=WORKSPACE,
|
||||
token="test-token",
|
||||
get=get,
|
||||
post=post,
|
||||
)
|
||||
return code, None, calls, recorded
|
||||
except module.GuardError as exc:
|
||||
return 1, str(exc), calls, recorded
|
||||
|
||||
|
||||
def reconcile(module, outcome: str, payloads: dict[str, Any], run_id: str = "run-1"):
|
||||
def get(url: str) -> dict[str, Any]:
|
||||
if url not in payloads:
|
||||
raise AssertionError(f"unexpected GET {url}")
|
||||
return payloads[url]
|
||||
|
||||
try:
|
||||
code = module.reconcile_apply(
|
||||
run_id=run_id,
|
||||
apply_outcome=outcome,
|
||||
token="test-token",
|
||||
get=get,
|
||||
)
|
||||
return code, None
|
||||
except module.GuardError as exc:
|
||||
return 1, str(exc)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
module = load_module()
|
||||
ws = f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{WORKSPACE}"
|
||||
run_url = "https://app.terraform.io/api/v2/runs/run-1"
|
||||
discard_url = f"{run_url}/actions/discard"
|
||||
failures: list[str] = []
|
||||
|
||||
def expect_refuse(name: str, payloads: dict[str, Any], fragment: str) -> None:
|
||||
code, error, _, recorded = check(module, payloads)
|
||||
if code != 1 or not error or fragment not in error:
|
||||
failures.append(f"{name}: expected refuse containing {fragment!r}, got {code} {error}")
|
||||
if recorded:
|
||||
failures.append(f"{name}: discard was posted on a refusal")
|
||||
|
||||
expect_refuse(
|
||||
"auto-apply",
|
||||
{ws: workspace_payload(auto_apply=True)},
|
||||
"auto-apply is on",
|
||||
)
|
||||
expect_refuse(
|
||||
"speculative-off",
|
||||
{ws: workspace_payload(speculative=False)},
|
||||
"speculative plans are off",
|
||||
)
|
||||
expect_refuse(
|
||||
"tags-regex",
|
||||
{ws: workspace_payload(tags_regex="^v")},
|
||||
"tag-based VCS triggering",
|
||||
)
|
||||
expect_refuse(
|
||||
"wrong-patterns",
|
||||
{ws: workspace_payload(trigger_patterns=["terraform/**"])},
|
||||
"trigger-patterns must be",
|
||||
)
|
||||
expect_refuse(
|
||||
"locked-without-run",
|
||||
{ws: workspace_payload(locked=True, current_run=None)},
|
||||
"locked without a current run",
|
||||
)
|
||||
expect_refuse(
|
||||
"applying",
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="applying"),
|
||||
},
|
||||
"wait, do not discard an apply",
|
||||
)
|
||||
expect_refuse(
|
||||
"not-discardable",
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="errored"),
|
||||
},
|
||||
"is not discardable",
|
||||
)
|
||||
|
||||
code, error, _, recorded = check(module, {ws: workspace_payload(locked=False)})
|
||||
if code != 0 or error is not None or recorded:
|
||||
failures.append(f"unlocked: expected exit 0, got {code} {error} {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="planned", plan_only=True),
|
||||
},
|
||||
)
|
||||
if code != 0 or recorded:
|
||||
failures.append(f"plan-only: expected exit 0 without discard, got {code} {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="planned"),
|
||||
},
|
||||
)
|
||||
if code != 0 or error is not None:
|
||||
failures.append(f"discard: expected exit 0, got {code} {error}")
|
||||
if not recorded or recorded[0][0] != discard_url:
|
||||
failures.append(f"discard: posted {recorded}")
|
||||
|
||||
code, error, _, recorded = check(
|
||||
module,
|
||||
{
|
||||
ws: workspace_payload(locked=True, current_run={"id": "run-1"}),
|
||||
run_url: run_payload(status="policy_checked"),
|
||||
},
|
||||
posts=[409],
|
||||
)
|
||||
if code != 0:
|
||||
failures.append(f"discard-409: expected exit 0, got {code} {error}")
|
||||
|
||||
try:
|
||||
module.check_and_discard(workspace=WORKSPACE, token="", get=lambda _url: {})
|
||||
failures.append("missing-token: accepted empty token")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
code, error = reconcile(module, "success", {})
|
||||
if code != 0:
|
||||
failures.append(f"reconcile-success: expected 0, got {code} {error}")
|
||||
|
||||
code, error = reconcile(
|
||||
module,
|
||||
"failure",
|
||||
{run_url: run_payload(status="applied")},
|
||||
)
|
||||
if code != 0:
|
||||
failures.append(f"reconcile-applied: expected 0, got {code} {error}")
|
||||
|
||||
code, error = reconcile(
|
||||
module,
|
||||
"failure",
|
||||
{run_url: run_payload(status="errored")},
|
||||
)
|
||||
if code != 1 or not error or "errored" not in error:
|
||||
failures.append(f"reconcile-errored: expected refuse, got {code} {error}")
|
||||
|
||||
try:
|
||||
module.reconcile_apply(run_id="", apply_outcome="failure", token="test-token")
|
||||
failures.append("reconcile-missing-run: accepted empty run id")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
try:
|
||||
module.reconcile_apply(run_id="run-1", apply_outcome="failure", token="")
|
||||
failures.append("reconcile-missing-token: accepted empty token")
|
||||
except module.GuardError:
|
||||
pass
|
||||
|
||||
if failures:
|
||||
print("FAIL: hcp-run-guard cases failed", file=__import__("sys").stderr)
|
||||
for item in failures:
|
||||
print(f" - {item}", file=__import__("sys").stderr)
|
||||
return 1
|
||||
print("PASS: HCP run guard checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
533
scripts/test-terraform-import-plan-check.py
Executable file
533
scripts/test-terraform-import-plan-check.py
Executable file
|
|
@ -0,0 +1,533 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic unit tests for the frontend Terraform plan checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||
REPOSITORY = SCRIPT.parent.parent
|
||||
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY}
|
||||
|
||||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
assert expected is not None, f"{environment} must pin an import ID for {address}"
|
||||
return expected
|
||||
|
||||
|
||||
def distribution_id(environment: str) -> str:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
|
||||
return configured
|
||||
|
||||
|
||||
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tag_change(environment: str, address: str) -> dict[str, Any]:
|
||||
manager = {
|
||||
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
|
||||
}
|
||||
before_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "cdk",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
after_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
if address == ROLE:
|
||||
before_tags.update(manager)
|
||||
after_tags.update(manager)
|
||||
if address == BUCKET:
|
||||
before_tags["aws-cdk:auto-delete-objects"] = "true"
|
||||
before: dict[str, Any] = {
|
||||
"tags": before_tags,
|
||||
"tags_all": before_tags,
|
||||
}
|
||||
after: dict[str, Any] = {
|
||||
"tags": after_tags,
|
||||
"tags_all": after_tags,
|
||||
}
|
||||
if address == DISTRIBUTION:
|
||||
before["id"] = distribution_id(environment)
|
||||
after["id"] = distribution_id(environment)
|
||||
return {"actions": ["update"], "before": before, "after": after}
|
||||
|
||||
|
||||
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||
if address != BUCKET_POLICY:
|
||||
raise AssertionError(f"{address} is not a reviewed policy update")
|
||||
return {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": json.dumps(pre_adoption_bucket_policy(environment))},
|
||||
"after": {"policy": json.dumps(bucket_policy(environment))},
|
||||
}
|
||||
|
||||
|
||||
def make_plan(
|
||||
environment: str,
|
||||
*,
|
||||
mode: str = "import",
|
||||
controlled_updates: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resources: list[dict[str, Any]] = []
|
||||
updates = controlled_updates or set()
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if mode == "import":
|
||||
change: dict[str, Any] = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id(environment, address)},
|
||||
}
|
||||
elif mode == "post-import":
|
||||
change = {"actions": ["no-op"]}
|
||||
elif address in updates:
|
||||
change = (
|
||||
tag_change(environment, address)
|
||||
if address in TAG_ADDRESSES
|
||||
else policy_change(environment, address)
|
||||
)
|
||||
else:
|
||||
change = {"actions": ["no-op"]}
|
||||
if address == DISTRIBUTION:
|
||||
change["after"] = {"id": distribution_id(environment)}
|
||||
resources.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": change,
|
||||
}
|
||||
)
|
||||
return {"resource_changes": resources}
|
||||
|
||||
|
||||
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
|
||||
return next(
|
||||
item for item in plan["resource_changes"] if item["address"] == address
|
||||
)
|
||||
|
||||
|
||||
def run_checker(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "plan.json"
|
||||
path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(path),
|
||||
"--environment",
|
||||
environment,
|
||||
]
|
||||
if post_import:
|
||||
command.append("--post-import-no-op")
|
||||
for address in allowed_updates:
|
||||
command.extend(["--allow-update-address", address])
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportPlanCheckerTests(unittest.TestCase):
|
||||
def assert_passes(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def assert_fails(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||
source = (
|
||||
REPOSITORY
|
||||
/ "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = """ spa_rewrite_code = join("\\n", [
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
])"""
|
||||
self.assertIn(expected, source)
|
||||
|
||||
def test_only_dev_has_a_live_root(self) -> None:
|
||||
live_roots = sorted(
|
||||
path.name
|
||||
for path in (REPOSITORY / "terraform/live").iterdir()
|
||||
if path.is_dir() and path.name != "modules"
|
||||
)
|
||||
self.assertEqual(["dev"], live_roots)
|
||||
|
||||
def test_dev_root_pins_adoption_complete_in_code(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
self.assertRegex(source, r"\n\s+adoption_complete\s+= true\n")
|
||||
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
|
||||
self.assertNotIn('variable "adoption_complete"', source)
|
||||
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
|
||||
self.assertNotIn(
|
||||
"variable ",
|
||||
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
|
||||
root_file,
|
||||
)
|
||||
|
||||
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
expected = {
|
||||
"dev": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
for name, value in zip(
|
||||
(
|
||||
"hosted_zone_id",
|
||||
"certificate_arn",
|
||||
"github_oidc_provider_arn",
|
||||
"cache_policy_id",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
):
|
||||
self.assertIn(f"{name}", source)
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
|
||||
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_passes(make_plan(environment), environment)
|
||||
|
||||
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
|
||||
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
|
||||
plan = make_plan("dev")
|
||||
if mutation == "missing":
|
||||
plan["resource_changes"].pop()
|
||||
elif mutation == "extra":
|
||||
plan["resource_changes"].append(
|
||||
{
|
||||
"address": "module.inventory.aws_route53_zone.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_zone",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": "Z00000000000000000000"},
|
||||
},
|
||||
}
|
||||
)
|
||||
elif mutation == "wrong-type":
|
||||
plan["resource_changes"][0]["type"] = "aws_s3_object"
|
||||
else:
|
||||
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
|
||||
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
|
||||
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["actions"] = actions
|
||||
with self.subTest(actions=actions):
|
||||
self.assert_fails(plan, "dev")
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_post_import_no_op_passes(self) -> None:
|
||||
self.assert_passes(
|
||||
make_plan("staging", mode="post-import"),
|
||||
"staging",
|
||||
post_import=True,
|
||||
)
|
||||
|
||||
def test_post_import_rejects_import_metadata_and_update(self) -> None:
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["actions"] = ["update"]
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
|
||||
def test_every_allowed_controlled_diff_passes(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in CONTROLLED_UPDATE_ADDRESSES:
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
),
|
||||
environment,
|
||||
address,
|
||||
)
|
||||
|
||||
def test_full_exact_controlled_allowlist_passes(self) -> None:
|
||||
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates=set(addresses),
|
||||
),
|
||||
"dev",
|
||||
*addresses,
|
||||
)
|
||||
|
||||
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
|
||||
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||
self.assert_fails(plan, "dev", BUCKET)
|
||||
|
||||
def test_role_trust_change_is_rejected(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||
for mutation in ("principal", "extra"):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={BUCKET_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(bucket_policy("dev"))
|
||||
if mutation == "principal":
|
||||
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
|
||||
source = (
|
||||
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
|
||||
document = document.split("resource ", 1)[0]
|
||||
self.assertNotIn("var.adoption_complete", document)
|
||||
self.assertIn("ListReleasePrefixes", document)
|
||||
self.assertIn("PublishReleasePrefix", document)
|
||||
self.assertIn("ReadReleasePointer", document)
|
||||
self.assertIn("ReadDistribution", document)
|
||||
self.assertIn("cloudfront:GetDistribution", document)
|
||||
self.assertIn("cloudfront:GetDistributionConfig", document)
|
||||
self.assertIn("releases/*", document)
|
||||
self.assertNotIn("AssumeCdkBootstrapRoles", document)
|
||||
self.assertNotIn("DescribeStack", document)
|
||||
self.assertNotIn("CreateInvalidation", document)
|
||||
self.assertNotIn("ReadDeploymentBucket", document)
|
||||
self.assertNotIn("PublishAndRollbackSiteObjects", document)
|
||||
self.assertNotIn(
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
)
|
||||
|
||||
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", DEPLOY_POLICY)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
resource(plan, DEPLOY_POLICY)["change"] = {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": "{}"},
|
||||
"after": {"policy": '{"Version":"2012-10-17"}'},
|
||||
}
|
||||
self.assert_fails(plan, "dev", DEPLOY_POLICY)
|
||||
|
||||
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
plan = make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={BUCKET_POLICY},
|
||||
)
|
||||
change = resource(plan, BUCKET_POLICY)["change"]
|
||||
before = json.loads(change["before"]["policy"])
|
||||
before["Statement"].append(
|
||||
{
|
||||
"Sid": "UnexpectedDrift",
|
||||
"Effect": "Deny",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
change["before"]["policy"] = json.dumps(before)
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_fails(plan, environment, BUCKET_POLICY)
|
||||
|
||||
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
|
||||
for field, value in (
|
||||
("after_unknown", {"tags": {"ManagedBy": True}}),
|
||||
("replace_paths", [["tags"]]),
|
||||
):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={ROLE},
|
||||
)
|
||||
resource(plan, ROLE)["change"][field] = value
|
||||
with self.subTest(field=field):
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
363
scripts/test-terraform-release-plan-check.py
Executable file
363
scripts/test-terraform-release-plan-check.py
Executable file
|
|
@ -0,0 +1,363 @@
|
|||
#!/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"
|
||||
EXPECTED_PREVIOUS = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
PLAN_ID = "plan-8F5JFydVYAmtTjET"
|
||||
POINTER_ADDRESS = "module.environment_owned.aws_s3_object.release_pointer"
|
||||
|
||||
|
||||
def run_case(
|
||||
fixture_name: str,
|
||||
*,
|
||||
expected_label: str = EXPECTED_LABEL,
|
||||
expected_previous: str = EXPECTED_PREVIOUS,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(FIXTURES / fixture_name),
|
||||
"--expected-version-label",
|
||||
expected_label,
|
||||
"--expected-previous-version-label",
|
||||
expected_previous,
|
||||
],
|
||||
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] = []
|
||||
addresses = [item["address"] for item in plan["resource_changes"]]
|
||||
if POINTER_ADDRESS not in addresses:
|
||||
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
|
||||
|
||||
addresses = [item["address"] for item in plan["resource_changes"]]
|
||||
if POINTER_ADDRESS not in addresses:
|
||||
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 test_deploy_workflow_uses_script_flags() -> list[str]:
|
||||
workflow = (
|
||||
Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
if workflow.count("--expected-version-label") < 2:
|
||||
failures.append(
|
||||
"deploy.yml must pass --expected-version-label on release and rollback"
|
||||
)
|
||||
if workflow.count("--expected-previous-version-label") < 2:
|
||||
failures.append(
|
||||
"deploy.yml must pass --expected-previous-version-label on release and rollback"
|
||||
)
|
||||
for forbidden in (
|
||||
"--expected-current-label",
|
||||
"--expected-previous-label",
|
||||
"--before-current-label",
|
||||
"--before-previous-label",
|
||||
"--current-origin-id",
|
||||
"--previous-origin-id",
|
||||
"CURRENT_ORIGIN_ID",
|
||||
):
|
||||
if forbidden in workflow:
|
||||
failures.append(f"deploy.yml still passes unknown flag {forbidden}")
|
||||
return failures
|
||||
|
||||
|
||||
def test_deploy_workflow_confirms_prefix_with_head_object() -> list[str]:
|
||||
workflow = (
|
||||
Path(__file__).resolve().parents[1] / ".github/workflows/deploy.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
if "aws s3api head-object" not in workflow:
|
||||
failures.append(
|
||||
"deploy.yml must confirm the uploaded index.html with s3api head-object"
|
||||
)
|
||||
if "aws s3 ls" in workflow:
|
||||
failures.append("deploy.yml must not list the prefix with aws s3 ls")
|
||||
if any(
|
||||
line.lstrip().startswith("run:") and "npm run verify" in line
|
||||
for line in workflow.splitlines()
|
||||
):
|
||||
failures.append(
|
||||
"deploy.yml must not re-run npm run verify; Frontend checks owns that gate"
|
||||
)
|
||||
if any(line.lstrip().startswith("pull_request:") for line in workflow.splitlines()):
|
||||
failures.append(
|
||||
"deploy.yml must not run on pull_request; Frontend checks owns PR verify"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cases = [
|
||||
("version-only", run_case("version-only.json"), 0),
|
||||
(
|
||||
"origin-timeout-normalization",
|
||||
run_case("origin-timeout-normalization.json"),
|
||||
0,
|
||||
),
|
||||
("origin-timeout-change", run_case("origin-timeout-change.json"), 1),
|
||||
("wrong-label", run_case("wrong-label.json"), 1),
|
||||
("wrong-before", run_case("wrong-before.json"), 1),
|
||||
("extra-origin-change", run_case("extra-origin-change.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),
|
||||
("nested-unknown", run_case("nested-unknown.json"), 1),
|
||||
("unknown-only", run_case("unknown-only.json"), 1),
|
||||
("empty", run_case("empty.json"), 1),
|
||||
("missing-action", run_case("missing-action.json"), 1),
|
||||
("extra-action", run_case("extra-action.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)
|
||||
download_failures.extend(test_deploy_workflow_uses_script_flags())
|
||||
download_failures.extend(test_deploy_workflow_confirms_prefix_with_head_object())
|
||||
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())
|
||||
300
scripts/test-verify-cloudfront-release.sh
Executable file
300
scripts/test-verify-cloudfront-release.sh
Executable file
|
|
@ -0,0 +1,300 @@
|
|||
#!/usr/bin/env bash
|
||||
# Stubbed aws/curl tests for scripts/verify-cloudfront-release.sh.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
|
||||
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
|
||||
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
INDEX_HTML='<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>'
|
||||
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
|
||||
failures=0
|
||||
assert_exit() {
|
||||
local name="$1" expected="$2" got="$3" log="$4"
|
||||
if [[ "${got}" != "${expected}" ]]; then
|
||||
echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2
|
||||
sed -n '1,80p' "${log}" >&2
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
echo "PASS: ${name}"
|
||||
fi
|
||||
}
|
||||
|
||||
make_stubs() {
|
||||
local bin="$1"
|
||||
mkdir -p "${bin}"
|
||||
cat > "${bin}/aws" << 'AWS'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
state_dir="${STUB_STATE}"
|
||||
if [[ "${1:-}" == "s3" ]]; then
|
||||
cat "${state_dir}/pointer.json"
|
||||
exit 0
|
||||
fi
|
||||
cat "${state_dir}/distribution.json"
|
||||
AWS
|
||||
cat > "${bin}/curl" << 'CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
state_dir="${STUB_STATE}"
|
||||
method="GET"
|
||||
url=""
|
||||
dump=""
|
||||
output=""
|
||||
write_out=""
|
||||
args=("$@")
|
||||
i=0
|
||||
while [[ $i -lt ${#args[@]} ]]; do
|
||||
arg="${args[$i]}"
|
||||
case "${arg}" in
|
||||
-X) i=$((i + 1)); method="${args[$i]}" ;;
|
||||
-D) i=$((i + 1)); dump="${args[$i]}" ;;
|
||||
-o) i=$((i + 1)); output="${args[$i]}" ;;
|
||||
-w) i=$((i + 1)); write_out="${args[$i]}" ;;
|
||||
-H|--max-time|-s|-S|-f|-fsS|-sS) ;;
|
||||
http*) url="${arg}" ;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [[ "${method}" == "OPTIONS" ]]; then
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://dev.seahaven.com\n\n' > "${dump}"
|
||||
[[ -n "${write_out}" ]] && printf '204'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${url}" == *"/assets/"* ]]; then
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: public,max-age=31536000,immutable\n\n' > "${dump}"
|
||||
if [[ -f "${state_dir}/asset.js" ]]; then
|
||||
body="$(cat "${state_dir}/asset.js")"
|
||||
else
|
||||
body='const api="https://api.dev.seahaven.com/api";'
|
||||
fi
|
||||
[[ -n "${output}" ]] && printf '%s' "${body}" > "${output}"
|
||||
[[ -z "${output}" ]] && printf '%s' "${body}"
|
||||
exit 0
|
||||
fi
|
||||
body="$(cat "${state_dir}/index.html")"
|
||||
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
|
||||
if [[ -n "${output}" ]]; then
|
||||
printf '%s' "${body}" > "${output}"
|
||||
else
|
||||
printf '%s' "${body}"
|
||||
fi
|
||||
exit 0
|
||||
CURL
|
||||
chmod +x "${bin}/aws" "${bin}/curl"
|
||||
}
|
||||
|
||||
dist_json() {
|
||||
local status="$1" current_path="$2"
|
||||
python3 -c 'import json,sys
|
||||
status, path = sys.argv[1], sys.argv[2]
|
||||
print(json.dumps({
|
||||
"Distribution": {
|
||||
"Status": status,
|
||||
"DistributionConfig": {
|
||||
"Origins": {"Items": [
|
||||
{"Id": "current", "OriginPath": path},
|
||||
{"Id": "previous", "OriginPath": ""},
|
||||
]}
|
||||
}
|
||||
}
|
||||
}))' "${status}" "${current_path}"
|
||||
}
|
||||
|
||||
pointer_json() {
|
||||
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}"
|
||||
export PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P"
|
||||
export EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
|
||||
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com"
|
||||
export SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=3
|
||||
export INTERVAL=0
|
||||
local log="${dir}/log.txt"
|
||||
set +e
|
||||
bash "${VERIFY}" > "${log}" 2>&1
|
||||
local code=$?
|
||||
set -e
|
||||
assert_exit "${name}" "$2" "${code}" "${log}"
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
printf 'InProgress\n' > "${dir}/status"
|
||||
cat > "${dir}/bin/aws" << AWS
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "\${1:-}" == "s3" ]]; then
|
||||
cat "${dir}/pointer.json"
|
||||
exit 0
|
||||
fi
|
||||
status="\$(cat "${dir}/status")"
|
||||
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}"
|
||||
echo Deployed > "${dir}/status"
|
||||
AWS
|
||||
chmod +x "${dir}/bin/aws"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=5 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt"
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 2. Right config never propagates (Deployed, stale hash).
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf 'stale' > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 3. Wrong origin path fails fast.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 4. Wrong pointer fails fast.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 5. Never Deployed.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "never-deployed" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: never-deployed missing last observed state" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
printf 'const x=1;' > "${dir}/asset.js"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL.
|
||||
{
|
||||
dir="$(mktemp -d)"
|
||||
make_stubs "${dir}/bin"
|
||||
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
|
||||
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
|
||||
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
|
||||
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
|
||||
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
|
||||
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
|
||||
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
|
||||
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
|
||||
export BUDGET=2 INTERVAL=0
|
||||
set +e
|
||||
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
assert_exit "forbidden-staging-api-url" 1 "${code}" "${dir}/log.txt"
|
||||
grep -q "forbidden URL api.staging.seahaven.com" "${dir}/log.txt" || { echo "FAIL: staging API URL did not name forbidden URL" >&2; failures=$((failures + 1)); }
|
||||
rm -rf "${dir}"
|
||||
}
|
||||
|
||||
if [[ "${failures}" -ne 0 ]]; then
|
||||
echo "FAIL: ${failures} verify-cloudfront-release cases failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: CloudFront release verify checks"
|
||||
94
scripts/testdata/terraform-release-plans/create.json
vendored
Normal file
94
scripts/testdata/terraform-release-plans/create.json
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["create"],
|
||||
"before": null,
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
94
scripts/testdata/terraform-release-plans/delete.json
vendored
Normal file
94
scripts/testdata/terraform-release-plans/delete.json
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["delete"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}"
|
||||
},
|
||||
"after": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
116
scripts/testdata/terraform-release-plans/dns-update.json
vendored
Normal file
116
scripts/testdata/terraform-release-plans/dns-update.json
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_route53_record.site_a",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"ttl": 60
|
||||
},
|
||||
"after": {
|
||||
"ttl": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
scripts/testdata/terraform-release-plans/empty.json
vendored
Normal file
9
scripts/testdata/terraform-release-plans/empty.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"resource_changes": [],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
106
scripts/testdata/terraform-release-plans/extra-action.json
vendored
Normal file
106
scripts/testdata/terraform-release-plans/extra-action.json
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release_extra",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/extra-origin-change.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/extra-origin-change.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 20,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
116
scripts/testdata/terraform-release-plans/iam-update.json
vendored
Normal file
116
scripts/testdata/terraform-release-plans/iam-update.json
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"policy": "{}"
|
||||
},
|
||||
"after": {
|
||||
"policy": "{\"Version\":\"2012-10-17\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
97
scripts/testdata/terraform-release-plans/missing-action.json
vendored
Normal file
97
scripts/testdata/terraform-release-plans/missing-action.json
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": []
|
||||
}
|
||||
130
scripts/testdata/terraform-release-plans/multiple-updates.json
vendored
Normal file
130
scripts/testdata/terraform-release-plans/multiple-updates.json
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"policy": "{}"
|
||||
},
|
||||
"after": {
|
||||
"policy": "{\"Version\":\"2012-10-17\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_route53_record.site_a",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"ttl": 60
|
||||
},
|
||||
"after": {
|
||||
"ttl": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
105
scripts/testdata/terraform-release-plans/nested-unknown.json
vendored
Normal file
105
scripts/testdata/terraform-release-plans/nested-unknown.json
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true,
|
||||
"tags": {
|
||||
"Environment": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
104
scripts/testdata/terraform-release-plans/origin-timeout-change.json
vendored
Normal file
104
scripts/testdata/terraform-release-plans/origin-timeout-change.json
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"response_completion_timeout": 10
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"response_completion_timeout": 60
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
105
scripts/testdata/terraform-release-plans/origin-timeout-normalization.json
vendored
Normal file
105
scripts/testdata/terraform-release-plans/origin-timeout-normalization.json
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"response_completion_timeout": 0
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1",
|
||||
"response_completion_timeout": 0
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"response_completion_timeout": null
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
scripts/testdata/terraform-release-plans/replace.json
vendored
Normal file
58
scripts/testdata/terraform-release-plans/replace.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["delete", "create"],
|
||||
"before": {
|
||||
"origin": []
|
||||
},
|
||||
"after": {
|
||||
"origin": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
103
scripts/testdata/terraform-release-plans/unknown-only.json
vendored
Normal file
103
scripts/testdata/terraform-release-plans/unknown-only.json
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true,
|
||||
"comment": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/version-only.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/version-only.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/wrong-before.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/wrong-before.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
102
scripts/testdata/terraform-release-plans/wrong-label.json
vendored
Normal file
102
scripts/testdata/terraform-release-plans/wrong-label.json
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role_policy",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": {
|
||||
"name": "policy"
|
||||
},
|
||||
"after": {
|
||||
"name": "policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_s3_object.release_pointer",
|
||||
"mode": "managed",
|
||||
"type": "aws_s3_object",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"content": "{\"current\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\",\"previous\":\"0000000000000000000000000000000000000000-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after": {
|
||||
"content": "{\"current\":\"cccccccccccccccccccccccccccccccccccccccc-3-1\",\"previous\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1\"}",
|
||||
"key": ".release/current"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"version_id": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudfront_distribution",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/0000000000000000000000000000000000000000-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after": {
|
||||
"origin": [
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
"origin_path": "/releases/cccccccccccccccccccccccccccccccccccccccc-3-1"
|
||||
},
|
||||
{
|
||||
"connection_attempts": 3,
|
||||
"connection_timeout": 10,
|
||||
"domain_name": "seahaven-shoc-frontend-dev.s3.us-east-1.amazonaws.com",
|
||||
"origin_access_control_id": "E30VSIK87N8H64",
|
||||
"origin_id": "shocfrontenddevDistributionOrigin10CCD0EE1-previous",
|
||||
"origin_path": "/releases/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"comment": "SeaHaven SHOC frontend (dev)"
|
||||
},
|
||||
"after_unknown": {
|
||||
"etag": true,
|
||||
"last_modified_time": true,
|
||||
"status": true,
|
||||
"in_progress_validation_batches": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"action_invocations": [
|
||||
{
|
||||
"address": "module.environment_owned.action.aws_cloudfront_create_invalidation.release",
|
||||
"type": "aws_cloudfront_create_invalidation"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
scripts/upload-sourcemaps.sh
Executable file
28
scripts/upload-sourcemaps.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}"
|
||||
|
||||
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
|
||||
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
|
||||
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
RELEASE_LABEL="${SENTRY_RELEASE:-${RELEASE_LABEL:-}}"
|
||||
|
||||
if [[ -n "${RELEASE_LABEL}" ]]; then
|
||||
RELEASE="${RELEASE_LABEL}"
|
||||
else
|
||||
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
fi
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
--release "${RELEASE}" \
|
||||
--url-prefix "~/" \
|
||||
--validate \
|
||||
dist
|
||||
209
scripts/verify-cloudfront-release.sh
Executable file
209
scripts/verify-cloudfront-release.sh
Executable file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verify a CloudFront content release or rollback.
|
||||
#
|
||||
# Fail fast when origin_path or .release/current is the wrong label.
|
||||
# Poll while the distribution is InProgress or the served index.html hash
|
||||
# still matches the previous release. On timeout, print last observed state.
|
||||
set -euo pipefail
|
||||
|
||||
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
|
||||
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
|
||||
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
|
||||
SITE_URL="${SITE_URL:-}"
|
||||
SITE_BUCKET="${SITE_BUCKET:-}"
|
||||
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
|
||||
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
|
||||
BUDGET="${BUDGET:-40}"
|
||||
INTERVAL="${INTERVAL:-15}"
|
||||
|
||||
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
|
||||
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
if [[ -n "${EXPECTED_LABEL}" ]]; then
|
||||
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
|
||||
else
|
||||
EXPECTED_PATH=""
|
||||
fi
|
||||
|
||||
sha256_of() {
|
||||
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
|
||||
}
|
||||
|
||||
read_pointer() {
|
||||
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
|
||||
}
|
||||
|
||||
read_distribution_json() {
|
||||
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
|
||||
}
|
||||
|
||||
parse_distribution() {
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
payload = json.load(sys.stdin)
|
||||
dist = payload.get("Distribution") or payload
|
||||
status = dist.get("Status") or "Unknown"
|
||||
config = dist.get("DistributionConfig") or {}
|
||||
origins = ((config.get("Origins") or {}).get("Items")) or []
|
||||
paths = [origin.get("OriginPath") or "" for origin in origins]
|
||||
expected = os.environ["EXPECTED_PATH"]
|
||||
print(status)
|
||||
print("\x1f".join(paths))
|
||||
print("yes" if expected in paths else "no")
|
||||
'
|
||||
}
|
||||
|
||||
pointer_current() {
|
||||
POINTER_BODY="$1" python3 -c '
|
||||
import json, os
|
||||
raw = os.environ.get("POINTER_BODY", "").strip()
|
||||
if not raw:
|
||||
print("")
|
||||
raise SystemExit
|
||||
print(json.loads(raw).get("current") or "")
|
||||
'
|
||||
}
|
||||
|
||||
last_status="Unknown"
|
||||
last_paths="Unknown"
|
||||
last_pointer="Unknown"
|
||||
last_hash="Unknown"
|
||||
last_path_ok="no"
|
||||
|
||||
observe() {
|
||||
last_pointer="$(read_pointer)"
|
||||
local parsed
|
||||
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
|
||||
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
|
||||
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
|
||||
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
|
||||
local body
|
||||
body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)"
|
||||
if [[ -n "${body}" ]]; then
|
||||
last_hash="$(printf '%s' "${body}" | sha256_of)"
|
||||
else
|
||||
last_hash="unreachable"
|
||||
fi
|
||||
}
|
||||
|
||||
report_state() {
|
||||
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
|
||||
}
|
||||
|
||||
fail_fast_if_misconfigured() {
|
||||
local current
|
||||
current="$(pointer_current "${last_pointer}")"
|
||||
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
|
||||
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${last_path_ok}" != "yes" ]]; then
|
||||
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
observe
|
||||
fail_fast_if_misconfigured
|
||||
|
||||
attempt=0
|
||||
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
|
||||
fail_fast_if_misconfigured
|
||||
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep "${INTERVAL}"
|
||||
observe
|
||||
done
|
||||
|
||||
if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then
|
||||
echo "FAIL: release did not converge within the budget." >&2
|
||||
report_state >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_asset_paths() {
|
||||
python3 -c '
|
||||
import re, sys
|
||||
html = open(sys.argv[1], encoding="utf-8").read()
|
||||
seen = []
|
||||
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
|
||||
if path not in seen:
|
||||
seen.append(path)
|
||||
print(path)
|
||||
' "$1"
|
||||
}
|
||||
|
||||
assert_baked_api_url() {
|
||||
local tmp="$1"
|
||||
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
|
||||
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
|
||||
exit 1
|
||||
fi
|
||||
: > "${tmp}/assets.txt"
|
||||
local immutable_ok="no"
|
||||
local asset_path
|
||||
while IFS= read -r asset_path; do
|
||||
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
|
||||
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
|
||||
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
|
||||
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
|
||||
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
|
||||
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
|
||||
exit 1
|
||||
fi
|
||||
immutable_ok="yes"
|
||||
fi
|
||||
done < "${tmp}/asset-paths.txt"
|
||||
if [[ "${immutable_ok}" != "yes" ]]; then
|
||||
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
|
||||
local forbidden
|
||||
for forbidden in api.staging.seahaven.com localhost:5141; do
|
||||
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then
|
||||
echo "FAIL: served JS assets are missing the baked dev API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html"
|
||||
curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html"
|
||||
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
|
||||
echo "FAIL: HTML Cache-Control is missing no-store." >&2
|
||||
exit 1
|
||||
fi
|
||||
write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt"
|
||||
assert_baked_api_url "${tmp}"
|
||||
|
||||
cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \
|
||||
-H "Origin: ${SITE_URL}" \
|
||||
-H "Access-Control-Request-Method: GET")"
|
||||
if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then
|
||||
echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
|
||||
echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
|
||||
report_state
|
||||
|
|
@ -37,6 +37,8 @@ export const API_PATHS = {
|
|||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
141
src/api/api.ts
141
src/api/api.ts
|
|
@ -3,18 +3,9 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function isAuthLoginRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.login);
|
||||
|
|
@ -51,35 +42,99 @@ export const api: KyInstance = ky.create({
|
|||
return response;
|
||||
}
|
||||
|
||||
clearSessionAndRedirectToLogin();
|
||||
expireSessionAndRedirectToLogin();
|
||||
return response;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
|
||||
|
||||
export async function apiRequestRaw(
|
||||
method: ApiRawMethod,
|
||||
url: string,
|
||||
options?: Options,
|
||||
entryPoint?: string,
|
||||
): Promise<Response> {
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
entryPoint: entryPoint ?? `api.${method}`,
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api[method](url, options);
|
||||
reportStatus(response.status);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.get(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "GET", url, entryPoint: "api.apiGet" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.get(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPost" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostForm" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PUT", url, entryPoint: "api.apiPut" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.put(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.patch(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.patch(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostNoContent(
|
||||
|
|
@ -87,31 +142,43 @@ export async function apiPostNoContent(
|
|||
body?: unknown,
|
||||
options?: Options,
|
||||
): Promise<void> {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||
const response = await api.delete(url, options);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { mapHttpStatusToMessage };
|
||||
|
|
|
|||
103
src/api/upload-form-with-progress.ts
Normal file
103
src/api/upload-form-with-progress.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
|
||||
export interface UploadFormResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface UploadFormOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
function parseJsonBody(text: string): unknown {
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException("The upload was aborted.", "AbortError");
|
||||
}
|
||||
|
||||
/**
|
||||
* hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept
|
||||
* streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies
|
||||
* as a network error before reaching the API. XHR reports progress on every transport.
|
||||
*/
|
||||
export function uploadFormWithProgress(
|
||||
path: string,
|
||||
body: FormData,
|
||||
options?: UploadFormOptions,
|
||||
): Promise<UploadFormResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.setRequestHeader("Authorization", token);
|
||||
}
|
||||
|
||||
const abortRequest = () => {
|
||||
request.abort();
|
||||
};
|
||||
options?.signal?.addEventListener("abort", abortRequest);
|
||||
|
||||
const detachSignal = () => {
|
||||
options?.signal?.removeEventListener("abort", abortRequest);
|
||||
};
|
||||
|
||||
const { onProgress } = options ?? {};
|
||||
if (onProgress) {
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
detachSignal();
|
||||
const { status } = request;
|
||||
|
||||
if (status === 401) {
|
||||
expireSessionAndRedirectToLogin();
|
||||
}
|
||||
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
data: parseJsonBody(request.responseText),
|
||||
});
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
detachSignal();
|
||||
reject(new Error("Upload failed. Check your connection and try again."));
|
||||
});
|
||||
|
||||
request.addEventListener("abort", () => {
|
||||
detachSignal();
|
||||
reject(createAbortError());
|
||||
});
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
|
|
@ -20,9 +21,9 @@ import {
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
|
|||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
toast.error("Unable to open this document. Please allow pop-ups and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
tab.opener = null;
|
||||
tab.location.href = objectUrl;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch {
|
||||
tab.close();
|
||||
toast.error("Unable to open this document.");
|
||||
}
|
||||
}
|
||||
|
||||
function ExtraDocRow({
|
||||
file,
|
||||
isReadOnly,
|
||||
pending,
|
||||
workOrderId,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: {
|
||||
file: MediaFile;
|
||||
isReadOnly: boolean;
|
||||
pending: boolean;
|
||||
workOrderId?: string | number;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
}) {
|
||||
|
|
@ -75,15 +98,20 @@ function ExtraDocRow({
|
|||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
disabled={workOrderId == null}
|
||||
onClick={() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
void openExtraDocContent(workOrderId, file.id);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
</button>
|
||||
{showCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
|
|
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
|
|||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
|
|||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
|
|||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
|
|||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
|
|||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
workOrderId={workOrderId}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ function SlideOverScheduleOnField({
|
|||
draft: WorkOrderTableRow;
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
}) {
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
|
||||
return (
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
|
|
@ -80,6 +83,20 @@ function SlideOverScheduleOnField({
|
|||
...deriveDayFields(start),
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
|
|
@ -9,9 +9,13 @@ import type {
|
|||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
import {
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
|
|||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
|
||||
if (!id) return previous;
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
|
||||
if (!previous.has(id)) return previous;
|
||||
const next = new Set(previous);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
|
||||
|
||||
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
|
||||
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
|
||||
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
|
||||
return {
|
||||
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
|
||||
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
|
||||
};
|
||||
} catch {
|
||||
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
function saveOwnership(
|
||||
workOrderId: string | number | undefined,
|
||||
ownership: MediaSurfaceOwnership,
|
||||
): void {
|
||||
if (workOrderId == null) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
|
||||
JSON.stringify({
|
||||
completion: [...ownership.completionOwnedIds],
|
||||
extra: [...ownership.extraOwnedIds],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOwnershipState(loadOwnership(workOrderId));
|
||||
}, [workOrderId]);
|
||||
|
||||
const updateOwnership = useCallback(
|
||||
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
|
||||
setOwnershipState((current) => {
|
||||
const next = update(current);
|
||||
saveOwnership(workOrderId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
const item = await uploadMediaMutation.mutateAsync(input);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
);
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() =>
|
||||
uploads.filter(
|
||||
(item) =>
|
||||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||
item.uploadStatus === "uploading" ||
|
||||
item.uploadStatus === "failed",
|
||||
),
|
||||
[uploads],
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||
[uploads, ownership],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||
),
|
||||
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||
isExtrasSurfaceMedia(item, ownership),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
[detailForRow?.media, ownership],
|
||||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
|
|
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
|
|||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
|
|
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
|
|
@ -111,6 +111,22 @@ export function DateCell({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{Boolean(onClear) && (
|
||||
<div className="mb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
|
|
@ -144,21 +160,6 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
type UseVendorDialogFormArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
vendorId: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedCompany, setSelectedCompany] = useState(company);
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const companyOptions = useMemo(
|
||||
() => buildCompanyOptions(allVendors, company),
|
||||
[allVendors, company],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, selectedCompany, tech, company),
|
||||
[allVendors, selectedCompany, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
|
||||
setSelectedCompany(company);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, initialOptions));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// invariant: rebuilding options after Add must not clear staged technician drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedCompany;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company: selectedCompany,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const canSubmit = computeVendorDialogCanSubmit({
|
||||
current: {
|
||||
company: selectedCompany,
|
||||
selectedKey,
|
||||
phone,
|
||||
notes,
|
||||
pendingCreateCount: Object.keys(pendingCreates).length,
|
||||
},
|
||||
baseline: {
|
||||
company,
|
||||
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
phone:
|
||||
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
|
||||
notes: "",
|
||||
pendingCreateCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
});
|
||||
|
||||
return {
|
||||
companyOptions,
|
||||
technicianOptions,
|
||||
selected,
|
||||
t,
|
||||
phone,
|
||||
notes,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
selectedCompany,
|
||||
selectedKey,
|
||||
anchorCompanyName,
|
||||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
setSelectedCompany(nextCompany);
|
||||
if (selectedOption?.company !== nextCompany) {
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSelectedKey("");
|
||||
setT("");
|
||||
setPhone("");
|
||||
}
|
||||
},
|
||||
handlePick: (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
},
|
||||
handleTechnicianCreated: (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
},
|
||||
setPhone,
|
||||
setNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
|
|||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
|||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
|||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
|||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||
techPhone: selectedKey ? phone : "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import { Building2, Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
|
|||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import type {
|
||||
CompanyOption,
|
||||
TechnicianOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedCompany: string;
|
||||
companyOptions: CompanyOption[];
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
|||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onCompanyPick: (company: string) => void;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
|||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedCompany,
|
||||
companyOptions,
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
|||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onCompanyPick,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
|||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<FieldLabel required>Company</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedCompany}
|
||||
placeholder="Select company …"
|
||||
icon={
|
||||
<Building2
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search company…"
|
||||
options={companyOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={onCompanyPick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company info</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Technician (optional)</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||
disabled={!selectedCompany}
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
label: option.name,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
|||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldLabel>Notes (optional)</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
export type VendorDialogSubmitSnapshot = {
|
||||
company: string;
|
||||
selectedKey: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
pendingCreateCount: number;
|
||||
};
|
||||
|
||||
type VendorDialogCanSubmitInput = {
|
||||
current: VendorDialogSubmitSnapshot;
|
||||
baseline: VendorDialogSubmitSnapshot;
|
||||
};
|
||||
|
||||
export function computeVendorDialogCanSubmit({
|
||||
current,
|
||||
baseline,
|
||||
}: VendorDialogCanSubmitInput): boolean {
|
||||
const valid = current.company.trim() !== "";
|
||||
const hasNewTechs = current.pendingCreateCount > 0;
|
||||
const dirty =
|
||||
current.company !== baseline.company ||
|
||||
current.selectedKey !== baseline.selectedKey ||
|
||||
current.phone !== baseline.phone ||
|
||||
current.pendingCreateCount !== baseline.pendingCreateCount;
|
||||
return valid && (dirty || hasNewTechs);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { ExternalLink, Mail, MapPin } from "lucide-react";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||
{Boolean(vendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||
{vendor != null && Boolean(vendor.address) && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
viewOnly?: boolean;
|
||||
canSubmit?: boolean;
|
||||
};
|
||||
|
||||
const lookDisabledSx = {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({
|
||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
|||
onCancel,
|
||||
onSave,
|
||||
viewOnly = false,
|
||||
canSubmit = false,
|
||||
}: VendorDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={isPending}
|
||||
onClick={() => void onSave()}
|
||||
aria-disabled={!canSubmit && !isPending}
|
||||
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
|
||||
sx={canSubmit || isPending ? undefined : lookDisabledSx}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void onSave();
|
||||
}}
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
|||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export type CompanyOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export function buildCompanyOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
currentCompany: string,
|
||||
): CompanyOption[] {
|
||||
const companies = new Map<string, CompanyOption>();
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const companyName = vendor.companyName.trim();
|
||||
if (companyName && !companies.has(companyName)) {
|
||||
companies.set(companyName, {
|
||||
key: companyName,
|
||||
label: companyName,
|
||||
vendor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCompany && !companies.has(currentCompany)) {
|
||||
companies.set(currentCompany, {
|
||||
key: currentCompany,
|
||||
label: currentCompany,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(companies.values());
|
||||
}
|
||||
|
||||
export function buildTechnicianOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
selectedCompany: string,
|
||||
tech: string,
|
||||
company: string,
|
||||
currentCompany: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.companyName !== selectedCompany) {
|
||||
continue;
|
||||
}
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: String(vendor.id),
|
||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
|||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
if (tech && selectedCompany === currentCompany) {
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||
vendors.find((v) => v.contactName === tech);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
label: tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
company: currentCompany,
|
||||
phone: "",
|
||||
vendor,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
|||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, tech, company),
|
||||
[allVendors, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const handlePick = (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
};
|
||||
|
||||
const handleTechnicianCreated = (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
};
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
const form = useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
<Text as="p" className="sr-only">
|
||||
Search and select the technician
|
||||
Search and select the vendor company
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
selectedCompany={form.selectedCompany}
|
||||
companyOptions={form.companyOptions}
|
||||
selectedKey={form.selectedKey}
|
||||
technicianOptions={form.technicianOptions}
|
||||
anchorCompanyName={form.anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||
tech={form.selected?.name ?? form.t}
|
||||
phone={form.phone}
|
||||
notes={form.notes}
|
||||
resolvedCompany={form.resolvedCompany}
|
||||
selectedVendor={form.selectedVendor}
|
||||
saveError={form.saveError}
|
||||
onCompanyPick={form.handleCompanyPick}
|
||||
onPick={form.handlePick}
|
||||
onPhoneChange={form.setPhone}
|
||||
onNotesChange={form.setNotes}
|
||||
onTechnicianCreated={form.handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
isPending={form.isPending}
|
||||
canSubmit={form.canSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
onSave={form.handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
|||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
function showRescheduleToast(
|
||||
message: string,
|
||||
onNavigate: () => void,
|
||||
linkLabel = "Click to view it.",
|
||||
) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
linkLabel,
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
|||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
if (!weekStart) {
|
||||
return showRescheduleToast(
|
||||
"Work order moved to Unscheduled.",
|
||||
onNavigate,
|
||||
"View in Advanced Filters.",
|
||||
);
|
||||
}
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
|
|
|
|||
|
|
@ -122,18 +122,26 @@ export function useWoTableRowHandlers({
|
|||
});
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const isClear = week === "";
|
||||
onPatch(
|
||||
id,
|
||||
isClear
|
||||
? applySchedulePatch("", false)
|
||||
: {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
},
|
||||
);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(week);
|
||||
if (!isClear) {
|
||||
onNavigateToDay(week);
|
||||
}
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export function WoTableRowCells({
|
|||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
const hasSchedule =
|
||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -137,6 +140,7 @@ export function WoTableRowCells({
|
|||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export function WoTableRow({
|
|||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
@ -212,7 +212,7 @@ export function WoTableRow({
|
|||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
|
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
|
|||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
|
|
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
||||
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle
|
||||
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Photos & Videos
|
||||
</DialogTitle>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
Upload photos and videos for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
categorizeAfterUpload?: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
categorizeAfterUpload = false,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
|
|
@ -27,7 +29,7 @@ export function MediaUploaderDropzone({
|
|||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
|
|||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const managesPurpose = Boolean(onCategorize);
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
managesPurpose;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
|
|
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
|
|||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
{needsCategory && (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
|
|
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
)}
|
||||
{Boolean(!needsCategory && managesPurpose) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ export function MediaUploader({
|
|||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { HTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
|
|
@ -91,7 +91,12 @@ export const upliftsApi = {
|
|||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await api.get(evidenceUrl(id));
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api, apiGet, apiPut } from "@/api/api";
|
||||
import { apiGet, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import type {
|
||||
SiteVendorPreference,
|
||||
VendorAssignmentResult,
|
||||
|
|
@ -27,7 +27,13 @@ type InsightParams = {
|
|||
};
|
||||
|
||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
`${base}/${path}`,
|
||||
{ searchParams: searchParams(params) },
|
||||
"vendorOperationsApi.saveDownload",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import type {
|
||||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -38,30 +39,45 @@ function uploadVendorDocument({
|
|||
purpose,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
});
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
|
||||
entryPoint: "vendorPortalApi.uploadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
({ reportStatus }) =>
|
||||
new Promise<VendorPortalDocument>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
reportStatus(request.status);
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(
|
||||
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reportStatus(request.status);
|
||||
reject(new Error("Upload failed"));
|
||||
});
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadVendorDocument(
|
||||
|
|
@ -69,19 +85,32 @@ async function downloadVendorDocument(
|
|||
dispatchId: string | number,
|
||||
document: VendorPortalDocument,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
await traceHttpOperation(
|
||||
{
|
||||
method: "GET",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
entryPoint: "vendorPortalApi.downloadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
);
|
||||
reportStatus(response.status);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Download failed",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
|
|
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
|
|||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
return traceHttpOperation<T>(
|
||||
{
|
||||
method: options?.method ?? "GET",
|
||||
url: resolveVendorPortalUrl(path),
|
||||
entryPoint: "vendorPortalApi.call",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
reportStatus(response.status);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isNoApprovalRequired(payload: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
|
|
@ -33,10 +35,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||
function throwDocumentHttpError(status: number, data: unknown): never {
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
if (status === 409) {
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
|
|
@ -45,14 +47,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
|
|||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
if (status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
throw new ApiError(mapHttpStatusToMessage(status, data), status);
|
||||
}
|
||||
|
||||
export const workOrderBoardDocumentsApi = {
|
||||
|
|
@ -81,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
commentId: string | number,
|
||||
text: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||
json: { text },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.commentItem(workOrderId, commentId),
|
||||
{ json: { text }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateComment",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -94,7 +98,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||
}
|
||||
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||
|
|
@ -119,21 +123,26 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
const { ok, status, data } = await traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: API_PATHS.workOrder.media(workOrderId),
|
||||
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const result = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
);
|
||||
reportStatus(result.status);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
if (ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(status, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
|
|
@ -152,15 +161,17 @@ export const workOrderBoardDocumentsApi = {
|
|||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
formData.append("workOrderVersion", workOrderVersion);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateMediaCategory",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (
|
||||
|
|
@ -183,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
);
|
||||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
searchParams: { workOrderVersion },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"delete",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
|
||||
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.deleteMedia",
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
|
|
@ -196,7 +209,30 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
getMediaContent: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(mapHttpStatusToMessage(404), 404);
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
|
|
@ -216,14 +252,16 @@ export const workOrderBoardDocumentsApi = {
|
|||
}
|
||||
|
||||
const formData = buildCompletionDocFormData(params);
|
||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.completionDoc(workOrderId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.uploadCompletionDoc",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
|
|
@ -314,10 +314,12 @@ export const workOrdersApi = {
|
|||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
|||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** why: backend field names must never be exposed through user-facing board toasts. */
|
||||
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
||||
if (error.code === "DispatchRequired") {
|
||||
return "Select a vendor company before saving.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
||||
import type {
|
||||
FileCategory,
|
||||
MediaCategory,
|
||||
|
|
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
|||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||
category: mapMediaCategory(item.category ?? item.Category),
|
||||
url: readString(item, "url", "Url"),
|
||||
fileName: readString(item, "fileName", "FileName") || "file",
|
||||
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
boardPatchValidationMessage,
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
|
|
@ -161,7 +162,7 @@ export function useWorkOrderTableMutations(
|
|||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
clearPatch(id);
|
||||
toast.error(error.message);
|
||||
toast.error(boardPatchValidationMessage(error));
|
||||
} else {
|
||||
clearPatch(id);
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
|
|
|
|||
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media
|
||||
* tiles reads as an id. The original name (and its extension) survive after the prefix.
|
||||
*/
|
||||
const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i;
|
||||
|
||||
export function toDisplayFileName(fileName: string): string {
|
||||
const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim();
|
||||
return withoutPrefix || fileName;
|
||||
}
|
||||
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
|
||||
export type MediaSurfaceOwnership = {
|
||||
completionOwnedIds: ReadonlySet<string>;
|
||||
extraOwnedIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const EMPTY_OWNERSHIP: MediaSurfaceOwnership = {
|
||||
completionOwnedIds: new Set(),
|
||||
extraOwnedIds: new Set(),
|
||||
};
|
||||
|
||||
function isPhotoOrVideo(item: MediaFile): boolean {
|
||||
return item.fileType === "image" || item.fileType === "video";
|
||||
}
|
||||
|
||||
function isInFlight(item: MediaFile): boolean {
|
||||
return item.uploadStatus === "uploading" || item.uploadStatus === "failed";
|
||||
}
|
||||
|
||||
/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */
|
||||
export function isCompletionSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (isInFlight(item)) return true;
|
||||
if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return true;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return false;
|
||||
return item.purpose === "Extra" && isPhotoOrVideo(item);
|
||||
}
|
||||
|
||||
export function isExtrasSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (item.purpose === "Aveta") return true;
|
||||
if (item.purpose !== "Extra") return false;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return false;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return true;
|
||||
return !isPhotoOrVideo(item);
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
|
||||
const MEDIA_MIME_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
|
||||
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
|
||||
const extOk = extensions.has(fileExtension(file));
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
|
|
@ -13,24 +42,25 @@ export function isWorkOrderFileTooLarge(
|
|||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
|
|
|
|||
12
src/lib/auth/expire-session.ts
Normal file
12
src/lib/auth/expire-session.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { clearAuth } from "@/lib/auth/auth-storage";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
|
||||
export function expireSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -1,3 +1,7 @@
|
|||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import "@/observability/sentry";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { AppRoot } from "@/app-root";
|
||||
|
|
@ -10,7 +14,11 @@ if (!rootElement) {
|
|||
throw new Error("Root element #root not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
ReactDOM.createRoot(rootElement, {
|
||||
onCaughtError: Sentry.reactErrorHandler(),
|
||||
onRecoverableError: Sentry.reactErrorHandler(),
|
||||
onUncaughtError: Sentry.reactErrorHandler(),
|
||||
}).render(
|
||||
<React.StrictMode>
|
||||
<AppProviders>
|
||||
<AppRoot />
|
||||
|
|
|
|||
111
src/observability/http-transaction.ts
Normal file
111
src/observability/http-transaction.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
|
||||
import { isHTTPError } from "ky";
|
||||
|
||||
const INTEGER_SEGMENT = /^\d+$/;
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const HEX_SEGMENT = /^[0-9a-f]+$/i;
|
||||
const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i;
|
||||
const OPAQUE_HEX_MIN_LENGTH = 8;
|
||||
const OPAQUE_TOKEN_MIN_LENGTH = 16;
|
||||
|
||||
function isIdentifierSegment(segment: string): boolean {
|
||||
if (segment.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (INTEGER_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (UUID_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
segment.length >= OPAQUE_TOKEN_MIN_LENGTH &&
|
||||
TOKEN_SEGMENT.test(segment) &&
|
||||
/\d/.test(segment) &&
|
||||
/[a-z]/i.test(segment)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeHttpRoute(input: string): string {
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(input).pathname;
|
||||
} catch {
|
||||
path = input.split("#")[0] ?? input;
|
||||
path = path.split("?")[0] ?? path;
|
||||
}
|
||||
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => (isIdentifierSegment(segment) ? ":id" : segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export type HttpTraceOptions = {
|
||||
method: string;
|
||||
url: string;
|
||||
entryPoint: string;
|
||||
actorType?: string;
|
||||
};
|
||||
|
||||
export type HttpTraceContext = {
|
||||
reportStatus: (status: number) => void;
|
||||
};
|
||||
|
||||
export async function traceHttpOperation<T>(
|
||||
options: HttpTraceOptions,
|
||||
callback: (context: HttpTraceContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const route = normalizeHttpRoute(options.url);
|
||||
|
||||
return startSpan(
|
||||
{
|
||||
name: `${options.method.toUpperCase()} ${route}`,
|
||||
op: "http.client",
|
||||
kind: 2,
|
||||
forceTransaction: true,
|
||||
attributes: {
|
||||
"http.request.method": options.method.toUpperCase(),
|
||||
"http.route": route,
|
||||
"code.function": options.entryPoint,
|
||||
"operation.type": "http.client",
|
||||
...(options.actorType ? { "actor.type": options.actorType } : {}),
|
||||
},
|
||||
},
|
||||
async (span: Span) => {
|
||||
const spanJson = spanToJSON(span);
|
||||
if (spanJson.trace_id) {
|
||||
span.setAttribute("trace_id", spanJson.trace_id);
|
||||
}
|
||||
if (spanJson.span_id) {
|
||||
span.setAttribute("span_id", spanJson.span_id);
|
||||
span.setAttribute("transaction_id", spanJson.span_id);
|
||||
}
|
||||
|
||||
let statusReported = false;
|
||||
const reportStatus = (status: number): void => {
|
||||
if (statusReported) {
|
||||
return;
|
||||
}
|
||||
statusReported = true;
|
||||
setHttpStatus(span, status);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callback({ reportStatus });
|
||||
if (!statusReported && result instanceof Response) {
|
||||
reportStatus(result.status);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!statusReported) {
|
||||
reportStatus(isHTTPError(error) ? error.response.status : 500);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
151
src/observability/sentry-scrubber.ts
Normal file
151
src/observability/sentry-scrubber.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { Breadcrumb, ErrorEvent, SpanJSON, TransactionEvent } from "@sentry/core";
|
||||
|
||||
import { normalizeHttpRoute } from "@/observability/http-transaction";
|
||||
|
||||
function sanitizeUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin);
|
||||
return `${parsed.origin}${normalizeHttpRoute(parsed.pathname)}`;
|
||||
} catch {
|
||||
return normalizeHttpRoute(value);
|
||||
}
|
||||
}
|
||||
|
||||
function keepOpaqueUserId(event: ErrorEvent | TransactionEvent): void {
|
||||
event.user = event.user?.id == null ? undefined : { id: String(event.user.id) };
|
||||
}
|
||||
|
||||
function scrubRequest(event: ErrorEvent | TransactionEvent): void {
|
||||
if (!event.request) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.request = {
|
||||
method: event.request.method,
|
||||
url: typeof event.request.url === "string" ? sanitizeUrl(event.request.url) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const SAFE_SPAN_DATA_KEYS = new Set([
|
||||
"actor.type",
|
||||
"code.function",
|
||||
"db.operation.name",
|
||||
"db.system",
|
||||
"http.method",
|
||||
"http.request.method",
|
||||
"http.response.status_code",
|
||||
"http.route",
|
||||
"http.status_code",
|
||||
"operation.type",
|
||||
"sentry.op",
|
||||
"sentry.origin",
|
||||
"server.address",
|
||||
]);
|
||||
|
||||
function safeSpanData(data: SpanJSON["data"] | undefined): SpanJSON["data"] {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data ?? {}).filter(
|
||||
([key, value]) =>
|
||||
SAFE_SPAN_DATA_KEYS.has(key) && ["string", "number", "boolean"].includes(typeof value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function valueAsString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeHttpDescription(description: string | undefined): string | undefined {
|
||||
if (!description) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i.exec(description);
|
||||
return match ? `${match[1]!.toUpperCase()} ${sanitizeUrl(match[2]!)}` : undefined;
|
||||
}
|
||||
|
||||
function scrubTransactionSpans(event: TransactionEvent): void {
|
||||
event.spans = event.spans?.map((span) => ({
|
||||
...span,
|
||||
data: safeSpanData(span.data),
|
||||
description: span.op?.includes("http") ? sanitizeHttpDescription(span.description) : undefined,
|
||||
}));
|
||||
|
||||
const trace = event.contexts?.trace;
|
||||
if (trace?.data) {
|
||||
trace.data = safeSpanData(trace.data);
|
||||
}
|
||||
}
|
||||
|
||||
function setStringTag(
|
||||
tags: NonNullable<TransactionEvent["tags"]>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const stringValue = valueAsString(value);
|
||||
if (stringValue) {
|
||||
tags[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionFunction(event: ErrorEvent | TransactionEvent): string | undefined {
|
||||
const traceFunction = valueAsString(event.contexts?.trace?.data?.["code.function"]);
|
||||
return traceFunction ?? (event.type === "transaction" ? event.transaction : undefined);
|
||||
}
|
||||
|
||||
function actorType(event: ErrorEvent | TransactionEvent): string {
|
||||
return (
|
||||
valueAsString(event.tags?.["actor.type"]) ??
|
||||
valueAsString(event.contexts?.trace?.data?.["actor.type"]) ??
|
||||
(event.user?.id == null ? "anonymous" : "authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
function attachTraceIdentifiers(event: ErrorEvent | TransactionEvent): void {
|
||||
const trace = event.contexts?.trace;
|
||||
const tags = { ...event.tags };
|
||||
|
||||
setStringTag(tags, "trace_id", trace?.trace_id);
|
||||
setStringTag(tags, "transaction_id", trace?.span_id);
|
||||
setStringTag(tags, "operation.type", valueAsString(trace?.data?.["operation.type"]) ?? trace?.op);
|
||||
setStringTag(tags, "code.function", transactionFunction(event));
|
||||
tags["actor.type"] = actorType(event);
|
||||
event.tags = tags;
|
||||
}
|
||||
|
||||
export function scrubErrorEvent(event: ErrorEvent): ErrorEvent {
|
||||
keepOpaqueUserId(event);
|
||||
scrubRequest(event);
|
||||
attachTraceIdentifiers(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function scrubTransactionEvent(event: TransactionEvent): TransactionEvent {
|
||||
keepOpaqueUserId(event);
|
||||
scrubRequest(event);
|
||||
attachTraceIdentifiers(event);
|
||||
scrubTransactionSpans(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
|
||||
const safe: Breadcrumb = {
|
||||
timestamp: breadcrumb.timestamp,
|
||||
type: breadcrumb.type,
|
||||
category: breadcrumb.category,
|
||||
level: breadcrumb.level,
|
||||
};
|
||||
|
||||
if (breadcrumb.category === "fetch" || breadcrumb.category === "xhr") {
|
||||
const method = breadcrumb.data?.method;
|
||||
const statusCode = breadcrumb.data?.status_code;
|
||||
const url = breadcrumb.data?.url;
|
||||
safe.data = {
|
||||
...(typeof method === "string" ? { method } : {}),
|
||||
...(typeof statusCode === "number" ? { status_code: statusCode } : {}),
|
||||
...(typeof url === "string" ? { url: sanitizeUrl(url) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return safe;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue