mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 16:33:12 +00:00
Merge branch 'main' into feat/ab/sh-288-event-notifications
This commit is contained in:
commit
019eb86894
71 changed files with 2772 additions and 2389 deletions
34
.github/renovate.json
vendored
34
.github/renovate.json
vendored
|
|
@ -1,8 +1,22 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["nuget", "github-actions", "terraform"],
|
||||
"enabledManagers": ["nuget", "github-actions", "terraform", "custom.regex"],
|
||||
"schedule": ["before 6am every weekday"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": [
|
||||
"EF_VERSION in the Elastic Beanstalk packaging script installs dotnet-ef at deploy time and must move with the tool manifest and EF Core packages"
|
||||
],
|
||||
"managerFilePatterns": ["/^scripts/package-elastic-beanstalk\\.sh$/"],
|
||||
"matchStrings": ["EF_VERSION=\"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
|
||||
"datasourceTemplate": "nuget",
|
||||
"depNameTemplate": "dotnet-ef",
|
||||
"versioningTemplate": "nuget"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": [
|
||||
|
|
@ -12,8 +26,10 @@
|
|||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": ["Group non-major nuget updates"],
|
||||
"matchManagers": ["nuget"],
|
||||
"description": [
|
||||
"Group non-major nuget updates, including the dotnet-ef pin in the packaging script"
|
||||
],
|
||||
"matchManagers": ["nuget", "custom.regex"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "nuget minor and patch"
|
||||
},
|
||||
|
|
@ -35,6 +51,18 @@
|
|||
"matchUpdateTypes": ["major"],
|
||||
"groupName": "aspnetcore and ef core"
|
||||
},
|
||||
{
|
||||
"description": [
|
||||
"net8.0 target: hold ASP.NET Core, EF Core, and dotnet-ef at 8.x until the TargetFramework moves; the group above takes over then"
|
||||
],
|
||||
"matchPackageNames": [
|
||||
"Microsoft.AspNetCore{/,}**",
|
||||
"Microsoft.EntityFrameworkCore{/,}**",
|
||||
"dotnet-ef"
|
||||
],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": ["Keep AWS SDK majors together"],
|
||||
"matchPackageNames": ["/^AWSSDK\\./"],
|
||||
|
|
|
|||
10
.github/workflows/architecture-quality.yml
vendored
10
.github/workflows/architecture-quality.yml
vendored
|
|
@ -2,6 +2,10 @@ name: Architecture and changed-file quality
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
# Required by the merge queue; see ci.yml.
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -24,6 +28,8 @@ jobs:
|
|||
- name: Repository quality gate
|
||||
shell: bash
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.sha }}
|
||||
# A merge group carries its own base and head; github.event.before is
|
||||
# empty on that event.
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
|
||||
run: bash scripts/governance-check.sh
|
||||
|
|
|
|||
60
.github/workflows/ci-terraform.yaml
vendored
Normal file
60
.github/workflows/ci-terraform.yaml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Terraform CI
|
||||
|
||||
# Static checks only. Plans run in HCP Terraform as speculative VCS runs on the
|
||||
# PR (shoc-backend-dev and shoc-backend-staging). Applies are HCP auto-apply
|
||||
# on merge to main (dev) and on a vX.Y.Z-staging tag (staging).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/deploy.yaml"
|
||||
- ".github/workflows/deploy-tag.yaml"
|
||||
- ".github/workflows/release.yaml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "terraform/**"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform fmt
|
||||
run: terraform fmt -check -recursive terraform
|
||||
|
||||
- name: Validate live/dev
|
||||
run: |
|
||||
terraform -chdir=terraform/live/dev init -backend=false
|
||||
terraform -chdir=terraform/live/dev validate
|
||||
|
||||
- name: Validate live/staging
|
||||
run: |
|
||||
terraform -chdir=terraform/live/staging init -backend=false
|
||||
terraform -chdir=terraform/live/staging validate
|
||||
|
||||
- name: Import plan guard tests
|
||||
run: python3 scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: Release promotion script tests
|
||||
run: |
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
|
@ -3,6 +3,11 @@ name: Backend CI
|
|||
on:
|
||||
pull_request:
|
||||
branches: [main, dev, staging]
|
||||
# The merge queue builds main plus the queued pull requests on a temporary
|
||||
# branch and only counts checks that ran on the merge_group event.
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -24,11 +29,6 @@ jobs:
|
|||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.9.8"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore SeaHavenIndustries.sln
|
||||
|
||||
|
|
@ -37,29 +37,3 @@ jobs:
|
|||
|
||||
- name: Test
|
||||
run: dotnet test SeaHavenIndustries.sln --no-build --configuration Release
|
||||
|
||||
- name: Terraform fmt and validate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
directories=()
|
||||
case "${{ github.base_ref }}" in
|
||||
dev)
|
||||
directories+=(terraform/live/dev)
|
||||
;;
|
||||
staging)
|
||||
directories+=(terraform/live/staging)
|
||||
;;
|
||||
esac
|
||||
|
||||
for dir in "${directories[@]}"; do
|
||||
terraform -chdir="$dir" fmt -check -recursive
|
||||
terraform -chdir="$dir" init -backend=false
|
||||
terraform -chdir="$dir" validate
|
||||
done
|
||||
|
||||
- name: Terraform import plan guard tests
|
||||
run: python scripts/test-terraform-import-plan-check.py
|
||||
|
||||
- name: Terraform release plan guard tests
|
||||
run: python scripts/test-terraform-release-plan-check.py
|
||||
|
|
|
|||
13
.github/workflows/dependency-review.yml
vendored
13
.github/workflows/dependency-review.yml
vendored
|
|
@ -1,8 +1,21 @@
|
|||
name: Dependency Review
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
review:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@4a6cbfd362140a68810f0f46d338026863b8e827 # v1.0.10
|
||||
|
||||
# The dependency-review action only diffs a pull request, and the org callable
|
||||
# does not take explicit base and head refs. Every pull request in a merge
|
||||
# group already passed the real review above before it could be queued, so the
|
||||
# merge group reports the same required check name and passes.
|
||||
review-merge-group:
|
||||
if: github.event_name == 'merge_group'
|
||||
name: review / dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Dependency review ran on the pull request before it entered the merge queue."
|
||||
|
|
|
|||
45
.github/workflows/deploy-tag.yaml
vendored
Normal file
45
.github/workflows/deploy-tag.yaml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Deploy API from tag
|
||||
|
||||
# Human CLI escape hatch. GITHUB_TOKEN tag pushes from release.yaml do not
|
||||
# start this workflow. No path filters.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: Resolve tag
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
environment: ${{ steps.resolve.outputs.environment }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: resolve
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
environment="$(python3 -c 'import os, sys; sys.path.insert(0, "scripts"); from next_release_tag import parse_environment_from_tag; print(parse_environment_from_tag(os.environ["TAG"]))')"
|
||||
{
|
||||
echo "environment=${environment}"
|
||||
echo "ref=${TAG}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
deploy:
|
||||
needs: target
|
||||
uses: ./.github/workflows/deploy.yaml
|
||||
with:
|
||||
environment: ${{ needs.target.outputs.environment }}
|
||||
ref: ${{ needs.target.outputs.ref }}
|
||||
secrets: inherit
|
||||
407
.github/workflows/deploy.yaml
vendored
Normal file
407
.github/workflows/deploy.yaml
vendored
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
name: Deploy API
|
||||
|
||||
# GitHub owns Elastic Beanstalk application versions. Terraform ignores
|
||||
# version_label. Do not put path filters on tag events; those live in
|
||||
# deploy-tag.yaml.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Target Environment
|
||||
required: true
|
||||
type: choice
|
||||
options: [dev, staging, prod]
|
||||
ref:
|
||||
description: Git ref to build (tag, branch, or SHA). Empty means this run's SHA.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "terraform/**"
|
||||
- "**/*.md"
|
||||
- ".github/workflows/ci.yml"
|
||||
- ".github/workflows/ci-terraform.yaml"
|
||||
- ".github/workflows/architecture-quality.yml"
|
||||
- ".github/workflows/release.yaml"
|
||||
- ".github/workflows/deploy-tag.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: Resolve target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
environment: ${{ steps.resolve.outputs.environment }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- id: resolve
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CALL_ENVIRONMENT: ${{ inputs.environment }}
|
||||
CALL_REF: ${{ inputs.ref }}
|
||||
INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
INPUT_REF: ${{ github.event.inputs.ref }}
|
||||
GITHUB_SHA_IN: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A called reusable workflow keeps the caller's github.event_name
|
||||
# (push or workflow_dispatch), not workflow_call. Prefer the call
|
||||
# inputs whenever they are set.
|
||||
if [ -n "${CALL_ENVIRONMENT}" ]; then
|
||||
environment="${CALL_ENVIRONMENT}"
|
||||
ref="${CALL_REF:-${GITHUB_SHA_IN}}"
|
||||
else
|
||||
case "${EVENT_NAME}" in
|
||||
workflow_dispatch)
|
||||
environment="${INPUT_ENVIRONMENT}"
|
||||
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
||||
;;
|
||||
push)
|
||||
environment=dev
|
||||
ref="${GITHUB_SHA_IN}"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported event ${EVENT_NAME}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "${environment}" in
|
||||
dev|staging|prod) ;;
|
||||
*)
|
||||
echo "unknown environment ${environment}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
{
|
||||
echo "environment=${environment}"
|
||||
echo "ref=${ref}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
echo "Deploying ${ref} to ${environment}"
|
||||
|
||||
deploy:
|
||||
name: Deploy ${{ needs.target.outputs.environment }}
|
||||
needs: target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
environment: ${{ needs.target.outputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-api-${{ needs.target.outputs.environment }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
checks: read
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||
TARGET_REF: ${{ needs.target.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.target.outputs.ref }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Require tag on main
|
||||
if: needs.target.outputs.environment != 'dev'
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_OR_REF: ${{ needs.target.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="$(gh api "repos/${REPO}/compare/main...${TAG_OR_REF}" --jq .status)"
|
||||
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
||||
echo "ref ${TAG_OR_REF} is not on main (compare status: ${status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require CI on the SHA
|
||||
if: needs.target.outputs.environment != 'dev'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/require_commit_checks.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--sha "${{ steps.commit.outputs.sha }}" \
|
||||
--timeout-seconds 60
|
||||
|
||||
- name: Skip prod AWS until live/prod exists
|
||||
id: prod-gate
|
||||
if: needs.target.outputs.environment == 'prod'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ vars.PROD_APP_CD_ENABLED }}" = "true" ]; then
|
||||
echo "skip_aws=false" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "PROD_APP_CD_ENABLED is not true; reviewers already approved; skipping AWS."
|
||||
echo "skip_aws=true" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
- name: Set up .NET
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Build Elastic Beanstalk source bundle
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
run: bash scripts/package-elastic-beanstalk.sh
|
||||
|
||||
- name: Validate exact release bundle
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
run: bash scripts/validate-elastic-beanstalk-bundle.sh
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prefix="/shoc-backend/${TARGET_ENVIRONMENT}/deploy"
|
||||
APPLICATION=$(aws ssm get-parameter --name "${prefix}/application-name" --query Parameter.Value --output text)
|
||||
ENVIRONMENT_NAME=$(aws ssm get-parameter --name "${prefix}/environment-name" --query Parameter.Value --output text)
|
||||
ARTIFACTS_BUCKET=$(aws ssm get-parameter --name "${prefix}/artifacts-bucket" --query Parameter.Value --output text)
|
||||
SMOKE_URL=$(aws ssm get-parameter --name "${prefix}/smoke-url" --query Parameter.Value --output text)
|
||||
{
|
||||
echo "application=${APPLICATION}"
|
||||
echo "environment_name=${ENVIRONMENT_NAME}"
|
||||
echo "artifacts_bucket=${ARTIFACTS_BUCKET}"
|
||||
echo "smoke_url=${SMOKE_URL}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Capture current environment version
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
env:
|
||||
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev="$(aws elasticbeanstalk describe-environments \
|
||||
--environment-names "${ENVIRONMENT_NAME}" \
|
||||
--region us-east-1 \
|
||||
--query 'Environments[0].VersionLabel' \
|
||||
--output text)"
|
||||
echo "previous_version_label=${prev}" >> "${GITHUB_ENV}"
|
||||
echo "Previous version label: ${prev}"
|
||||
|
||||
- name: Upload bundle and update environment
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
env:
|
||||
APPLICATION: ${{ steps.deploy.outputs.application }}
|
||||
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
||||
ARTIFACTS_BUCKET: ${{ steps.deploy.outputs.artifacts_bucket }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_label="${GIT_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
s3_key="shoc-backend/releases/${TARGET_ENVIRONMENT}/${GIT_SHA}/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/site.zip"
|
||||
aws s3 cp .artifacts/elastic-beanstalk/site.zip \
|
||||
"s3://${ARTIFACTS_BUCKET}/${s3_key}" \
|
||||
--region us-east-1
|
||||
aws elasticbeanstalk create-application-version \
|
||||
--application-name "${APPLICATION}" \
|
||||
--version-label "${version_label}" \
|
||||
--description "GitHub Actions ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--source-bundle "S3Bucket=${ARTIFACTS_BUCKET},S3Key=${s3_key}" \
|
||||
--process \
|
||||
--region us-east-1
|
||||
status="UNPROCESSED"
|
||||
for _ in $(seq 1 36); do
|
||||
status="$(aws elasticbeanstalk describe-application-versions \
|
||||
--application-name "${APPLICATION}" \
|
||||
--version-labels "${version_label}" \
|
||||
--region us-east-1 \
|
||||
--query 'ApplicationVersions[0].Status' \
|
||||
--output text)"
|
||||
echo "application version status: $status"
|
||||
if [ "$status" = "PROCESSED" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$status" = "FAILED" ]; then
|
||||
echo "Elastic Beanstalk failed to process ${version_label}." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$status" != "PROCESSED" ]; then
|
||||
echo "Application version did not become PROCESSED." >&2
|
||||
exit 1
|
||||
fi
|
||||
aws elasticbeanstalk update-environment \
|
||||
--environment-name "${ENVIRONMENT_NAME}" \
|
||||
--version-label "${version_label}" \
|
||||
--region us-east-1
|
||||
echo "version_label=${version_label}" >> "${GITHUB_ENV}"
|
||||
echo "environment_updated=true" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify exact application version is active
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
env:
|
||||
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="${version_label}"
|
||||
status="Unknown"
|
||||
current="Unknown"
|
||||
health="Unknown"
|
||||
for _ in $(seq 1 80); do
|
||||
read -r status current health < <(
|
||||
aws elasticbeanstalk describe-environments \
|
||||
--environment-names "${ENVIRONMENT_NAME}" \
|
||||
--region us-east-1 \
|
||||
--query 'Environments[0].[Status,VersionLabel,Health]' \
|
||||
--output text
|
||||
)
|
||||
echo "environment status: $status; version: $current; health: $health"
|
||||
if [ "$status" = "Ready" ]; then
|
||||
if [ "$current" != "$expected" ]; then
|
||||
echo "Environment became Ready on version $current, not the expected $expected." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
|
||||
echo "Expected application version is Ready and healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Expected version is active; waiting for health to leave $health."
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "Expected application version did not become Ready and healthy within the deployment window (last seen: status=$status version=$current health=$health)." >&2
|
||||
exit 1
|
||||
|
||||
- name: Post-deploy smoke
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
run: bash scripts/smoke-elastic-beanstalk.sh "${{ steps.deploy.outputs.smoke_url }}"
|
||||
|
||||
- name: Verify webhook secret source is operational
|
||||
if: needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true'
|
||||
env:
|
||||
SMOKE_URL: ${{ steps.deploy.outputs.smoke_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
response_file="$(mktemp)"
|
||||
trap 'rm -f "$response_file"' EXIT
|
||||
status="$(curl --silent --show-error \
|
||||
--output "$response_file" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header "X-SH-Timestamp: $(date +%s)" \
|
||||
--header 'X-SH-Key-Id: deployment-smoke-invalid-key' \
|
||||
--header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \
|
||||
--data '{}' \
|
||||
"${SMOKE_URL}/api/webhooks/work-orders")"
|
||||
if [ "$status" != "401" ]; then
|
||||
echo "Expected 401 from enabled webhook; received $status." >&2
|
||||
sed -n '1,20p' "$response_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore previous application version on failure (schema is not reverted)
|
||||
if: ${{ failure() && !cancelled() && (needs.target.outputs.environment != 'prod' || steps.prod-gate.outputs.skip_aws != 'true') }}
|
||||
env:
|
||||
ENVIRONMENT_NAME: ${{ steps.deploy.outputs.environment_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prev="${previous_version_label:-}"
|
||||
if [ "${environment_updated:-}" != "true" ]; then
|
||||
echo "Environment was not updated; nothing to roll back."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$prev" ] || [ "$prev" = "null" ] || [ "$prev" = "None" ] || [ "$prev" = "N/A" ]; then
|
||||
echo "No previous version recorded; nothing to roll back." >&2
|
||||
exit 0
|
||||
fi
|
||||
if [ "$prev" = "${version_label:-}" ]; then
|
||||
echo "Previous version is the failed release; nothing to roll back." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for any in-flight environment update to settle..."
|
||||
status="Unknown"
|
||||
current="Unknown"
|
||||
health="Unknown"
|
||||
for _ in $(seq 1 80); do
|
||||
read -r status current health < <(
|
||||
aws elasticbeanstalk describe-environments \
|
||||
--environment-names "${ENVIRONMENT_NAME}" \
|
||||
--region us-east-1 \
|
||||
--query 'Environments[0].[Status,VersionLabel,Health]' \
|
||||
--output text
|
||||
)
|
||||
echo "environment status: $status; version: $current; health: $health"
|
||||
if [ "$status" = "Ready" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
if [ "$status" != "Ready" ]; then
|
||||
echo "Environment did not settle before rollback." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$current" = "$prev" ]; then
|
||||
echo "Environment is already on previous version $prev."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Restoring previous application version $prev."
|
||||
aws elasticbeanstalk update-environment \
|
||||
--environment-name "${ENVIRONMENT_NAME}" \
|
||||
--version-label "${prev}" \
|
||||
--region us-east-1
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
read -r status current health < <(
|
||||
aws elasticbeanstalk describe-environments \
|
||||
--environment-names "${ENVIRONMENT_NAME}" \
|
||||
--region us-east-1 \
|
||||
--query 'Environments[0].[Status,VersionLabel,Health]' \
|
||||
--output text
|
||||
)
|
||||
echo "environment status: $status; version: $current; health: $health"
|
||||
if [ "$status" = "Ready" ]; then
|
||||
if [ "$current" != "$prev" ]; then
|
||||
echo "Environment became Ready on version $current, not the previous $prev." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$health" = "Green" ] || [ "$health" = "Yellow" ]; then
|
||||
echo "Previous application version is Ready and healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Previous version is active; waiting for health to leave $health."
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "Environment did not return to Ready and healthy within the rollback window (last seen: status=$status version=$current health=$health)." >&2
|
||||
exit 1
|
||||
1238
.github/workflows/deploy.yml
vendored
1238
.github/workflows/deploy.yml
vendored
File diff suppressed because it is too large
Load diff
91
.github/workflows/release.yaml
vendored
Normal file
91
.github/workflows/release.yaml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
name: Release
|
||||
|
||||
# Cut a SemVer tag from main HEAD, then call deploy. GITHUB_TOKEN is enough;
|
||||
# it cannot start other workflows via the tag push, so this file calls deploy.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Target environment
|
||||
required: true
|
||||
type: choice
|
||||
options: [staging, prod]
|
||||
bump:
|
||||
description: SemVer bump from the last prod core tag
|
||||
required: true
|
||||
type: choice
|
||||
options: [patch, minor, major]
|
||||
message:
|
||||
description: Annotated tag message and GitHub Release body
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
cut:
|
||||
name: Cut tag
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
sha: ${{ steps.tag.outputs.sha }}
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Require main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Release must run from main (Use workflow from: main). Got ${GITHUB_REF}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require CI
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/require_commit_checks.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--sha "$(git rev-parse HEAD)" \
|
||||
--timeout-seconds 1200
|
||||
|
||||
- name: Compute and push tag
|
||||
id: tag
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.inputs.environment }}
|
||||
BUMP: ${{ github.event.inputs.bump }}
|
||||
MESSAGE: ${{ github.event.inputs.message }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags origin
|
||||
sha="$(git rev-parse HEAD)"
|
||||
tag="$(git tag | python3 scripts/next_release_tag.py --environment "${ENVIRONMENT}" --bump "${BUMP}")"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${tag}" -m "${MESSAGE}" "${sha}"
|
||||
git push origin "refs/tags/${tag}"
|
||||
gh release create "${tag}" --target "${sha}" --notes "${MESSAGE}" --title "${tag}"
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "sha=${sha}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
echo "Created ${tag} at ${sha}"
|
||||
|
||||
deploy:
|
||||
name: Deploy release
|
||||
needs: cut
|
||||
uses: ./.github/workflows/deploy.yaml
|
||||
with:
|
||||
environment: ${{ needs.cut.outputs.environment }}
|
||||
ref: ${{ needs.cut.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
|
@ -282,6 +282,92 @@ public sealed class UpliftQueueReadTests
|
|||
item.Status.Should().Be("Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_MapsDetailModalWorkOrderContext_DispatcherTechnicianAndScheduledDate()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-81", "SITE-N", "Plumbing");
|
||||
vendor.ContactName = " Tom Tech ";
|
||||
workOrder.ScheduledDate = new DateTime(2026, 4, 10, 9, 30, 0);
|
||||
workOrder.AssignTo = "dispatcher-1";
|
||||
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", FirstName = "Dana", LastName = "Ruiz" });
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-81");
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 4, 10, 9, 30, 0));
|
||||
item.WorkOrderDispatcherName.Should().Be("Dana Ruiz");
|
||||
item.TechnicianName.Should().Be("Tom Tech");
|
||||
item.VendorCompanyName.Should().Be("Gateway");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_DetailModalWorkOrderContext_IsNullForUnassignedUnscheduledWorkOrder()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-82", "SITE-N", "Plumbing");
|
||||
var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-82");
|
||||
context.DispatchUpliftRequests.Add(Request(dispatch, "Pending", new DateTime(2026, 4, 1)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var item = result.Items.Should().ContainSingle().Subject;
|
||||
item.WorkOrderScheduledDate.Should().BeNull();
|
||||
item.WorkOrderDispatcherName.Should().BeNull();
|
||||
item.TechnicianName.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_DetailModalWorkOrderContext_NeverCrossesAccounts()
|
||||
{
|
||||
// Two tenants' work orders sit side by side. Each queue row must carry the
|
||||
// dispatcher, technician and schedule of its own work order and vendor only;
|
||||
// another account's work order never supplies them.
|
||||
using var context = NewContext();
|
||||
var accountA = new Accounts { Name = "Account A" };
|
||||
var accountB = new Accounts { Name = "Account B" };
|
||||
context.AddRange(accountA, accountB);
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" },
|
||||
new ApplicationUser { Id = "dispatcher-b", FirstName = "Ben", LastName = "Beta" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var (vendorA, workOrderA) = await SeedWorkOrderAsync(context, "WO-A", "SITE-A", "HVAC", "Vendor A");
|
||||
vendorA.ContactName = "Tech A";
|
||||
workOrderA.AccountId = accountA.Id;
|
||||
workOrderA.AssignTo = "dispatcher-a";
|
||||
workOrderA.ScheduledDate = new DateTime(2026, 5, 1);
|
||||
var (vendorB, workOrderB) = await SeedWorkOrderAsync(context, "WO-B", "SITE-B", "Roofing", "Vendor B");
|
||||
vendorB.ContactName = "Tech B";
|
||||
workOrderB.AccountId = accountB.Id;
|
||||
workOrderB.AssignTo = "dispatcher-b";
|
||||
workOrderB.ScheduledDate = new DateTime(2026, 6, 1);
|
||||
var dispatchA = await SeedDispatchAsync(context, vendorA, workOrderA, "DIS-A");
|
||||
var dispatchB = await SeedDispatchAsync(context, vendorB, workOrderB, "DIS-B");
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
Request(dispatchA, "Pending", new DateTime(2026, 4, 1)),
|
||||
Request(dispatchB, "Pending", new DateTime(2026, 4, 2)));
|
||||
await context.SaveChangesAsync();
|
||||
var service = NewService(context);
|
||||
|
||||
var result = await service.ListAsync(UserWithRoles("Approver"), "Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
var rowA = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderA.Id).Subject;
|
||||
rowA.WorkOrderDispatcherName.Should().Be("Ann Alpha");
|
||||
rowA.TechnicianName.Should().Be("Tech A");
|
||||
rowA.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 5, 1));
|
||||
var rowB = result.Items.Should().ContainSingle(i => i.WorkOrderId == workOrderB.Id).Subject;
|
||||
rowB.WorkOrderDispatcherName.Should().Be("Ben Beta");
|
||||
rowB.TechnicianName.Should().Be("Tech B");
|
||||
rowB.WorkOrderScheduledDate.Should().Be(new DateTime(2026, 6, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ResolvesWorkOrderContext_ForDispatchLinkedOnlyThroughDispatchWorkOrders()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
Mock.Of<IWorkOrderPocService>(),
|
||||
advancedSearch);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ public class WorkOrderRouteContractTests
|
|||
"PATCH {id:int}/board",
|
||||
"PATCH {id:int}/comments/{commentId:int}",
|
||||
"PATCH {id:int}/media/{mediaId:int}",
|
||||
"PATCH {id:int}/poc",
|
||||
"POST AddChecklistItem",
|
||||
"POST AddComment",
|
||||
"POST AddCommentJson",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderPocService _pocService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
|
||||
public WorkOrderBoardController(
|
||||
|
|
@ -31,12 +32,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderPocService pocService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
{
|
||||
_workOrderBoardService = workOrderBoardService;
|
||||
_boardUpdateService = boardUpdateService;
|
||||
_boardCreateService = boardCreateService;
|
||||
_boardCancelService = boardCancelService;
|
||||
_pocService = pocService;
|
||||
_advancedSearchService = advancedSearchService;
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +168,56 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH-379: replaces the WO-level POC (name, phone, notes) with audit entries.
|
||||
/// Blank name+phone clears the override so the work order follows the Site.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:int}/poc")]
|
||||
public async Task<IActionResult> UpdateWorkOrderPoc(int id, [FromBody] WorkOrderPocUpdateRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _pocService.UpdatePocAsync(id, request, User, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardConcurrencyException ex)
|
||||
{
|
||||
return Conflict(new WorkOrderBoardConflictDto
|
||||
{
|
||||
CurrentState = ex.CurrentState
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("board")]
|
||||
public async Task<IActionResult> CreateBoardWorkOrder(
|
||||
[FromBody] WorkOrderBoardCreateRequestDto request,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ namespace Data.SeaHavenIndustries
|
|||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSiteCode { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
// SH-209: detail modal context, resolved from the same effective work order
|
||||
// and the requesting dispatch's vendor.
|
||||
public DateTime? WorkOrderScheduledDate { get; set; }
|
||||
public string? WorkOrderDispatcherFirstName { get; set; }
|
||||
public string? WorkOrderDispatcherLastName { get; set; }
|
||||
public string? TechnicianName { get; set; }
|
||||
public bool WorkOrderClosed { get; set; }
|
||||
public int AttachmentCount { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@
|
|||
| G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` |
|
||||
| G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced |
|
||||
| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` |
|
||||
| G11 | Terraform static validation | import configuration integrity | commands below | `ci` on the matching PR base |
|
||||
| G12 | Terraform release plan safety | dev application CD version_label | `python scripts/test-terraform-release-plan-check.py` | `architecture-quality` → `governance-check.sh` |
|
||||
| G11 | Terraform static validation | import configuration integrity | commands below | `ci-terraform` on PRs to `main` or `dev` |
|
||||
| G13 | App/Terraform isolation | separate delivery lanes | `python3 scripts/check_app_terraform_isolation.py` | `architecture-quality` → `governance-check.sh` |
|
||||
|
||||
## How to run locally
|
||||
|
||||
|
|
@ -36,44 +36,47 @@ bash scripts/governance-check.sh
|
|||
|
||||
# By default it compares against the default branch for changed-file formatting.
|
||||
# Override the comparison point:
|
||||
BASE_REF=origin/dev bash scripts/governance-check.sh
|
||||
BASE_REF=origin/main bash scripts/governance-check.sh
|
||||
BASE_REF=main bash scripts/governance-check.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
1. `dotnet restore` (G1)
|
||||
2. runs the `ArchitectureTests` filter with `--no-restore` (G2)
|
||||
3. computes changed `.cs` files vs `BASE_REF` (default `origin/dev`) and runs
|
||||
3. computes changed `.cs` files vs `BASE_REF` (default `origin/main`) and runs
|
||||
`dotnet format --verify-no-changes --include ...` (G3). When there are no
|
||||
changed C# files it skips G3 with an explicit "skipped: no changed C#" line.
|
||||
4. builds the complete solution in Release with no restore (G4).
|
||||
5. runs the complete solution test suite in Release with no rebuild (G5).
|
||||
6. verifies that the Terraform plan guard rejects create, delete, replacement,
|
||||
unmanaged resource types, and updates not allowlisted by exact address (G10).
|
||||
7. verifies that the release plan guard accepts only a version-only update of
|
||||
`module.environment.aws_elastic_beanstalk_environment.this` (G12).
|
||||
7. runs the release-tag, commit-check, and isolation unit tests.
|
||||
8. rejects a diff that contains both `terraform/` and deployable application
|
||||
files (G13).
|
||||
|
||||
G10 permits only exact approved resource address/type pairs for the
|
||||
environment-owned boundary: Elastic
|
||||
Beanstalk environment, IAM role/inline policy/managed-policy attachment/
|
||||
instance profile, Secrets Manager secret metadata, and Route 53 record.
|
||||
Initial mode permits no update. Controlled mode requires one
|
||||
SSM `/shoc-backend/<env>/deploy/*` parameters are created after adoption and
|
||||
are not part of the import allowlist. Initial mode permits no update.
|
||||
Controlled mode requires one
|
||||
`--allow-update-address` argument per reviewed in-place update. Every invocation
|
||||
also requires `--environment dev` or `--environment staging`; an empty or
|
||||
incomplete environment plan fails.
|
||||
|
||||
G11 runs `terraform fmt -check -recursive`, `terraform init -backend=false`,
|
||||
and `terraform validate`. PRs to `dev` validate `live/dev`.
|
||||
PRs to `staging` validate only `live/staging`. Org-baseline CloudFormation owns
|
||||
the HCP role substrate, and Terraform owns the dev deploy role, so no backend
|
||||
CDK or bootstrap root remains in the matrix.
|
||||
G11 runs `terraform fmt -check -recursive terraform`, `terraform init -backend=false`,
|
||||
and `terraform validate` for both `live/dev` and `live/staging` on every PR to
|
||||
`main` or `dev`. Org-baseline CloudFormation owns the HCP role substrate, and Terraform
|
||||
owns the environment GitHub deploy roles, so no backend CDK or bootstrap root
|
||||
remains in the matrix. HCP plan/apply roles stay in org-baseline; this
|
||||
repository never manages `hcptf-*` roles.
|
||||
|
||||
G12 accepts only a local or downloaded plan JSON whose sole managed update is
|
||||
`module.environment.aws_elastic_beanstalk_environment.this` with
|
||||
`version_label` as the only changed attribute. Counts of `0` add / `1` change /
|
||||
`0` destroy are not a substitute. The optional download uses
|
||||
`GET /api/v2/plans/:id/json-output` on `app.terraform.io` with one redirect to
|
||||
`archivist.terraform.io` and does not create, apply, discard, or poll runs.
|
||||
The former G12 version-only HCP apply guard is not part of the repository gate.
|
||||
|
||||
G13 fails when the same diff contains both `terraform/` and deployable
|
||||
application files. Workflow, documentation, and gate-script changes may share
|
||||
a PR with either side.
|
||||
|
||||
## Migration gates (G6)
|
||||
|
||||
|
|
|
|||
|
|
@ -109,9 +109,12 @@ Suppressions are single-diagnostic and cite the ADR — **no wildcard
|
|||
suppressions** (no global `[SuppressMessage]`, no `.editorconfig` severity
|
||||
sweeps, no `#pragma` swaths). See architecture §10.
|
||||
|
||||
Do not mix deployable application changes with Terraform or CDK changes. The
|
||||
first Terraform-owned application-CD change is the allowed exception because it
|
||||
introduces `release_version_label`. Later PRs must keep those diffs separate.
|
||||
Infra and application **PRs** stay separate. GitHub Actions owns Elastic
|
||||
Beanstalk application versions. HCP Terraform owns infrastructure and ignores
|
||||
`version_label`. A change set that includes both `terraform/` and deployable
|
||||
application files (`.cs`, `.csproj`, `.razor`, `.ebextensions/`, or the
|
||||
Elastic Beanstalk package/smoke scripts) fails the isolation check. Workflow,
|
||||
docs, and gate-script changes may travel with either side.
|
||||
|
||||
## 8. PR description contract (minimal)
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,20 @@ namespace SeaHaven.DataServices.Helpers
|
|||
c.Notes
|
||||
})
|
||||
.FirstOrDefault(),
|
||||
// SH-379/SH-190: the Site's live primary contact backs the
|
||||
// follow-the-site POC display when no override or link exists.
|
||||
SitePoc = w.Locations != null
|
||||
? w.Locations.Contacts!
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => new
|
||||
{
|
||||
Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(),
|
||||
c.PhoneNumber
|
||||
})
|
||||
.FirstOrDefault()
|
||||
: null,
|
||||
w.LifecycleStatus,
|
||||
w.LegacyStatus,
|
||||
w.Status,
|
||||
|
|
@ -116,11 +130,11 @@ namespace SeaHaven.DataServices.Helpers
|
|||
: null;
|
||||
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
|
||||
var pocName = primaryFrozenPoc?.Name
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name);
|
||||
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
|
||||
var pocPhone = primaryFrozenPoc?.Phone
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber);
|
||||
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
|
||||
var pocNotes = frozenPoc?.Notes
|
||||
?? (!string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes);
|
||||
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
|
@ -188,10 +202,15 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.MediaCount,
|
||||
frozenSite,
|
||||
frozenCompany,
|
||||
frozenPoc);
|
||||
frozenPoc,
|
||||
w.WoPocName,
|
||||
w.WoPocPhone);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string? FirstNotBlank(params string?[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
|
||||
private static T? Deserialize<T>(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ namespace SeaHaven.DataServices.Implementation
|
|||
WorkOrderNumber = x.workOrder != null ? x.workOrder.InternalWONumber : null,
|
||||
WorkOrderSiteCode = x.workOrder != null ? x.workOrder.SiteCode : null,
|
||||
WorkOrderService = x.workOrder != null ? x.workOrder.Service : null,
|
||||
// SH-209: the detail modal reads its work-order context from this row,
|
||||
// so it never depends on a separate work-order fetch. Technician
|
||||
// follows the board convention (vendor contact) for the requesting
|
||||
// dispatch's vendor, the same vendor as VendorCompanyName.
|
||||
WorkOrderScheduledDate = x.workOrder != null ? x.workOrder.ScheduledDate : null,
|
||||
WorkOrderDispatcherFirstName = x.workOrder != null && x.workOrder.AssignToUser != null
|
||||
? x.workOrder.AssignToUser.FirstName
|
||||
: null,
|
||||
WorkOrderDispatcherLastName = x.workOrder != null && x.workOrder.AssignToUser != null
|
||||
? x.workOrder.AssignToUser.LastName
|
||||
: null,
|
||||
TechnicianName = x.v != null ? x.v.ContactName : null,
|
||||
// SH-208: a work order is closed for uplift decisions once its
|
||||
// lifecycle reaches a terminal state; mirrors the SH-196 revoke guard.
|
||||
WorkOrderClosed = x.workOrder != null
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders
|
||||
.Include(workOrder => workOrder.Locations)
|
||||
.Include(workOrder => workOrder.Locations!)
|
||||
.ThenInclude(location => location.Contacts)
|
||||
.Include(workOrder => workOrder.WorkOrderContacts!)
|
||||
.ThenInclude(contact => contact.POC)
|
||||
.Include(workOrder => workOrder.PrimaryDispatch!)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderPocDataService : IWorkOrderPocDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderPocDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken)
|
||||
=> _context.workOrders
|
||||
.Include(workOrder => workOrder.Locations!)
|
||||
.ThenInclude(location => location.Contacts)
|
||||
.Include(workOrder => workOrder.WorkOrderContacts!)
|
||||
.ThenInclude(contact => contact.POC)
|
||||
.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId
|
||||
&& w.istemplate != true
|
||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||
&& (accountId == null || w.AccountId == accountId),
|
||||
cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
|
||||
int? locationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (locationId == null)
|
||||
return Array.Empty<Contacts>();
|
||||
|
||||
return await _context.Contacts
|
||||
.AsNoTracking()
|
||||
.Where(c => c.LocationId == locationId && c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal file
28
SeaHaven.DataServices/Interfaces/IWorkOrderPocDataService.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: data access for the work-order-level POC override.
|
||||
/// </summary>
|
||||
public interface IWorkOrderPocDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-221 account-scoped tracked load for POC updates. Includes the site's
|
||||
/// contacts and the linked WorkOrderContacts POC. Null when the work order
|
||||
/// does not exist or is outside the caller's account scope.
|
||||
/// </summary>
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(
|
||||
int workOrderId,
|
||||
int? accountId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// The site's live contacts for a location ordered by SiteContactOrder then Id.
|
||||
/// Deleted contacts are excluded. Empty when the location has none.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Contacts>> GetSiteContactsAsync(
|
||||
int? locationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int MediaCount = 0,
|
||||
WorkOrderFrozenSite? FrozenSite = null,
|
||||
WorkOrderFrozenCompany? FrozenCompany = null,
|
||||
WorkOrderFrozenPoc? FrozenPoc = null);
|
||||
WorkOrderFrozenPoc? FrozenPoc = null,
|
||||
string? WoPocName = null,
|
||||
string? WoPocPhone = null);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderSite { get; set; }
|
||||
public string? WorkOrderService { get; set; }
|
||||
// SH-209: detail modal work-order context.
|
||||
public DateTime? WorkOrderScheduledDate { get; set; }
|
||||
public string? WorkOrderDispatcherName { get; set; }
|
||||
public string? TechnicianName { get; set; }
|
||||
public string? RequestedByName { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>SH-190: a manual WO-level POC override is present (locked away from the Site).</summary>
|
||||
public bool PocCustomized { get; set; }
|
||||
public List<WorkOrderAdditionalContactDto>? AdditionalContacts { get; set; }
|
||||
public WorkOrderFrozenSiteDto? FrozenSite { get; set; }
|
||||
public WorkOrderFrozenCompanyDto? FrozenCompany { get; set; }
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@ namespace SeaHaven.Services.DTOs
|
|||
public int? Severity { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH-379: replaces the work-order-level POC override (PocName/PocPhone/PocNotes).
|
||||
/// Blank name and phone clear the override so the work order follows the Site's
|
||||
/// live contact again. An edit equal to a live Site contact does not lock (SH-190).
|
||||
/// </summary>
|
||||
public class WorkOrderPocUpdateRequestDto
|
||||
{
|
||||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>Base64 row version, same concurrency token as the board patch.</summary>
|
||||
public string? WorkOrderVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ namespace SeaHaven.Services.Helpers
|
|||
var contact = workOrder.WorkOrderContacts?
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefault();
|
||||
// SH-379/SH-190: a work order without an override or link freezes the
|
||||
// Site's live primary contact, matching the board display precedence.
|
||||
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(location?.Contacts);
|
||||
var pocName = FirstNotBlank(
|
||||
workOrder.PocName,
|
||||
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName));
|
||||
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber);
|
||||
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
|
||||
WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
|
||||
|
||||
var contacts = new List<WorkOrderFrozenPocContact>();
|
||||
|
|
|
|||
|
|
@ -32,17 +32,18 @@ namespace SeaHaven.Services.Helpers
|
|||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
|
||||
};
|
||||
|
||||
// A browser fills the multipart part's Content-Type from File.type, which mobile
|
||||
// browsers leave empty (or the client sends octet-stream) when the OS cannot
|
||||
// classify a picked file. Only then is the type resolved from the extension; the
|
||||
// category rule, extension pairing, and magic-byte signature still decide.
|
||||
private static readonly HashSet<string> UndeterminedContentTypes =
|
||||
new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" };
|
||||
|
||||
// The multipart part's Content-Type comes from the browser's File.type, which is
|
||||
// unreliable on mobile: it is left empty or sent as application/octet-stream when the
|
||||
// OS cannot classify a picked file, and is sometimes a foreign-but-plausible video
|
||||
// type the OS attaches to a supported container (e.g. video/3gpp for an .mp4,
|
||||
// video/x-quicktime for a .mov). An allowlisted declared type stays authoritative and
|
||||
// pairs against its own extension; anything else falls back to the extension. The
|
||||
// extension pairing and magic-byte signature below still decide, so this never widens
|
||||
// the accepted set of files.
|
||||
private static string? ResolveContentType(string declaredType, string extension)
|
||||
{
|
||||
if (!UndeterminedContentTypes.Contains(declaredType))
|
||||
return AllowedContentTypes.Contains(declaredType) ? declaredType : null;
|
||||
if (AllowedContentTypes.Contains(declaredType))
|
||||
return declaredType;
|
||||
|
||||
foreach (var (contentType, extensions) in ExtensionsByContentType)
|
||||
{
|
||||
|
|
|
|||
51
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal file
51
SeaHaven.Services/Helpers/WorkOrderPocSiteContact.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379 / SH-190: single resolution for "the Site's live contact" used by the
|
||||
/// manual POC override. The same rules back the board projection fallback, the
|
||||
/// completion snapshot fallback, the create-time baseline, and the
|
||||
/// "changing away from the contact the work order follows" lock comparison.
|
||||
/// </summary>
|
||||
public static class WorkOrderPocSiteContact
|
||||
{
|
||||
/// <summary>
|
||||
/// The Site's primary live contact: first non-deleted contact ordered by
|
||||
/// SiteContactOrder then Id. Null when the site has no live contacts.
|
||||
/// </summary>
|
||||
public static Contacts? ResolvePrimary(IEnumerable<Contacts>? contacts)
|
||||
=> contacts?
|
||||
.Where(c => c.IsDeleted != true)
|
||||
.OrderBy(c => c.SiteContactOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>Display name matching the board projection: trimmed First + Last.</summary>
|
||||
public static string? DisplayName(Contacts? contact)
|
||||
=> contact == null
|
||||
? null
|
||||
: Normalize(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
||||
|
||||
/// <summary>
|
||||
/// SH-190: true when a manual POC (trimmed name + phone) equals the single
|
||||
/// contact the work order currently follows — the linked WorkOrderContacts
|
||||
/// POC, or else the Site primary. Such an edit is not a change away from
|
||||
/// that contact, so it must not lock or store an override. Comparing against
|
||||
/// only the followed contact (rather than any live Site contact) means an
|
||||
/// edit to a different live contact is still stored, since the work order
|
||||
/// only ever displays the one it follows.
|
||||
/// </summary>
|
||||
public static bool Matches(string? name, string? phone, Contacts? contact)
|
||||
{
|
||||
if (contact == null)
|
||||
return false;
|
||||
|
||||
return string.Equals(DisplayName(contact), Normalize(name), StringComparison.Ordinal)
|
||||
&& string.Equals(Normalize(contact.PhoneNumber), Normalize(phone), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,9 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderNumber = r.WorkOrderNumber,
|
||||
WorkOrderSite = r.WorkOrderSiteCode,
|
||||
WorkOrderService = r.WorkOrderService,
|
||||
WorkOrderScheduledDate = r.WorkOrderScheduledDate,
|
||||
WorkOrderDispatcherName = ResolveRequestedByName(null, r.WorkOrderDispatcherFirstName, r.WorkOrderDispatcherLastName),
|
||||
TechnicianName = string.IsNullOrWhiteSpace(r.TechnicianName) ? null : r.TechnicianName.Trim(),
|
||||
RequestedByName = ResolveRequestedByName(r.RequestedByVendorName, r.RequestedByFirstName, r.RequestedByLastName),
|
||||
DecidedByName = ResolveRequestedByName(null, r.DecidedByFirstName, r.DecidedByLastName),
|
||||
CurrentNTE = r.CurrentNTE,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
private readonly IWorkOrderPocDataService _pocData;
|
||||
private readonly IServicesRegistryService? _servicesRegistryService;
|
||||
|
||||
public WorkOrderBoardCreateService(
|
||||
|
|
@ -29,6 +30,7 @@ namespace SeaHaven.Services.Implementation
|
|||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderBoardCreateValidation validator,
|
||||
IWorkOrderAccountResolver accountResolver,
|
||||
IWorkOrderPocDataService pocData,
|
||||
IServicesRegistryService? servicesRegistryService = null)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
|
|
@ -37,6 +39,7 @@ namespace SeaHaven.Services.Implementation
|
|||
_auditService = auditService;
|
||||
_validator = validator;
|
||||
_accountResolver = accountResolver;
|
||||
_pocData = pocData;
|
||||
_servicesRegistryService = servicesRegistryService;
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +67,25 @@ namespace SeaHaven.Services.Implementation
|
|||
var pocName = TrimOrNull(request.PocName);
|
||||
var pocPhone = TrimOrNull(request.PocPhone);
|
||||
var pocNotes = TrimOrNull(request.PocNotes);
|
||||
// SH-190 AC3: a create-time POC equal to the single contact the work
|
||||
// order will follow is not an override. Store nothing so the work order
|
||||
// keeps following that contact until a dispatcher edits away from it.
|
||||
// The followed contact is the linked POC (PocContactId) when one is
|
||||
// supplied, otherwise the Site primary. Comparing against the followed
|
||||
// contact rather than any live Site contact means a create-time POC that
|
||||
// matches a *different* Site contact is stored, since the board only
|
||||
// shows the one the work order follows. A supplied PocContactId that is
|
||||
// not among the Site's live contacts leaves no follow target, so the
|
||||
// typed POC is stored.
|
||||
var siteContacts = await _pocData.GetSiteContactsAsync(request.LocationId, cancellationToken);
|
||||
var followContact = request.PocContactId.HasValue
|
||||
? siteContacts.FirstOrDefault(c => c.Id == request.PocContactId.Value)
|
||||
: WorkOrderPocSiteContact.ResolvePrimary(siteContacts);
|
||||
if (WorkOrderPocSiteContact.Matches(pocName, pocPhone, followContact))
|
||||
{
|
||||
pocName = null;
|
||||
pocPhone = null;
|
||||
}
|
||||
var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
|
||||
var techPhone = TrimOrNull(request.TechPhone);
|
||||
var vendorNotes = TrimOrNull(request.VendorNotes);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ namespace SeaHaven.Services.Implementation
|
|||
PocName = row.PocName,
|
||||
PocPhone = row.PocPhone,
|
||||
PocNotes = row.PocNotes,
|
||||
PocCustomized = !string.IsNullOrWhiteSpace(row.WoPocName) || !string.IsNullOrWhiteSpace(row.WoPocPhone),
|
||||
FrozenSite = MapFrozenSite(row.FrozenSite),
|
||||
FrozenCompany = MapFrozenCompany(row.FrozenCompany),
|
||||
FrozenPoc = MapFrozenPoc(row.FrozenPoc),
|
||||
|
|
|
|||
176
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal file
176
SeaHaven.Services/Implementation/WorkOrderPocService.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: persists a manual POC override on an existing work order. The manual
|
||||
/// value is stored in the WO-level PocName/PocPhone/PocNotes fields, staged as
|
||||
/// FieldChanged audit entries (which also write field locks so later syncs never
|
||||
/// overwrite a manual POC, SH-190 AC2), and captured by the completion freeze.
|
||||
/// An edit equal to the contact the work order currently follows (its linked
|
||||
/// WorkOrderContacts POC, or else the Site primary) stores no override, so a
|
||||
/// never-overridden work order keeps following the Site (SH-190 AC3).
|
||||
/// </summary>
|
||||
public class WorkOrderPocService : IWorkOrderPocService
|
||||
{
|
||||
private readonly IWorkOrderPocDataService _pocData;
|
||||
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
||||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||
|
||||
public WorkOrderPocService(
|
||||
IWorkOrderPocDataService pocData,
|
||||
IWorkOrderBoardMutationDataService mutationData,
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderAccountResolver accountResolver)
|
||||
{
|
||||
_pocData = pocData;
|
||||
_mutationData = mutationData;
|
||||
_boardService = boardService;
|
||||
_auditService = auditService;
|
||||
_accountResolver = accountResolver;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> UpdatePocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderPocUpdateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId)
|
||||
{
|
||||
var accountId = _accountResolver.ResolveAccountFilter(user);
|
||||
|
||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
||||
if (workOrderVersion == null)
|
||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||
|
||||
ValidateLengths(request);
|
||||
|
||||
await _mutationData.ExecuteTransactionalAsync(async ct =>
|
||||
{
|
||||
var workOrder = await _pocData.GetTrackedWorkOrderAsync(workOrderId, accountId, ct);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
|
||||
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
||||
if (WorkOrderBoardMutationRules.IsCoreLocked(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
var sitePrimary = WorkOrderPocSiteContact.ResolvePrimary(workOrder.Locations?.Contacts);
|
||||
var linkedContact = workOrder.WorkOrderContacts?
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefault()?.POC;
|
||||
|
||||
var newName = TrimOrNull(request.PocName);
|
||||
var newPhone = TrimOrNull(request.PocPhone);
|
||||
var newNotes = TrimOrNull(request.PocNotes);
|
||||
|
||||
// SH-190: changing away from the contact the work order follows
|
||||
// locks the POC. An edit that equals that single contact — the
|
||||
// linked WorkOrderContacts POC, or else the Site primary — (and
|
||||
// blanking both fields) stores no override, so the work order keeps
|
||||
// following it. Comparing against the followed contact rather than
|
||||
// any live Site contact means editing to a *different* Site contact
|
||||
// is stored as an override, since the board only ever shows the one
|
||||
// the work order follows.
|
||||
var followContact = linkedContact ?? sitePrimary;
|
||||
var overrideName = newName;
|
||||
var overridePhone = newPhone;
|
||||
if ((overrideName != null || overridePhone != null)
|
||||
&& WorkOrderPocSiteContact.Matches(overrideName, overridePhone, followContact))
|
||||
{
|
||||
overrideName = null;
|
||||
overridePhone = null;
|
||||
}
|
||||
|
||||
var oldEffectiveName = FirstNotBlank(workOrder.PocName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var oldEffectivePhone = FirstNotBlank(workOrder.PocPhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
var newEffectiveName = FirstNotBlank(overrideName, ContactDisplayName(linkedContact), WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var newEffectivePhone = FirstNotBlank(overridePhone, linkedContact?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
|
||||
var storedChanged = !string.Equals(workOrder.PocName, overrideName, StringComparison.Ordinal)
|
||||
|| !string.Equals(workOrder.PocPhone, overridePhone, StringComparison.Ordinal)
|
||||
|| !string.Equals(workOrder.PocNotes, newNotes, StringComparison.Ordinal);
|
||||
|
||||
var oldNotes = workOrder.PocNotes;
|
||||
workOrder.PocName = overrideName;
|
||||
workOrder.PocPhone = overridePhone;
|
||||
workOrder.PocNotes = newNotes;
|
||||
|
||||
if (!storedChanged)
|
||||
return;
|
||||
|
||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
||||
|
||||
// Audit effective old -> new values with the same field names create
|
||||
// uses, so the trail reads consistently across create and update.
|
||||
if (!string.Equals(oldEffectiveName, newEffectiveName, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocName", oldEffectiveName, newEffectiveName, actorId);
|
||||
if (!string.Equals(oldEffectivePhone, newEffectivePhone, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocPhone", oldEffectivePhone, newEffectivePhone, actorId);
|
||||
if (!string.Equals(TrimOrNull(oldNotes), newNotes, StringComparison.Ordinal))
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "PocNotes", TrimOrNull(oldNotes), newNotes, actorId);
|
||||
|
||||
var outcome = await _mutationData.SaveAsync(ct);
|
||||
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
|
||||
throw new WorkOrderBoardConcurrencyException(await LoadBoardRowAsync(workOrderId, user));
|
||||
}, CancellationToken.None);
|
||||
|
||||
var row = await LoadBoardRowAsync(workOrderId, user);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId, ClaimsPrincipal user)
|
||||
=> await _boardService.GetBoardRowAsync(workOrderId, user);
|
||||
|
||||
private static void ValidateLengths(WorkOrderPocUpdateRequestDto request)
|
||||
{
|
||||
if (request.PocName?.Length > 200)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocName must be at most 200 characters.");
|
||||
if (request.PocPhone?.Length > 50)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocPhone must be at most 50 characters.");
|
||||
if (request.PocNotes?.Length > 2000)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "pocNotes must be at most 2000 characters.");
|
||||
}
|
||||
|
||||
private static string? ContactDisplayName(Contacts? contact)
|
||||
=> contact == null
|
||||
? null
|
||||
: TrimOrNull(((contact.FirstName ?? "") + " " + (contact.LastName ?? "")).Trim());
|
||||
|
||||
private static string? FirstNotBlank(params string?[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
|
||||
=> current != null && current.AsSpan().SequenceEqual(expected);
|
||||
}
|
||||
}
|
||||
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal file
18
SeaHaven.Services/Interfaces/IWorkOrderPocService.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// SH-379: persists a manual POC override on an existing work order with audit
|
||||
/// entries, and applies the SH-190 follow-site / lock semantics.
|
||||
/// </summary>
|
||||
public interface IWorkOrderPocService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> UpdatePocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderPocUpdateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,91 @@ public sealed class UpliftQueueReadRelationalTests
|
|||
Assert.True(item.WorkOrderClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_ResolvesDetailModalContext_PerAccountWorkOrder()
|
||||
{
|
||||
// SH-209: dispatcher (work-order assignee navigation), technician (vendor
|
||||
// contact) and scheduled date must translate to SQL through the effective
|
||||
// work-order subquery, and each row resolves them from its own account's
|
||||
// work order only.
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteUpliftTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
var accountA = new Accounts { Name = "Account A" };
|
||||
var accountB = new Accounts { Name = "Account B" };
|
||||
var dispatcherA = new ApplicationUser { Id = "dispatcher-a", FirstName = "Ann", LastName = "Alpha" };
|
||||
var vendorA = new Vendor { CompanyName = "Vendor A", ContactName = "Tech A", IsActive = true };
|
||||
var vendorB = new Vendor { CompanyName = "Vendor B", ContactName = "Tech B", IsActive = true };
|
||||
context.AddRange(accountA, accountB, dispatcherA, vendorA, vendorB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var workOrderA = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-A",
|
||||
WorkerOrderTitle = "Repair",
|
||||
AccountId = accountA.Id,
|
||||
AssignTo = dispatcherA.Id,
|
||||
ScheduledDate = new DateTime(2026, 5, 1, 8, 0, 0)
|
||||
};
|
||||
var workOrderB = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-B",
|
||||
WorkerOrderTitle = "Repair",
|
||||
AccountId = accountB.Id
|
||||
};
|
||||
context.AddRange(workOrderA, workOrderB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dispatchA = new Dispatch { VendorId = vendorA.Id, WorkOrderId = workOrderA.Id, DispatchNumber = "DIS-A", Status = "Scheduled" };
|
||||
var dispatchB = new Dispatch { VendorId = vendorB.Id, WorkOrderId = workOrderB.Id, DispatchNumber = "DIS-B", Status = "Scheduled" };
|
||||
context.Dispatches.AddRange(dispatchA, dispatchB);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
context.DispatchUpliftRequests.AddRange(
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchA.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 4, 1),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 100m,
|
||||
NotificationStatus = "Pending"
|
||||
},
|
||||
new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = dispatchB.Id,
|
||||
Status = "Pending",
|
||||
CreatedDate = new DateTime(2026, 4, 2),
|
||||
RequiredTier = 1,
|
||||
RequestedNTE = 200m,
|
||||
NotificationStatus = "Pending"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var data = new UpliftDataService(context);
|
||||
|
||||
var (total, items) = await data.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, total);
|
||||
var rowA = Assert.Single(items, i => i.WorkOrderId == workOrderA.Id);
|
||||
Assert.Equal("Ann", rowA.WorkOrderDispatcherFirstName);
|
||||
Assert.Equal("Alpha", rowA.WorkOrderDispatcherLastName);
|
||||
Assert.Equal("Tech A", rowA.TechnicianName);
|
||||
Assert.Equal(new DateTime(2026, 5, 1, 8, 0, 0), rowA.WorkOrderScheduledDate);
|
||||
var rowB = Assert.Single(items, i => i.WorkOrderId == workOrderB.Id);
|
||||
Assert.Null(rowB.WorkOrderDispatcherFirstName);
|
||||
Assert.Null(rowB.WorkOrderDispatcherLastName);
|
||||
Assert.Equal("Tech B", rowB.TechnicianName);
|
||||
Assert.Null(rowB.WorkOrderScheduledDate);
|
||||
}
|
||||
|
||||
private sealed class SqliteUpliftTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteUpliftTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public class WorkOrderAccountScopeTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
return new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -232,7 +232,7 @@ public class WorkOrderAccountScopeTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var create = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await create.CreateAsync(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
|
|
@ -111,7 +112,8 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public class WorkOrderBoardCreateServiceTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var validator = new WorkOrderBoardCreateValidation();
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver);
|
||||
var service = new WorkOrderBoardCreateService(boardData, mutationData, boardService, audit, validator, resolver, new WorkOrderPocDataService(context));
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
@ -81,7 +82,8 @@ public class WorkOrderBoardCreateSyncLockTests
|
|||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
resolver);
|
||||
resolver,
|
||||
new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
|
|
|
|||
|
|
@ -2084,11 +2084,40 @@ public class WorkOrderMediaFileRulesTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected()
|
||||
public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected()
|
||||
{
|
||||
// A concrete declared type is authoritative; only an undetermined one falls back to the extension.
|
||||
// An allowlisted declared type stays authoritative and pairs against its own
|
||||
// extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof.
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// SH-381: mobile browsers attach a foreign-but-plausible type to a supported container.
|
||||
// The extension plus magic bytes must decide, not the unreliable declared MIME.
|
||||
[InlineData("clip.mp4", "video/3gpp")]
|
||||
[InlineData("VID_0001.MP4", "video/3gpp")]
|
||||
[InlineData("IMG_1587.MOV", "video/x-quicktime")]
|
||||
[InlineData("IMG_1587.mov", "video/mpeg")]
|
||||
[InlineData("photo.jpg", "image/heic")]
|
||||
public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature(
|
||||
string fileName,
|
||||
string contentType)
|
||||
{
|
||||
Assert.True(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension()
|
||||
{
|
||||
// Falling back to the extension does not weaken the gate: the bytes must still match
|
||||
// the resolved type, the extension must still be supported, and a resolved document
|
||||
// stays out of photo/video categories.
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp")));
|
||||
Assert.False(WorkOrderMediaFileRules.IsAllowed(
|
||||
FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before));
|
||||
}
|
||||
|
||||
private static byte[] BytesFor(string fileName)
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ public class WorkOrderPhase7CoexistenceTests
|
|||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver, new WorkOrderPocDataService(context));
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(new WorkOrderIngestDataService(context), policy, fieldLocks, audit, WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
|
|
|
|||
641
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal file
641
SeaHavenIndustries.Tests/WorkOrderPocServiceTests.cs
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderPocServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ManualOverridePersistsIsAuditedAndLocks()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", "Call first", workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Manual POC", persisted.PocName);
|
||||
Assert.Equal("312-555-0001", persisted.PocPhone);
|
||||
Assert.Equal("Call first", persisted.PocNotes);
|
||||
Assert.Equal("Manual POC", row.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Alice Site", auditName.OldValue);
|
||||
Assert.Equal("Manual POC", auditName.NewValue);
|
||||
Assert.Equal("FieldChanged", auditName.Action);
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualOverrideSurvivesCompleteAndLaterSiteChanges()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var updateService = CreateUpdateService(context);
|
||||
var completed = await updateService.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
Assert.Equal("Manual POC", completed.PocName);
|
||||
|
||||
var siteContact = await context.Contacts.SingleAsync(c => c.FirstName == "Alice");
|
||||
siteContact.FirstName = "Changed";
|
||||
siteContact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Manual POC", row!.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteContactUpdateDoesNotOverwriteManualOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
var siteContact = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
siteContact.FirstName = "Rotated";
|
||||
siteContact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Manual POC", row!.PocName);
|
||||
Assert.Equal("312-555-0001", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlockedWorkOrderFollowsSiteContact()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
var primary = await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardService = CreateBoardService(context);
|
||||
var before = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", before!.PocName);
|
||||
Assert.Equal("312-555-0100", before.PocPhone);
|
||||
Assert.False(before.PocCustomized);
|
||||
|
||||
primary.PhoneNumber = "312-555-0999";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var after = await boardService.GetBoardRowAsync(workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", after!.PocName);
|
||||
Assert.Equal("312-555-0999", after.PocPhone);
|
||||
Assert.False(after.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditEqualToSiteContactDoesNotLock()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Alice Site", "312-555-0100", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
Assert.False(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.Where(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName")
|
||||
.OrderByDescending(a => a.Id)
|
||||
.FirstAsync();
|
||||
Assert.Equal("Manual POC", auditName.OldValue);
|
||||
Assert.Equal("Alice Site", auditName.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearingBothFieldsFollowsSiteAgain()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", "keep notes", workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc(null, null, null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.False(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrossTenantUpdateIsRejected()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context, accountId: 2);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(accountId: 1),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingAccountScopeIsForbidden()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.MissingScope(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletedWorkOrderIsReadOnly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Completed;
|
||||
workOrder.CompletedDate = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledWorkOrderIsReadOnly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Manual POC", "312-555-0001", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleVersionThrowsConflict()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreatePocService(context);
|
||||
var stale = new WorkOrderPocUpdateRequestDto
|
||||
{
|
||||
PocName = "Manual POC",
|
||||
PocPhone = "312-555-0001",
|
||||
WorkOrderVersion = Convert.ToBase64String(new byte[] { 9, 9, 9, 9 })
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||
service.UpdatePocAsync(workOrder.Id, stale, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletionFreezesSiteContactWhenNoOverrideOrLink()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.ScheduledDate = DateTime.UtcNow.Date.AddDays(1);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var updateService = CreateUpdateService(context);
|
||||
var completed = await updateService.PatchFieldAsync(
|
||||
workOrder.Id,
|
||||
Patch(WorkOrderBoardFieldNames.LifecycleStatus, "Complete", workOrder),
|
||||
"actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
|
||||
Assert.Equal("Alice Site", completed.PocName);
|
||||
Assert.Equal("312-555-0100", completed.PocPhone);
|
||||
|
||||
var contact = await context.Contacts.SingleAsync();
|
||||
contact.PhoneNumber = "999-555-0100";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var row = await CreateBoardService(context).GetBoardRowAsync(
|
||||
workOrder.Id, WorkOrderAccountTestHelpers.AccountUser());
|
||||
Assert.Equal("Alice Site", row!.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocEqualToSiteContactFollowsSite()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Alice Site",
|
||||
PocPhone = "312-555-0100"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Null(persisted.PocName);
|
||||
Assert.Null(persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.False(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocAwayFromSiteContactIsStored()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Manual POC",
|
||||
PocPhone = "312-555-0001"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Equal("Manual POC", persisted.PocName);
|
||||
Assert.Equal("312-555-0001", persisted.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditToSecondSiteContactStoresOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
await SeedSiteContactAsync(context, firstName: "Bob", lastName: "Backup", phone: "312-555-0200", order: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// The WO follows the Site primary (Alice). Editing to the second live Site
|
||||
// contact (Bob) is a change away from what the board shows and must be
|
||||
// stored and locked, not silently cleared back to Alice.
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Bob Backup", "312-555-0200", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Bob Backup", persisted.PocName);
|
||||
Assert.Equal("312-555-0200", persisted.PocPhone);
|
||||
Assert.Equal("Bob Backup", row.PocName);
|
||||
Assert.Equal("312-555-0200", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Alice Site", auditName.OldValue);
|
||||
Assert.Equal("Bob Backup", auditName.NewValue);
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocName"));
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l =>
|
||||
l.WorkOrderId == workOrder.Id && l.FieldName == "PocPhone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditToSitePrimaryWhenLinkedContactDiffersStoresOverride()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedScopeAsync(context);
|
||||
var workOrder = NewWorkOrder(context);
|
||||
await SeedSiteContactAsync(context, firstName: "Alice", lastName: "Site", phone: "312-555-0100", order: 0);
|
||||
var linked = new Contacts
|
||||
{
|
||||
FirstName = "Carol",
|
||||
LastName = "Linked",
|
||||
PhoneNumber = "312-555-0300"
|
||||
};
|
||||
context.Contacts.Add(linked);
|
||||
await context.SaveChangesAsync();
|
||||
context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = linked.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// The WO follows its linked contact (Carol). Typing the Site's primary
|
||||
// (Alice) is a change away from what the board shows and must be stored,
|
||||
// not cleared back to Carol.
|
||||
var service = CreatePocService(context);
|
||||
var row = await service.UpdatePocAsync(
|
||||
workOrder.Id,
|
||||
Poc("Alice Site", "312-555-0100", null, workOrder),
|
||||
WorkOrderAccountTestHelpers.AccountUser(),
|
||||
"actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrder.Id);
|
||||
Assert.Equal("Alice Site", persisted.PocName);
|
||||
Assert.Equal("312-555-0100", persisted.PocPhone);
|
||||
Assert.Equal("Alice Site", row.PocName);
|
||||
Assert.Equal("312-555-0100", row.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
|
||||
var auditName = await context.WorkOrderAuditLogs.SingleAsync(a =>
|
||||
a.WorkOrderId == workOrder.Id && a.FieldName == "PocName");
|
||||
Assert.Equal("Carol Linked", auditName.OldValue);
|
||||
Assert.Equal("Alice Site", auditName.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreateWithPocEqualToSecondSiteContactIsStored()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Alice",
|
||||
LastName = "Site",
|
||||
PhoneNumber = "312-555-0100",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 0
|
||||
});
|
||||
context.Contacts.Add(new Contacts
|
||||
{
|
||||
FirstName = "Bob",
|
||||
LastName = "Backup",
|
||||
PhoneNumber = "312-555-0200",
|
||||
LocationId = 1,
|
||||
SiteContactOrder = 1
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Create-time POC equal to the second Site contact (Bob) is not the contact
|
||||
// the WO would follow (the primary, Alice), so it must be stored.
|
||||
var service = CreateCreateService(context);
|
||||
var row = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 1,
|
||||
PocName = "Bob Backup",
|
||||
PocPhone = "312-555-0200"
|
||||
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||
|
||||
var persisted = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id);
|
||||
Assert.Equal("Bob Backup", persisted.PocName);
|
||||
Assert.Equal("312-555-0200", persisted.PocPhone);
|
||||
Assert.True(row.PocCustomized);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task SeedScopeAsync(ApplicationDbContext context)
|
||||
{
|
||||
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static WorkOrder NewWorkOrder(ApplicationDbContext context, int accountId = 1)
|
||||
{
|
||||
var location = context.Locations.Local.Single(l => l.Id == 1);
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000012345",
|
||||
SiteCode = "BK5",
|
||||
LocationId = location.Id,
|
||||
Locations = location,
|
||||
AccountId = accountId,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = Version()
|
||||
};
|
||||
context.workOrders.Add(workOrder);
|
||||
return workOrder;
|
||||
}
|
||||
|
||||
private static async Task<Contacts> SeedSiteContactAsync(
|
||||
ApplicationDbContext context,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string phone,
|
||||
int order = 0)
|
||||
{
|
||||
var contact = new Contacts
|
||||
{
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
PhoneNumber = phone,
|
||||
LocationId = 1,
|
||||
SiteContactOrder = order
|
||||
};
|
||||
context.Contacts.Add(contact);
|
||||
await context.SaveChangesAsync();
|
||||
return contact;
|
||||
}
|
||||
|
||||
private static WorkOrderPocService CreatePocService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderPocDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
CreateBoardService(context),
|
||||
new WorkOrderAuditService(
|
||||
new WorkOrderAuditDataService(context),
|
||||
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
private static WorkOrderBoardService CreateBoardService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderBoardDataService(context),
|
||||
WorkOrderAccountTestHelpers.Resolver(context));
|
||||
|
||||
private static WorkOrderBoardUpdateService CreateUpdateService(ApplicationDbContext context)
|
||||
{
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
return new WorkOrderBoardUpdateService(
|
||||
new WorkOrderBoardDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
|
||||
}
|
||||
|
||||
private static WorkOrderBoardCreateService CreateCreateService(ApplicationDbContext context)
|
||||
=> new(
|
||||
new WorkOrderBoardDataService(context),
|
||||
new WorkOrderBoardMutationDataService(context),
|
||||
CreateBoardService(context),
|
||||
new WorkOrderAuditService(
|
||||
new WorkOrderAuditDataService(context),
|
||||
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
|
||||
new WorkOrderBoardCreateValidation(),
|
||||
WorkOrderAccountTestHelpers.Resolver(context),
|
||||
new WorkOrderPocDataService(context));
|
||||
|
||||
private static WorkOrderPocUpdateRequestDto Poc(
|
||||
string? name,
|
||||
string? phone,
|
||||
string? notes,
|
||||
WorkOrder workOrder)
|
||||
=> new()
|
||||
{
|
||||
PocName = name,
|
||||
PocPhone = phone,
|
||||
PocNotes = notes,
|
||||
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
|
||||
};
|
||||
|
||||
private static WorkOrderBoardPatchRequestDto Patch(
|
||||
string field,
|
||||
string value,
|
||||
WorkOrder workOrder)
|
||||
=> new()
|
||||
{
|
||||
Field = field,
|
||||
Value = value,
|
||||
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion ?? Version())
|
||||
};
|
||||
|
||||
private static byte[] Version() => new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||
}
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject HCP Terraform plans that are not a version-only Elastic Beanstalk update.
|
||||
|
||||
This script may read a local plan JSON file or download plan JSON from the
|
||||
documented HashiCorp endpoint:
|
||||
|
||||
GET https://app.terraform.io/api/v2/plans/:id/json-output
|
||||
|
||||
The download follows exactly one redirect, and only to archivist.terraform.io.
|
||||
It does not create, apply, discard, or poll runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
RELEASE_ADDRESS = "module.environment.aws_elastic_beanstalk_environment.this"
|
||||
API_HOST = "app.terraform.io"
|
||||
ARCHIVE_HOST = "archivist.terraform.io"
|
||||
PLAN_ID_RE = re.compile(r"^plan-[A-Za-z0-9]+$")
|
||||
VERSION_LABEL_RE = re.compile(r"^[0-9a-f]{40}-[0-9]+-[0-9]+$")
|
||||
IGNORED_ACTIONS = {"no-op", "read"}
|
||||
UNSAFE_ACTIONS = {"create", "delete"}
|
||||
# Wholly unknown computed attributes may be ignored. Nested unknowns on any
|
||||
# other attribute are treated as changes so the version-only guard fails closed.
|
||||
COMPUTED_UNKNOWN_ATTRIBUTES = frozenset({"instances", "load_balancers"})
|
||||
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||
|
||||
UrlOpen = Callable[..., Any]
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Return the redirect response instead of following it."""
|
||||
|
||||
def http_error_301(self, req, fp, code, msg, headers):
|
||||
return self._capture(req, fp, code, headers)
|
||||
|
||||
http_error_302 = http_error_303 = http_error_307 = http_error_308 = http_error_301
|
||||
|
||||
@staticmethod
|
||||
def _capture(req, fp, code, headers):
|
||||
response = urllib.response.addinfourl(fp, headers, req.full_url, code=code)
|
||||
response.msg = "Redirect"
|
||||
return response
|
||||
|
||||
|
||||
def _urlopen_without_redirects(
|
||||
*handlers: urllib.request.BaseHandler,
|
||||
) -> UrlOpen:
|
||||
context = ssl.create_default_context()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context),
|
||||
_NoRedirectHandler,
|
||||
*handlers,
|
||||
)
|
||||
return opener.open
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument(
|
||||
"plan_json",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
help="Local Terraform plan JSON. Mutually exclusive with --plan-id.",
|
||||
)
|
||||
source.add_argument(
|
||||
"--plan-id",
|
||||
help="HCP Terraform plan ID. Downloads JSON from app.terraform.io.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-version-label",
|
||||
required=True,
|
||||
help="Immutable application version the plan must apply.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--evidence-out",
|
||||
type=Path,
|
||||
help="Write machine-readable proof after every assertion passes.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def download_plan_json(
|
||||
plan_id: str,
|
||||
token: str,
|
||||
*,
|
||||
urlopen: UrlOpen | None = None,
|
||||
handlers: tuple[urllib.request.BaseHandler, ...] = (),
|
||||
) -> dict[str, Any]:
|
||||
if not PLAN_ID_RE.fullmatch(plan_id):
|
||||
raise ValueError(f"plan id {plan_id!r} is not a valid HCP plan id")
|
||||
if not token:
|
||||
raise ValueError("TF_API_TOKEN is required to download plan JSON")
|
||||
|
||||
opener = urlopen or _urlopen_without_redirects(*handlers)
|
||||
api_url = f"https://{API_HOST}/api/v2/plans/{plan_id}/json-output"
|
||||
request = urllib.request.Request(
|
||||
api_url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
first = _open_pinned(opener, request, allowed_host=API_HOST)
|
||||
try:
|
||||
if first.status == 204:
|
||||
raise ValueError(
|
||||
"plan JSON is not ready; refusing to poll the plans endpoint"
|
||||
)
|
||||
if first.status not in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"expected a redirect from {API_HOST}, got HTTP {first.status}"
|
||||
)
|
||||
location = first.headers.get("Location")
|
||||
if not location:
|
||||
raise ValueError(f"{API_HOST} redirect is missing a Location header")
|
||||
archive = urlparse(location)
|
||||
if archive.scheme != "https" or archive.hostname != ARCHIVE_HOST:
|
||||
raise ValueError(
|
||||
"refusing redirect that is not https://"
|
||||
f"{ARCHIVE_HOST}/"
|
||||
)
|
||||
archive_request = urllib.request.Request(location, method="GET")
|
||||
second = _open_pinned(opener, archive_request, allowed_host=ARCHIVE_HOST)
|
||||
try:
|
||||
if second.status in REDIRECT_STATUSES:
|
||||
raise ValueError(
|
||||
f"refusing a second redirect from {ARCHIVE_HOST}"
|
||||
)
|
||||
if second.status != 200:
|
||||
raise ValueError(
|
||||
f"plan JSON download from {ARCHIVE_HOST} returned "
|
||||
f"HTTP {second.status}"
|
||||
)
|
||||
payload = second.read()
|
||||
finally:
|
||||
second.close()
|
||||
finally:
|
||||
first.close()
|
||||
|
||||
plan = json.loads(payload.decode("utf-8"))
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("plan JSON must be an object")
|
||||
return plan
|
||||
|
||||
|
||||
def _open_pinned(urlopen: UrlOpen, request: urllib.request.Request, *, allowed_host: str):
|
||||
parsed = urlparse(request.full_url)
|
||||
if parsed.scheme != "https" or parsed.hostname != allowed_host:
|
||||
raise ValueError(
|
||||
f"refusing to contact {parsed.scheme}://{parsed.hostname} "
|
||||
f"(pinned host is {allowed_host})"
|
||||
)
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
return urlopen(request, context=context, timeout=30)
|
||||
except TypeError:
|
||||
return urlopen(request, timeout=30)
|
||||
|
||||
|
||||
def _is_nested_unknown(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(item is True or _is_nested_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def changed_attributes(change: dict[str, Any]) -> set[str]:
|
||||
before = change.get("before") or {}
|
||||
after = change.get("after") or {}
|
||||
unknown = change.get("after_unknown") or {}
|
||||
keys = set(before) | set(after) | set(unknown)
|
||||
changed: set[str] = set()
|
||||
for key in keys:
|
||||
unknown_value = unknown.get(key)
|
||||
if unknown_value is True:
|
||||
if key in COMPUTED_UNKNOWN_ATTRIBUTES:
|
||||
continue
|
||||
changed.add(key)
|
||||
continue
|
||||
if _is_nested_unknown(unknown_value):
|
||||
changed.add(key)
|
||||
continue
|
||||
if before.get(key) != after.get(key):
|
||||
changed.add(key)
|
||||
return changed
|
||||
|
||||
|
||||
def validate_plan(plan: dict[str, Any], expected_label: str) -> list[str]:
|
||||
violations: list[str] = []
|
||||
if not VERSION_LABEL_RE.fullmatch(expected_label):
|
||||
violations.append(
|
||||
"expected version label must be <full-sha>-<run-id>-<attempt>"
|
||||
)
|
||||
return violations
|
||||
|
||||
updates: list[dict[str, Any]] = []
|
||||
for resource in plan.get("resource_changes", []):
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address", "<unknown>")
|
||||
change = resource.get("change") or {}
|
||||
actions = list(change.get("actions") or [])
|
||||
action_set = set(actions)
|
||||
if action_set <= IGNORED_ACTIONS:
|
||||
continue
|
||||
|
||||
if change.get("importing"):
|
||||
violations.append(f"{address}: import actions are not allowed")
|
||||
|
||||
unsafe = sorted(action_set & UNSAFE_ACTIONS)
|
||||
if unsafe:
|
||||
violations.append(f"{address}: unsafe actions {unsafe}")
|
||||
if "replace" in action_set or actions in (
|
||||
["delete", "create"],
|
||||
["create", "delete"],
|
||||
):
|
||||
violations.append(f"{address}: replacement is not allowed")
|
||||
|
||||
if "update" in action_set:
|
||||
updates.append(resource)
|
||||
if action_set != {"update"}:
|
||||
violations.append(
|
||||
f"{address}: update must be the only action, got {actions}"
|
||||
)
|
||||
|
||||
if address != RELEASE_ADDRESS and action_set - IGNORED_ACTIONS:
|
||||
violations.append(
|
||||
f"{address}: managed address is outside the version-only release"
|
||||
)
|
||||
|
||||
if len(updates) != 1:
|
||||
violations.append(
|
||||
f"expected exactly one managed update, found {len(updates)}"
|
||||
)
|
||||
return violations
|
||||
|
||||
resource = updates[0]
|
||||
address = resource.get("address", "<unknown>")
|
||||
if address != RELEASE_ADDRESS:
|
||||
violations.append(
|
||||
f"{address}: expected update address {RELEASE_ADDRESS}"
|
||||
)
|
||||
return violations
|
||||
|
||||
change = resource.get("change") or {}
|
||||
changed = changed_attributes(change)
|
||||
if changed != {"version_label"}:
|
||||
violations.append(
|
||||
f"{address}: expected only version_label to change, found "
|
||||
f"{sorted(changed) if changed else 'no attribute changes'}"
|
||||
)
|
||||
|
||||
after = change.get("after") or {}
|
||||
actual = after.get("version_label")
|
||||
if actual != expected_label:
|
||||
violations.append(
|
||||
f"{address}: after version_label {actual!r} does not match "
|
||||
f"{expected_label!r}"
|
||||
)
|
||||
|
||||
unknown = change.get("after_unknown") or {}
|
||||
if unknown.get("version_label") is True:
|
||||
violations.append(f"{address}: version_label after value is unknown")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.plan_id:
|
||||
try:
|
||||
plan = download_plan_json(args.plan_id, os.environ.get("TF_API_TOKEN", ""))
|
||||
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
|
||||
print(f"FAIL: could not download plan JSON: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
if args.plan_json is None:
|
||||
print("FAIL: plan JSON path or --plan-id is required", file=sys.stderr)
|
||||
return 1
|
||||
plan = json.loads(args.plan_json.read_text(encoding="utf-8"))
|
||||
|
||||
violations = validate_plan(plan, args.expected_version_label)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not a version-only release", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.evidence_out:
|
||||
evidence = {
|
||||
"address": RELEASE_ADDRESS,
|
||||
"expected_version_label": args.expected_version_label,
|
||||
"managed_updates": 1,
|
||||
"changed_attributes": ["version_label"],
|
||||
"creates": 0,
|
||||
"deletes": 0,
|
||||
"replacements": 0,
|
||||
}
|
||||
args.evidence_out.write_text(
|
||||
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
"PASS: version-only plan updates "
|
||||
f"{RELEASE_ADDRESS} version_label to {args.expected_version_label}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
69
scripts/check_app_terraform_isolation.py
Normal file
69
scripts/check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail when a change set mixes Terraform with deployable application files.
|
||||
|
||||
Workflow, docs, and gate-script changes may travel with either side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
APP_SCRIPT_NAMES = {
|
||||
"scripts/package-elastic-beanstalk.sh",
|
||||
"scripts/validate-elastic-beanstalk-bundle.sh",
|
||||
"scripts/smoke-elastic-beanstalk.sh",
|
||||
}
|
||||
|
||||
APP_SUFFIXES = (".cs", ".csproj", ".razor")
|
||||
|
||||
|
||||
def is_terraform_path(path: str) -> bool:
|
||||
return path == "terraform" or path.startswith("terraform/")
|
||||
|
||||
|
||||
def is_app_path(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/")
|
||||
if normalized in APP_SCRIPT_NAMES:
|
||||
return True
|
||||
if normalized.startswith(".ebextensions/"):
|
||||
return True
|
||||
return normalized.endswith(APP_SUFFIXES)
|
||||
|
||||
|
||||
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
|
||||
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
|
||||
app_files = sorted({path for path in paths if is_app_path(path)})
|
||||
if terraform_files and app_files:
|
||||
return terraform_files, app_files
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="*",
|
||||
help="Changed paths. Omit and pass newline-separated paths on stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
paths = list(args.paths)
|
||||
if not paths and not sys.stdin.isatty():
|
||||
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||
violation = isolation_violation(paths)
|
||||
if violation is None:
|
||||
print("PASS: application and Terraform changes are isolated")
|
||||
return 0
|
||||
terraform_files, app_files = violation
|
||||
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
|
||||
print("terraform:", file=sys.stderr)
|
||||
for path in terraform_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
print("application:", file=sys.stderr)
|
||||
for path in app_files:
|
||||
print(f" {path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
#
|
||||
# Usage:
|
||||
# bash scripts/governance-check.sh
|
||||
# BASE_REF=origin/dev bash scripts/governance-check.sh
|
||||
# BASE_REF=origin/main bash scripts/governance-check.sh
|
||||
# BASE_REF=<base-sha> HEAD_REF=<head-sha> bash scripts/governance-check.sh
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -30,9 +30,9 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Comparison point for changed-file formatting. Default to the dev integration
|
||||
# branch locally; CI overrides BASE_REF/HEAD_REF with the PR base/head SHAs.
|
||||
BASE_REF="${BASE_REF:-origin/dev}"
|
||||
# Comparison point for changed-file formatting. Default to main locally; CI
|
||||
# overrides BASE_REF/HEAD_REF with the PR base/head SHAs.
|
||||
BASE_REF="${BASE_REF:-origin/main}"
|
||||
HEAD_REF="${HEAD_REF:-HEAD}"
|
||||
|
||||
# Resolve the base ref before using it for a diff.
|
||||
|
|
@ -83,8 +83,16 @@ log "G10: Terraform import plan safety"
|
|||
python scripts/test-terraform-import-plan-check.py
|
||||
ok "G10: Terraform import plan safety"
|
||||
|
||||
log "G12: Terraform release plan safety"
|
||||
python scripts/test-terraform-release-plan-check.py
|
||||
ok "G12: Terraform release plan safety"
|
||||
log "Release promotion scripts"
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
ok "Release promotion scripts"
|
||||
|
||||
log "G13: application and Terraform isolation (${BASE_REF}..${HEAD_REF})"
|
||||
python3 scripts/check_app_terraform_isolation.py < <(
|
||||
git diff --name-only --diff-filter=ACMR "${BASE_REF}" "${HEAD_REF}"
|
||||
)
|
||||
ok "G13: application and Terraform isolation"
|
||||
|
||||
log "governance-check: all required repository gates passed"
|
||||
|
|
|
|||
95
scripts/next_release_tag.py
Normal file
95
scripts/next_release_tag.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compute the next SemVer git tag for a staging or prod cut.
|
||||
|
||||
Base version is the highest existing core prod tag vX.Y.Z (not -staging).
|
||||
Missing tags start at 0.0.0. Staging gets v{next}-staging; prod gets v{next}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
PROD_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||
STAGING_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)-staging$")
|
||||
|
||||
|
||||
def parse_environment_from_tag(tag: str) -> str:
|
||||
if STAGING_TAG.fullmatch(tag):
|
||||
return "staging"
|
||||
if PROD_TAG.fullmatch(tag):
|
||||
return "prod"
|
||||
raise ValueError(
|
||||
f"tag {tag!r} is not vX.Y.Z or vX.Y.Z-staging"
|
||||
)
|
||||
|
||||
|
||||
def highest_prod_core(tags: list[str]) -> tuple[int, int, int]:
|
||||
cores: list[tuple[int, int, int]] = []
|
||||
for tag in tags:
|
||||
match = PROD_TAG.fullmatch(tag)
|
||||
if match:
|
||||
cores.append(tuple(int(part) for part in match.groups())) # type: ignore[arg-type]
|
||||
if not cores:
|
||||
return (0, 0, 0)
|
||||
return max(cores)
|
||||
|
||||
|
||||
def bump_core(core: tuple[int, int, int], bump: str) -> tuple[int, int, int]:
|
||||
major, minor, patch = core
|
||||
if bump == "major":
|
||||
return (major + 1, 0, 0)
|
||||
if bump == "minor":
|
||||
return (major, minor + 1, 0)
|
||||
if bump == "patch":
|
||||
return (major, minor, patch + 1)
|
||||
raise ValueError(f"bump must be major, minor, or patch, got {bump!r}")
|
||||
|
||||
|
||||
def format_tag(core: tuple[int, int, int], environment: str) -> str:
|
||||
name = f"v{core[0]}.{core[1]}.{core[2]}"
|
||||
if environment == "staging":
|
||||
return f"{name}-staging"
|
||||
if environment == "prod":
|
||||
return name
|
||||
raise ValueError(f"environment must be staging or prod, got {environment!r}")
|
||||
|
||||
|
||||
def next_release_tag(
|
||||
tags: list[str], environment: str, bump: str
|
||||
) -> str:
|
||||
if environment not in {"staging", "prod"}:
|
||||
raise ValueError(f"environment must be staging or prod, got {environment!r}")
|
||||
nxt = bump_core(highest_prod_core(tags), bump)
|
||||
tag = format_tag(nxt, environment)
|
||||
if tag in tags:
|
||||
raise ValueError(f"tag {tag} already exists")
|
||||
return tag
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--environment", required=True, choices=("staging", "prod"))
|
||||
parser.add_argument("--bump", required=True, choices=("major", "minor", "patch"))
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="tags",
|
||||
help="Existing git tag. Repeat, or omit and pass tags on stdin.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
tags = list(args.tags)
|
||||
if not tags and not sys.stdin.isatty():
|
||||
tags = [line.strip() for line in sys.stdin if line.strip()]
|
||||
try:
|
||||
print(next_release_tag(tags, args.environment, args.bump))
|
||||
except ValueError as exc:
|
||||
print(f"FAIL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
123
scripts/require_commit_checks.py
Normal file
123
scripts/require_commit_checks.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail unless required GitHub check runs succeeded on a commit SHA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REQUIRED_CHECK_NAMES = (
|
||||
"Build and test",
|
||||
"architecture",
|
||||
)
|
||||
|
||||
|
||||
def _run_recency(run: dict) -> tuple:
|
||||
"""Order check runs so a later rerun wins over an earlier result."""
|
||||
started = run.get("started_at") or ""
|
||||
completed = run.get("completed_at") or ""
|
||||
run_id = run.get("id") or 0
|
||||
return (started, completed, run_id)
|
||||
|
||||
|
||||
def classify_checks(
|
||||
check_runs: list[dict], required_names: tuple[str, ...] = REQUIRED_CHECK_NAMES
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Return ('success'|'pending'|'failure', detail lines)."""
|
||||
by_name: dict[str, dict] = {}
|
||||
for run in check_runs:
|
||||
name = run.get("name")
|
||||
if name not in required_names:
|
||||
continue
|
||||
current = by_name.get(name)
|
||||
if current is None or _run_recency(run) > _run_recency(current):
|
||||
by_name[name] = run
|
||||
|
||||
missing = [name for name in required_names if name not in by_name]
|
||||
if missing:
|
||||
return "pending", [f"missing: {name}" for name in missing]
|
||||
|
||||
details: list[str] = []
|
||||
pending = False
|
||||
failed = False
|
||||
for name in required_names:
|
||||
run = by_name[name]
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
details.append(f"{name} status={status} conclusion={conclusion}")
|
||||
if status != "completed":
|
||||
pending = True
|
||||
elif conclusion != "success":
|
||||
failed = True
|
||||
if failed:
|
||||
return "failure", details
|
||||
if pending:
|
||||
return "pending", details
|
||||
return "success", details
|
||||
|
||||
|
||||
def fetch_check_runs(repo: str, sha: str, token: str) -> list[dict]:
|
||||
url = (
|
||||
f"https://api.github.com/repos/{repo}/commits/{urllib.parse.quote(sha)}"
|
||||
"/check-runs?per_page=100"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
payload = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", "replace")
|
||||
raise SystemExit(f"GitHub check-runs HTTP {exc.code}: {body}") from exc
|
||||
return payload.get("check_runs") or []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--sha", required=True)
|
||||
parser.add_argument("--timeout-seconds", type=int, default=1200)
|
||||
parser.add_argument("--poll-seconds", type=int, default=15)
|
||||
args = parser.parse_args()
|
||||
token = __import__("os").environ.get("GITHUB_TOKEN") or __import__("os").environ.get(
|
||||
"GH_TOKEN"
|
||||
)
|
||||
if not token:
|
||||
print("FAIL: GITHUB_TOKEN or GH_TOKEN is required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
deadline = time.time() + args.timeout_seconds
|
||||
while True:
|
||||
runs = fetch_check_runs(args.repo, args.sha, token)
|
||||
state, details = classify_checks(runs)
|
||||
for line in details:
|
||||
print(line)
|
||||
if state == "success":
|
||||
print(f"PASS: required checks succeeded on {args.sha}")
|
||||
return 0
|
||||
if state == "failure":
|
||||
print(f"FAIL: required checks did not succeed on {args.sha}", file=sys.stderr)
|
||||
return 1
|
||||
if time.time() >= deadline:
|
||||
print(
|
||||
f"FAIL: timed out waiting for required checks on {args.sha}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"waiting {args.poll_seconds}s for checks...")
|
||||
time.sleep(args.poll_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,10 @@ COMMON_RESOURCES = {
|
|||
"module.environment.aws_iam_role_policy.runtime_app_config": "aws_iam_role_policy",
|
||||
"module.environment.aws_iam_role_policy_attachment.web_tier": "aws_iam_role_policy_attachment",
|
||||
"module.environment.aws_secretsmanager_secret.app_config": "aws_secretsmanager_secret",
|
||||
"module.environment.aws_ssm_parameter.deploy_application_name": "aws_ssm_parameter",
|
||||
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": "aws_ssm_parameter",
|
||||
"module.environment.aws_ssm_parameter.deploy_environment_name": "aws_ssm_parameter",
|
||||
"module.environment.aws_ssm_parameter.deploy_smoke_url": "aws_ssm_parameter",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
|
|
@ -52,6 +56,18 @@ DEV_IMPORT_IDS = {
|
|||
"module.environment.aws_route53_record.api_alias[0]": (
|
||||
"Z07671212N75U4YLPWZR8_api.dev.seahaven.com_A"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_application_name": (
|
||||
"/shoc-backend/dev/deploy/application-name"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": (
|
||||
"/shoc-backend/dev/deploy/artifacts-bucket"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_environment_name": (
|
||||
"/shoc-backend/dev/deploy/environment-name"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_smoke_url": (
|
||||
"/shoc-backend/dev/deploy/smoke-url"
|
||||
),
|
||||
}
|
||||
|
||||
DEV_IMPORT_BASELINE = {
|
||||
|
|
@ -92,6 +108,18 @@ STAGING_IMPORT_IDS = {
|
|||
"module.environment.aws_route53_record.api_cname[0]": (
|
||||
"Z02602739VQWBWCAGXP4_api.staging.seahaven.com_CNAME"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_application_name": (
|
||||
"/shoc-backend/staging/deploy/application-name"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_artifacts_bucket": (
|
||||
"/shoc-backend/staging/deploy/artifacts-bucket"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_environment_name": (
|
||||
"/shoc-backend/staging/deploy/environment-name"
|
||||
),
|
||||
"module.environment.aws_ssm_parameter.deploy_smoke_url": (
|
||||
"/shoc-backend/staging/deploy/smoke-url"
|
||||
),
|
||||
}
|
||||
|
||||
STAGING_IMPORT_BASELINE = {
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for check-terraform-release-plan.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from urllib.request import Request
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-release-plan.py")
|
||||
FIXTURES = Path(__file__).with_name("testdata") / "terraform-release-plans"
|
||||
EXPECTED_LABEL = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
PLAN_ID = "plan-8F5JFydVYAmtTjET"
|
||||
|
||||
|
||||
def run_case(
|
||||
fixture_name: str,
|
||||
*,
|
||||
expected_label: str = EXPECTED_LABEL,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(FIXTURES / fixture_name),
|
||||
"--expected-version-label",
|
||||
expected_label,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
status: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: bytes = b"",
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self._body = body
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def load_check_module():
|
||||
spec = importlib.util.spec_from_file_location("check_terraform_release_plan", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_download_pinning() -> list[str]:
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_urlopen(request: Request, **_kwargs):
|
||||
url = request.full_url
|
||||
calls.append(url)
|
||||
host = request.host if hasattr(request, "host") else ""
|
||||
if url.startswith("https://app.terraform.io/api/v2/plans/"):
|
||||
if request.get_header("Authorization") != "Bearer test-token":
|
||||
raise AssertionError("API request is missing the bearer token")
|
||||
if "/runs" in url or "/apply" in url or "/discard" in url:
|
||||
raise AssertionError(f"download contacted a run-control path: {url}")
|
||||
return FakeResponse(
|
||||
url=url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
if url == archive_url:
|
||||
if request.get_header("Authorization"):
|
||||
raise AssertionError("archivist request must not send TF_API_TOKEN")
|
||||
return FakeResponse(url=url, status=200, body=fixture)
|
||||
raise AssertionError(f"unexpected URL {url} host={host}")
|
||||
|
||||
plan = module.download_plan_json(PLAN_ID, "test-token", urlopen=fake_urlopen)
|
||||
failures: list[str] = []
|
||||
if plan["resource_changes"][1]["address"] != (
|
||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
||||
):
|
||||
failures.append("download did not return the version-only fixture")
|
||||
if calls != [
|
||||
f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output",
|
||||
archive_url,
|
||||
]:
|
||||
failures.append(f"download URLs were {calls}")
|
||||
|
||||
try:
|
||||
module.download_plan_json("run-not-a-plan", "test-token", urlopen=fake_urlopen)
|
||||
failures.append("invalid plan id was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def redirect_elsewhere(request: Request, **_kwargs):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://evil.example/plan.json"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=redirect_elsewhere)
|
||||
failures.append("redirect to a non-archivist host was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def double_redirect(request: Request, **_kwargs):
|
||||
if request.full_url.startswith("https://app.terraform.io/"):
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": archive_url},
|
||||
)
|
||||
return FakeResponse(
|
||||
url=request.full_url,
|
||||
status=307,
|
||||
headers={"Location": "https://archivist.terraform.io/v1/object/other"},
|
||||
)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=double_redirect)
|
||||
failures.append("second archivist redirect was accepted")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def not_ready(request: Request, **_kwargs):
|
||||
return FakeResponse(url=request.full_url, status=204)
|
||||
|
||||
try:
|
||||
module.download_plan_json(PLAN_ID, "test-token", urlopen=not_ready)
|
||||
failures.append("HTTP 204 was polled or accepted")
|
||||
except ValueError as exc:
|
||||
if "poll" not in str(exc):
|
||||
failures.append(f"HTTP 204 error was {exc}")
|
||||
|
||||
source = SCRIPT.read_text(encoding="utf-8")
|
||||
for banned in ("/apply", "/discard", "/runs"):
|
||||
if banned in source:
|
||||
failures.append(f"download client contains run-control path {banned}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def _scripted_https_handler(fixture: bytes, archive_url: str):
|
||||
calls: list[str] = []
|
||||
api_prefix = "https://app.terraform.io/api/v2/plans/"
|
||||
|
||||
class ScriptedHTTPSHandler(urllib.request.BaseHandler):
|
||||
handler_order = 100
|
||||
|
||||
def https_open(self, req: Request):
|
||||
url = req.full_url
|
||||
calls.append(url)
|
||||
headers = EmailMessage()
|
||||
if url.startswith(api_prefix):
|
||||
headers["Location"] = archive_url
|
||||
body = b""
|
||||
status = 307
|
||||
msg = "Temporary Redirect"
|
||||
elif url == archive_url:
|
||||
body = fixture
|
||||
status = 200
|
||||
msg = "OK"
|
||||
else:
|
||||
raise AssertionError(f"unexpected URL {url}")
|
||||
response = urllib.response.addinfourl(
|
||||
io.BytesIO(body),
|
||||
headers,
|
||||
url,
|
||||
code=status,
|
||||
)
|
||||
response.msg = msg
|
||||
return response
|
||||
|
||||
return ScriptedHTTPSHandler(), calls
|
||||
|
||||
|
||||
def test_download_standard_opener_redirect() -> list[str]:
|
||||
"""urllib follows the HCP 307; the guard must still inspect that first hop."""
|
||||
module = load_check_module()
|
||||
fixture = (FIXTURES / "version-only.json").read_bytes()
|
||||
archive_url = "https://archivist.terraform.io/v1/object/example"
|
||||
api_url = f"https://app.terraform.io/api/v2/plans/{PLAN_ID}/json-output"
|
||||
failures: list[str] = []
|
||||
|
||||
following_handler, following_calls = _scripted_https_handler(fixture, archive_url)
|
||||
followed = urllib.request.build_opener(following_handler).open(api_url)
|
||||
try:
|
||||
if followed.status != 200:
|
||||
failures.append(
|
||||
f"standard opener first status was {followed.status}, not 200"
|
||||
)
|
||||
if following_calls != [api_url, archive_url]:
|
||||
failures.append(f"standard opener URLs were {following_calls}")
|
||||
finally:
|
||||
followed.close()
|
||||
|
||||
guard_handler, guard_calls = _scripted_https_handler(fixture, archive_url)
|
||||
try:
|
||||
plan = module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
handlers=(guard_handler,),
|
||||
)
|
||||
except ValueError as exc:
|
||||
failures.append(f"no-redirect download failed: {exc}")
|
||||
return failures
|
||||
|
||||
if plan["resource_changes"][1]["address"] != (
|
||||
"module.environment.aws_elastic_beanstalk_environment.this"
|
||||
):
|
||||
failures.append("no-redirect download did not return the version-only fixture")
|
||||
if guard_calls != [api_url, archive_url]:
|
||||
failures.append(f"no-redirect download URLs were {guard_calls}")
|
||||
|
||||
following_urlopen_handler, _ = _scripted_https_handler(fixture, archive_url)
|
||||
following_urlopen = urllib.request.build_opener(following_urlopen_handler).open
|
||||
try:
|
||||
module.download_plan_json(
|
||||
PLAN_ID,
|
||||
"test-token",
|
||||
urlopen=following_urlopen,
|
||||
)
|
||||
failures.append("redirect-following urlopen was accepted as the first hop")
|
||||
except ValueError as exc:
|
||||
if "expected a redirect" not in str(exc):
|
||||
failures.append(f"following urlopen error was {exc}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cases = [
|
||||
("version-only", run_case("version-only.json"), 0),
|
||||
("wrong-label", run_case("wrong-label.json"), 1),
|
||||
("eb-setting-change", run_case("eb-setting-change.json"), 1),
|
||||
("nested-unknown-tags", run_case("nested-unknown-tags.json"), 1),
|
||||
("unknown-only-description", run_case("unknown-only-description.json"), 1),
|
||||
("iam-update", run_case("iam-update.json"), 1),
|
||||
("dns-update", run_case("dns-update.json"), 1),
|
||||
("create", run_case("create.json"), 1),
|
||||
("delete", run_case("delete.json"), 1),
|
||||
("replace", run_case("replace.json"), 1),
|
||||
("multiple-updates", run_case("multiple-updates.json"), 1),
|
||||
("empty", run_case("empty.json"), 1),
|
||||
]
|
||||
failures = [
|
||||
(name, result, expected)
|
||||
for name, result, expected in cases
|
||||
if result.returncode != expected
|
||||
]
|
||||
download_failures = test_download_pinning()
|
||||
redirect_failures = test_download_standard_opener_redirect()
|
||||
download_failures.extend(redirect_failures)
|
||||
if failures or download_failures:
|
||||
if failures:
|
||||
print(
|
||||
"FAIL: release plan-check cases failed: "
|
||||
+ ", ".join(name for name, _, _ in failures),
|
||||
file=sys.stderr,
|
||||
)
|
||||
for name, result, expected in failures:
|
||||
print(
|
||||
f"{name}: expected {expected}, got {result.returncode}\n"
|
||||
f"{result.stdout}{result.stderr}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for item in download_failures:
|
||||
print(f"FAIL: {item}", file=sys.stderr)
|
||||
return 1
|
||||
print("PASS: Terraform release plan safety checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
59
scripts/test_check_app_terraform_isolation.py
Normal file
59
scripts/test_check_app_terraform_isolation.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for check_app_terraform_isolation.isolation_violation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from check_app_terraform_isolation import isolation_violation
|
||||
|
||||
|
||||
class IsolationTests(unittest.TestCase):
|
||||
def test_terraform_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/README.md",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_app_only(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"Api.SeaHavenIndustries/Controllers/WorkOrderController.cs",
|
||||
".ebextensions/01_migrations.config",
|
||||
"scripts/package-elastic-beanstalk.sh",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_docs_and_workflows_with_terraform(self) -> None:
|
||||
self.assertIsNone(
|
||||
isolation_violation(
|
||||
[
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
".github/workflows/deploy.yaml",
|
||||
"QUALITY_GATES.md",
|
||||
"scripts/governance-check.sh",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_app_and_terraform_fails(self) -> None:
|
||||
violation = isolation_violation(
|
||||
[
|
||||
"terraform/live/dev/main.tf",
|
||||
"SeaHaven.Services/Implementation/WorkOrderService.cs",
|
||||
]
|
||||
)
|
||||
self.assertIsNotNone(violation)
|
||||
terraform_files, app_files = violation or ([], [])
|
||||
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
|
||||
self.assertTrue(any(path.endswith(".cs") for path in app_files))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
62
scripts/test_next_release_tag.py
Normal file
62
scripts/test_next_release_tag.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for next_release_tag.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from next_release_tag import (
|
||||
next_release_tag,
|
||||
parse_environment_from_tag,
|
||||
)
|
||||
|
||||
|
||||
class NextReleaseTagTests(unittest.TestCase):
|
||||
def test_first_patch_staging(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "staging", "patch"), "v0.0.1-staging")
|
||||
|
||||
def test_first_minor_prod(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "prod", "minor"), "v0.1.0")
|
||||
|
||||
def test_first_major_prod(self) -> None:
|
||||
self.assertEqual(next_release_tag([], "prod", "major"), "v1.0.0")
|
||||
|
||||
def test_staging_then_prod_same_core(self) -> None:
|
||||
tags = ["v1.2.3"]
|
||||
staging = next_release_tag(tags, "staging", "patch")
|
||||
self.assertEqual(staging, "v1.2.4-staging")
|
||||
prod = next_release_tag(tags + [staging], "prod", "patch")
|
||||
self.assertEqual(prod, "v1.2.4")
|
||||
|
||||
def test_staging_prerelease_does_not_raise_prod_base(self) -> None:
|
||||
tags = ["v1.2.3", "v9.9.9-staging"]
|
||||
self.assertEqual(next_release_tag(tags, "staging", "patch"), "v1.2.4-staging")
|
||||
|
||||
def test_duplicate_staging_fails(self) -> None:
|
||||
tags = ["v1.2.3", "v1.2.4-staging"]
|
||||
with self.assertRaises(ValueError):
|
||||
next_release_tag(tags, "staging", "patch")
|
||||
|
||||
def test_prod_after_staging_uses_same_core(self) -> None:
|
||||
tags = ["v1.2.3", "v9.9.9-staging"]
|
||||
self.assertEqual(next_release_tag(tags, "prod", "patch"), "v1.2.4")
|
||||
|
||||
def test_parse_environment(self) -> None:
|
||||
self.assertEqual(parse_environment_from_tag("v1.2.3-staging"), "staging")
|
||||
self.assertEqual(parse_environment_from_tag("v1.2.3"), "prod")
|
||||
|
||||
def test_reject_prefixed_and_prod_prerelease(self) -> None:
|
||||
for tag in (
|
||||
"staging-v1.2.3",
|
||||
"prod-v1.2.3",
|
||||
"v1.2.3-prod",
|
||||
"v1.2.3-staging.1",
|
||||
"v1.2",
|
||||
"1.2.3",
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_environment_from_tag(tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
112
scripts/test_require_commit_checks.py
Normal file
112
scripts/test_require_commit_checks.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for require_commit_checks.classify_checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from require_commit_checks import classify_checks
|
||||
|
||||
|
||||
class ClassifyChecksTests(unittest.TestCase):
|
||||
def test_success(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "success"},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "success")
|
||||
|
||||
def test_pending_missing(self) -> None:
|
||||
state, details = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "pending")
|
||||
self.assertTrue(any("architecture" in line for line in details))
|
||||
|
||||
def test_pending_in_progress(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "in_progress", "conclusion": None},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "pending")
|
||||
|
||||
def test_failure(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{"name": "Build and test", "status": "completed", "conclusion": "failure"},
|
||||
{"name": "architecture", "status": "completed", "conclusion": "success"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
|
||||
def test_latest_rerun_success_wins_over_older_failure(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 1,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:05:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
},
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 3,
|
||||
"started_at": "2026-09-16T12:10:00Z",
|
||||
"completed_at": "2026-09-16T12:12:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 2,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:04:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "success")
|
||||
|
||||
def test_latest_rerun_failure_wins_over_older_success(self) -> None:
|
||||
state, _ = classify_checks(
|
||||
[
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 9,
|
||||
"started_at": "2026-09-16T13:00:00Z",
|
||||
"completed_at": "2026-09-16T13:02:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
},
|
||||
{
|
||||
"name": "architecture",
|
||||
"id": 4,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:01:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
{
|
||||
"name": "Build and test",
|
||||
"id": 5,
|
||||
"started_at": "2026-09-16T12:00:00Z",
|
||||
"completed_at": "2026-09-16T12:03:00Z",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["create"],
|
||||
"before": null,
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["delete"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
|
||||
},
|
||||
"after": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_route53_record.api_alias[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_record",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"alias": [
|
||||
{
|
||||
"name": "awseb--awseb-cmpb3ypfib53-1654918745.us-east-1.elb.amazonaws.com",
|
||||
"zone_id": "Z35SXDOTRQ7X7K"
|
||||
}
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"alias": [
|
||||
{
|
||||
"name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com",
|
||||
"zone_id": "Z117KPS5GTRQ2G"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
||||
"name": "ASPNETCORE_ENVIRONMENT",
|
||||
"value": "Production"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:application:environment",
|
||||
"name": "ASPNETCORE_ENVIRONMENT",
|
||||
"value": "Development"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"resource_changes": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"permissions_boundary": "arn:aws:iam::396287094661:policy/shoc-backend-dev-deploy-boundary"
|
||||
},
|
||||
"after": {
|
||||
"permissions_boundary": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.github_deploy",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": { "description": "old" },
|
||||
"after": { "description": "new" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "prod", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true,
|
||||
"tags": { "env": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["delete", "create"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"name": "shoc-backend-dev"
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"name": "shoc-backend-dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true,
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_iam_role.runtime",
|
||||
"mode": "managed",
|
||||
"type": "aws_iam_role",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"before": { "name": "shoc-backend-dev" },
|
||||
"after": { "name": "shoc-backend-dev" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1",
|
||||
"setting": [
|
||||
{
|
||||
"namespace": "aws:elasticbeanstalk:environment",
|
||||
"name": "EnvironmentType",
|
||||
"value": "LoadBalanced"
|
||||
}
|
||||
],
|
||||
"tags": { "env": "dev", "project": "shoc" }
|
||||
},
|
||||
"after_unknown": {
|
||||
"instances": true,
|
||||
"load_balancers": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"resource_changes": [
|
||||
{
|
||||
"address": "module.environment.aws_elastic_beanstalk_environment.this",
|
||||
"mode": "managed",
|
||||
"type": "aws_elastic_beanstalk_environment",
|
||||
"change": {
|
||||
"actions": ["update"],
|
||||
"before": {
|
||||
"version_label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
},
|
||||
"after": {
|
||||
"version_label": "cccccccccccccccccccccccccccccccccccccccc-9-9",
|
||||
"setting": [],
|
||||
"tags": { "env": "dev" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -36,17 +36,20 @@ update, delete, or replacement actions. The second reviewed phase may update
|
|||
only explicitly allowlisted ownership metadata and the narrowed dev deploy S3
|
||||
policy.
|
||||
|
||||
The GitHub Environment secret `AWS_DEPLOY_ROLE_ARN` retains the existing role
|
||||
ARN throughout adoption.
|
||||
The GitHub Environment **variable** `DEPLOY_ROLE_ARN` is the OIDC role used
|
||||
by application CD. Environment secrets `TF_API_TOKEN` and
|
||||
`AWS_DEPLOY_ROLE_ARN` were removed at cutover.
|
||||
|
||||
## Local validation
|
||||
|
||||
```bash
|
||||
terraform -chdir=terraform fmt -check -recursive
|
||||
terraform fmt -check -recursive terraform
|
||||
terraform -chdir=terraform/live/dev init -backend=false
|
||||
terraform -chdir=terraform/live/dev validate
|
||||
terraform -chdir=terraform/live/staging init -backend=false
|
||||
terraform -chdir=terraform/live/staging validate
|
||||
python scripts/test-terraform-import-plan-check.py
|
||||
python scripts/test-terraform-release-plan-check.py
|
||||
python3 scripts/test_next_release_tag.py
|
||||
python3 scripts/test_require_commit_checks.py
|
||||
python3 scripts/test_check_app_terraform_isolation.py
|
||||
```
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ shared or Elastic Beanstalk-generated infrastructure.
|
|||
The shared `shoc-backend` Elastic Beanstalk application and
|
||||
`shoc-sqlserver-shared` RDS instance, VPC, subnets, EB service role, shared
|
||||
certificate, shared RDS security group, and EB-generated SG/ALB/ASG/CloudFormation
|
||||
resources must never enter an environment state.
|
||||
resources must never enter an environment state. Both roots leave
|
||||
`instance_security_group_id` null: the AWS provider reports the EB-generated
|
||||
`awseb-*-AWSEBSecurityGroup` as an empty `SecurityGroups` setting, so pinning it
|
||||
produces a permanent update diff.
|
||||
|
||||
Secret values are not Terraform resources, variables, outputs, or managed EB
|
||||
settings. Terraform manages the app-config secret shell and maps approved JSON
|
||||
|
|
@ -127,59 +130,62 @@ The measured self-contained .NET/EF bundle is approximately 199.5 MB and
|
|||
separate builds are not byte-identical. Each deploy job therefore validates the
|
||||
exact bundle it uploads; bundle bytes never enter Terraform plans or state.
|
||||
|
||||
## Dev application CD
|
||||
## Application CD
|
||||
|
||||
GitHub compiles, validates, and uploads the bundle, then creates the immutable
|
||||
Elastic Beanstalk application version. HCP Terraform is the only caller of
|
||||
`UpdateEnvironment`, by setting `version_label` on
|
||||
`module.environment.aws_elastic_beanstalk_environment.this`. GitHub then
|
||||
health-checks, smokes, and requests one guarded Terraform rollback. Terraform
|
||||
does not manage `aws_elastic_beanstalk_application_version`; retained versions
|
||||
are the rollback inventory.
|
||||
GitHub Actions owns application versions. It compiles the bundle, uploads it,
|
||||
creates the Elastic Beanstalk application version, and calls
|
||||
`UpdateEnvironment`. Terraform ignores `version_label` so those deploys are not
|
||||
drift. The non-legacy deploy policy writes bundles only under
|
||||
`shoc-backend/releases/<environment>/*`; the Elastic Beanstalk staging
|
||||
prefixes (`resources/_runtime/_embedded_extensions/shoc-backend/*` and
|
||||
`resources/environments/<env-id>/*`) keep the full object and ACL action set. If health, smoke, or the webhook probe fails after that update, the job
|
||||
restores the previous Elastic Beanstalk version label. Database migrations
|
||||
already applied by the failed bundle are not reverted. Deploy parameters are read from `/shoc-backend/<env>/deploy/*` SSM
|
||||
parameters this module writes.
|
||||
|
||||
`release_version_label` is a nullable root and module variable. Null VCS plans
|
||||
leave the live version unchanged. Application-CD runs pass the immutable
|
||||
`<full-sha>-<run-id>-<attempt>` label only as a run-specific
|
||||
`TF_VAR_release_version_label` HCL string. Do not set this variable on the
|
||||
workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new
|
||||
configuration version on application releases; `create-run` reuses the
|
||||
workspace's last applied VCS config. Global auto-apply stays off. GitHub
|
||||
`apply-run` treats an already-applied run as success so a mis-set auto-apply
|
||||
cannot start a false-failure rollback. The workspace stays branch-based on
|
||||
`dev` with Automatic Speculative Plans enabled and trigger patterns
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`. GitHub discards a
|
||||
leftover non-speculative VCS run before `create-run`, so a merge to `dev`
|
||||
cannot lock the workspace out from under GitHub CD. GitHub applies only after
|
||||
`plan-output` counts are `0/1/0` and
|
||||
`scripts/check-terraform-release-plan.py` accepts a version-only plan JSON.
|
||||
Merge to `main` deploys **dev** unless the push is terraform-only. Staging is
|
||||
cut from **Actions → Release** (`environment`, `bump`, `message`). That
|
||||
workflow waits for CI, tags `vX.Y.Z-staging` from main HEAD with
|
||||
`GITHUB_TOKEN`, then calls deploy. Do not cut prod yet; leave
|
||||
`PROD_APP_CD_ENABLED` unset and do not create the `prod` GitHub Environment.
|
||||
Staging import is proven after the first GitHub-owned zip
|
||||
(`v0.0.1-staging`). Terraform now manages the declared Elastic Beanstalk
|
||||
settings. The API CNAME stays pinned to the imported ALB target.
|
||||
|
||||
Staging application CD uses the same guarded lane against workspace
|
||||
`shoc-backend-staging`. The workspace stays branch-based on `staging` with
|
||||
trigger patterns `terraform/live/staging/**` and `terraform/live/modules/**`,
|
||||
and GitHub deploys on pushes to `staging` and on manual `workflow_dispatch`.
|
||||
HCP workspaces stay VCS-driven with auto-apply on after cutover. Speculative
|
||||
plans on every PR are the infra gate. Do not point `TFC_AWS_*` at
|
||||
`hcptf-bootstrap`. Org-baseline CloudFormation owns the HCP plan/apply roles.
|
||||
GitHub Actions does not create, wait on, or apply HCP runs. Application and
|
||||
Terraform changes stay in separate PRs so a merge cannot race an HCP apply
|
||||
against an app deploy. Terraform-only merges skip `deploy.yaml`. App-only
|
||||
tags skip HCP when trigger patterns do not match.
|
||||
|
||||
### Credentials and enablement
|
||||
### Credentials
|
||||
|
||||
Store dedicated HCP team tokens as the GitHub environment secret `TF_API_TOKEN`:
|
||||
Store `DEPLOY_ROLE_ARN` as a GitHub Environment **variable** (`dev`,
|
||||
`staging`). OIDC trust is
|
||||
`repo:Sea-Haven-Industries/shoc-backend:environment:<env>` plus
|
||||
`job_workflow_ref` for `.github/workflows/deploy.yaml` at `refs/heads/main`
|
||||
and `refs/tags/v*`. Adding another deploy workflow is a cross-family IAM
|
||||
change. The new CD path does not use `TF_API_TOKEN`.
|
||||
|
||||
- `dev`: use a token scoped only to workspace `shoc-backend-dev`.
|
||||
- `staging`: use a separate token scoped only to workspace
|
||||
`shoc-backend-staging`.
|
||||
GitHub Environment deployment branch and tag policies are repository
|
||||
settings, not this diff. The policy matches `GITHUB_REF` of the workflow run.
|
||||
Branch patterns never match tag refs; adding `v*` as a branch pattern fails
|
||||
the same way as an empty allowlist.
|
||||
|
||||
Plan JSON download requires workspace admin on the corresponding workspace. Do
|
||||
not grant project admin or workspace create/move/delete permissions. Do not rely
|
||||
on a repository-level token or reuse the dev-scoped token for staging. Rotate
|
||||
each token at least every 90 days.
|
||||
1. `dev` — allow branch `main`.
|
||||
2. `staging` — **tag-type** policy matching `v*.*.*-staging` for
|
||||
`deploy-tag.yaml`. Allow branch `main` because Actions → Release is
|
||||
`workflow_dispatch` on `main` and then calls `deploy.yaml`
|
||||
(`GITHUB_TOKEN` tag pushes do not start `deploy-tag.yaml`).
|
||||
|
||||
Repository variable `TERRAFORM_APP_CD_ENABLED` starts unset/false so pushes to
|
||||
`dev` do not deploy. `workflow_dispatch` on `dev` still runs a release for the
|
||||
first manual proof. Set the variable to `true` only after that proof confirms
|
||||
the exact version, a version-only plan, apply, `efbundle`, Ready/Green, smokes,
|
||||
and a retained previous version.
|
||||
|
||||
This change is the allowed exception that mixes deployable application CD with
|
||||
the Terraform variable that application CD needs. Later PRs must not mix
|
||||
deployable application changes with Terraform or CDK changes.
|
||||
Do not create the `prod` environment yet. Leave `PROD_APP_CD_ENABLED`
|
||||
unset. Until the `prod` environment exists with reviewers, do not run
|
||||
Actions → Release with `environment=prod`, do not push a bare `vX.Y.Z`
|
||||
tag, and do not `workflow_dispatch` deploy with `environment=prod`. Any
|
||||
of those declares `environment: prod` and would auto-create an
|
||||
unprotected environment.
|
||||
|
||||
## Pinned live identities
|
||||
|
||||
|
|
@ -194,11 +200,12 @@ identifiers make accidental cross-environment reuse fail review and planning.
|
|||
|
||||
## Safety invariants
|
||||
|
||||
- Auto-apply remains off.
|
||||
- VCS stays branch-based on `dev` for `shoc-backend-dev` and on `staging` for
|
||||
`shoc-backend-staging`, with speculative PR plans enabled and trigger
|
||||
patterns `terraform/live/dev/**` (dev) and `terraform/live/staging/**`
|
||||
(staging), each alongside `terraform/live/modules/**`. Do not switch
|
||||
Automatic Run Triggering to tag-based.
|
||||
- Org baseline owns final HCP plan/apply permissions and manager tags.
|
||||
- After cutover, auto-apply is on. Speculative PR plans stay on.
|
||||
- `shoc-backend-dev` is branch-based on `main` with trigger patterns
|
||||
`terraform/live/dev/**` and `terraform/live/modules/**`.
|
||||
- `shoc-backend-staging` is tag-based on `^v\d+\.\d+\.\d+-staging$` with
|
||||
trigger patterns `terraform/live/staging/**` and `terraform/live/modules/**`.
|
||||
- Org baseline owns HCP plan/apply permissions and manager tags. Never manage
|
||||
`hcptf-*` in this repository.
|
||||
- Every imported Terraform resource has `prevent_destroy`.
|
||||
- Elastic Beanstalk `version_label` is ignored so GitHub deploys are not drift.
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ module "environment" {
|
|||
github_deploy_role_name = "githubdeploy-shoc-backend-dev"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = true
|
||||
release_version_label = var.release_version_label
|
||||
smoke_url = "https://api.dev.seahaven.com"
|
||||
hosted_zone_id = "Z07671212N75U4YLPWZR8"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "A"
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ locals {
|
|||
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
|
||||
use_legacy_s3_policy = var.legacy_dev_s3_policy
|
||||
app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*"
|
||||
deploy_ssm_prefix = "/shoc-backend/${var.environment}/deploy"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "runtime_assume" {
|
||||
|
|
@ -177,6 +178,18 @@ data "aws_iam_policy_document" "deploy_assume" {
|
|||
variable = "token.actions.githubusercontent.com:sub"
|
||||
values = ["repo:${var.github_repo}:environment:${var.github_environment}"]
|
||||
}
|
||||
|
||||
# StringLike: a tag-loaded reusable workflow uses @refs/tags/v*, while
|
||||
# push and workflow_dispatch use @refs/heads/main. Adding a deploy
|
||||
# workflow means adding its ref here (cross-family IAM).
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||
values = [
|
||||
"${var.github_repo}/.github/workflows/deploy.yaml@refs/heads/main",
|
||||
"${var.github_repo}/.github/workflows/deploy.yaml@refs/tags/v*",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,23 +289,77 @@ data "aws_iam_policy_document" "deploy" {
|
|||
}
|
||||
}
|
||||
|
||||
# deploy.yaml uploads each bundle to
|
||||
# <app>/releases/<environment>/<sha>/<run>/site.zip and Elastic Beanstalk
|
||||
# reads it back from there. The deploy role never writes another
|
||||
# environment's release prefix.
|
||||
dynamic "statement" {
|
||||
for_each = local.use_legacy_s3_policy ? [] : [1]
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["s3:PutObject"]
|
||||
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
|
||||
sid = "UploadReleaseBundle"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:PutObject",
|
||||
"s3:GetObject",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/releases/${var.environment}/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Elastic Beanstalk stages the processed version, embedded extensions,
|
||||
# and manifests under environment-scoped prefixes and requires object ACLs
|
||||
# on this BucketOwnerPreferred bucket.
|
||||
dynamic "statement" {
|
||||
for_each = local.use_legacy_s3_policy ? [] : [1]
|
||||
content {
|
||||
sid = "ManageEnvironmentArtifacts"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectAcl",
|
||||
"s3:PutObjectVersionAcl",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAcl",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:GetObjectVersionAcl",
|
||||
"s3:DeleteObject",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:s3:::${local.eb_bucket_name}/resources/_runtime/_embedded_extensions/${var.eb_application_name}/*",
|
||||
"arn:aws:s3:::${local.eb_bucket_name}/resources/environments/${var.eb_environment_id}/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = local.use_legacy_s3_policy ? [] : [1]
|
||||
content {
|
||||
effect = "Allow"
|
||||
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:ListBucket",
|
||||
"s3:GetBucketPolicy",
|
||||
"s3:GetBucketAcl",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:GetBucketOwnershipControls",
|
||||
]
|
||||
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ReadDeployParameters"
|
||||
effect = "Allow"
|
||||
actions = [
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters",
|
||||
]
|
||||
resources = [
|
||||
"arn:aws:ssm:${var.aws_region}:${var.aws_account_id}:parameter/shoc-backend/${var.environment}/deploy/*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "github_deploy" {
|
||||
|
|
@ -305,6 +372,34 @@ resource "aws_iam_role_policy" "github_deploy" {
|
|||
}
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_application_name" {
|
||||
name = "${local.deploy_ssm_prefix}/application-name"
|
||||
type = "String"
|
||||
value = var.eb_application_name
|
||||
description = "Elastic Beanstalk application name; GitHub CD creates application versions here"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_environment_name" {
|
||||
name = "${local.deploy_ssm_prefix}/environment-name"
|
||||
type = "String"
|
||||
value = var.eb_environment_name
|
||||
description = "Elastic Beanstalk environment name; GitHub CD calls UpdateEnvironment"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_artifacts_bucket" {
|
||||
name = "${local.deploy_ssm_prefix}/artifacts-bucket"
|
||||
type = "String"
|
||||
value = local.eb_bucket_name
|
||||
description = "Bucket for release zips; GitHub CD uploads site.zip here"
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "deploy_smoke_url" {
|
||||
name = "${local.deploy_ssm_prefix}/smoke-url"
|
||||
type = "String"
|
||||
value = var.smoke_url
|
||||
description = "HTTPS origin for post-deploy smoke checks"
|
||||
}
|
||||
|
||||
locals {
|
||||
managed_eb_settings = concat(
|
||||
[
|
||||
|
|
@ -444,16 +539,13 @@ locals {
|
|||
}
|
||||
|
||||
resource "aws_elastic_beanstalk_environment" "this" {
|
||||
# Null VCS plans omit this Optional+Computed argument, so the provider
|
||||
# refreshes the live label without reverting releases. Application-CD runs
|
||||
# pass an immutable <full-sha>-<run-id>-<attempt> value as a run-specific
|
||||
# TF_VAR_release_version_label.
|
||||
name = var.eb_environment_name
|
||||
application = var.eb_application_name
|
||||
platform_arn = var.platform_arn
|
||||
version_label = var.release_version_label
|
||||
tier = "WebServer"
|
||||
cname_prefix = var.eb_environment_name
|
||||
# GitHub Actions owns application versions via UpdateEnvironment.
|
||||
# version_label is ignored so app deploys are not Terraform drift.
|
||||
name = var.eb_environment_name
|
||||
application = var.eb_application_name
|
||||
platform_arn = var.platform_arn
|
||||
tier = "WebServer"
|
||||
cname_prefix = var.eb_environment_name
|
||||
|
||||
dynamic "setting" {
|
||||
for_each = var.manage_eb_settings ? local.managed_eb_settings : []
|
||||
|
|
@ -471,6 +563,7 @@ resource "aws_elastic_beanstalk_environment" "this" {
|
|||
prevent_destroy = true
|
||||
ignore_changes = [
|
||||
wait_for_ready_timeout,
|
||||
version_label,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,20 +200,9 @@ variable "legacy_dev_s3_policy" {
|
|||
default = false
|
||||
}
|
||||
|
||||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
variable "smoke_url" {
|
||||
type = string
|
||||
description = "HTTPS origin used by post-deploy smoke checks. Written to SSM for GitHub Actions."
|
||||
}
|
||||
|
||||
variable "hosted_zone_id" {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ module "environment" {
|
|||
aws_account_id = local.aws_account_id
|
||||
aws_region = local.aws_region
|
||||
environment = "staging"
|
||||
adoption_complete = false
|
||||
manage_eb_settings = false
|
||||
adoption_complete = true
|
||||
manage_eb_settings = true
|
||||
eb_application_name = local.eb_application_name
|
||||
eb_environment_name = local.eb_environment_name
|
||||
eb_environment_id = local.eb_environment_id
|
||||
|
|
@ -35,7 +35,7 @@ module "environment" {
|
|||
vpc_id = "vpc-0d16336143f3da25e"
|
||||
instance_subnet_ids = ["subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f", "subnet-09eaf2bfa468d206f"]
|
||||
load_balancer_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"]
|
||||
instance_security_group_id = "sg-02ea36a6719217fa2"
|
||||
instance_security_group_id = null
|
||||
eb_service_role_name = "shoc-eb-service-role"
|
||||
shared_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
|
||||
runtime_role_name = "shoc-backend-staging"
|
||||
|
|
@ -58,11 +58,11 @@ module "environment" {
|
|||
github_deploy_role_name = "githubdeploy-shoc-backend-staging"
|
||||
github_deploy_policy_name = "GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
legacy_dev_s3_policy = false
|
||||
smoke_url = "https://api.staging.seahaven.com"
|
||||
hosted_zone_id = "Z02602739VQWBWCAGXP4"
|
||||
api_domain = local.api_domain
|
||||
api_record_type = "CNAME"
|
||||
api_cname_target = "awseb--AWSEB-pPXqiRgNnZe8-16996010.us-east-1.elb.amazonaws.com"
|
||||
release_version_label = var.release_version_label
|
||||
metadata_before_adoption = {
|
||||
runtime_role_description = "SHOC backend staging compute role (EB instance profile)"
|
||||
runtime_role_tags = {
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
variable "release_version_label" {
|
||||
type = string
|
||||
default = null
|
||||
nullable = true
|
||||
|
||||
description = "Immutable Elastic Beanstalk application version. Null VCS plans leave the live version unchanged."
|
||||
|
||||
validation {
|
||||
condition = (
|
||||
var.release_version_label == null ||
|
||||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
|
||||
)
|
||||
error_message = "release_version_label must be <full-sha>-<run-id>-<attempt>."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue